mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: support devcontainer agents in ui and unify backend (#18332)
This commit consolidates two container endpoints on the backend and improves the frontend devcontainer support by showing names and displaying apps as appropriate. With this change, the frontend now has knowledge of the subagent and we can also display things like port forwards. The frontend was updated to show dev container labels on the border as well as subagent connection status. The recreation flow was also adjusted a bit to show placeholder app icons when relevant. Support for apps was also added, although these are still WIP on the backend. And the port forwarding utility was added in since the sub agents now provide the necessary info. Fixes coder/internal#666
This commit is contained in:
Generated
+9
-7
@@ -3354,8 +3354,6 @@ export interface WorkspaceAgentContainer {
|
||||
readonly ports: readonly WorkspaceAgentContainerPort[];
|
||||
readonly status: string;
|
||||
readonly volumes: Record<string, string>;
|
||||
readonly devcontainer_status?: WorkspaceAgentDevcontainerStatus;
|
||||
readonly devcontainer_dirty: boolean;
|
||||
}
|
||||
|
||||
// From codersdk/workspaceagents.go
|
||||
@@ -3375,6 +3373,14 @@ export interface WorkspaceAgentDevcontainer {
|
||||
readonly status: WorkspaceAgentDevcontainerStatus;
|
||||
readonly dirty: boolean;
|
||||
readonly container?: WorkspaceAgentContainer;
|
||||
readonly agent?: WorkspaceAgentDevcontainerAgent;
|
||||
}
|
||||
|
||||
// From codersdk/workspaceagents.go
|
||||
export interface WorkspaceAgentDevcontainerAgent {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly directory: string;
|
||||
}
|
||||
|
||||
// From codersdk/workspaceagents.go
|
||||
@@ -3387,11 +3393,6 @@ export type WorkspaceAgentDevcontainerStatus =
|
||||
export const WorkspaceAgentDevcontainerStatuses: WorkspaceAgentDevcontainerStatus[] =
|
||||
["error", "running", "starting", "stopped"];
|
||||
|
||||
// From codersdk/workspaceagents.go
|
||||
export interface WorkspaceAgentDevcontainersResponse {
|
||||
readonly devcontainers: readonly WorkspaceAgentDevcontainer[];
|
||||
}
|
||||
|
||||
// From codersdk/workspaceagents.go
|
||||
export interface WorkspaceAgentHealth {
|
||||
readonly healthy: boolean;
|
||||
@@ -3424,6 +3425,7 @@ export const WorkspaceAgentLifecycles: WorkspaceAgentLifecycle[] = [
|
||||
|
||||
// From codersdk/workspaceagents.go
|
||||
export interface WorkspaceAgentListContainersResponse {
|
||||
readonly devcontainers: readonly WorkspaceAgentDevcontainer[];
|
||||
readonly containers: readonly WorkspaceAgentContainer[];
|
||||
readonly warnings?: readonly string[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { WorkspaceApp } from "api/typesGenerated";
|
||||
import type { Workspace, WorkspaceAgent } from "api/typesGenerated";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "components/DropdownMenu/DropdownMenu";
|
||||
import { Folder } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { AgentButton } from "../AgentButton";
|
||||
import { AppLink } from "../AppLink/AppLink";
|
||||
|
||||
type AgentAppsProps = {
|
||||
section: AgentAppSection;
|
||||
agent: WorkspaceAgent;
|
||||
workspace: Workspace;
|
||||
};
|
||||
|
||||
export const AgentApps: FC<AgentAppsProps> = ({
|
||||
section,
|
||||
agent,
|
||||
workspace,
|
||||
}) => {
|
||||
return section.group ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<AgentButton>
|
||||
<Folder />
|
||||
{section.group}
|
||||
</AgentButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{section.apps.map((app) => (
|
||||
<DropdownMenuItem key={app.slug}>
|
||||
<AppLink grouped app={app} agent={agent} workspace={workspace} />
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<>
|
||||
{section.apps.map((app) => (
|
||||
<AppLink key={app.slug} app={app} agent={agent} workspace={workspace} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type AgentAppSection = {
|
||||
/**
|
||||
* If there is no `group`, just render all of the apps inline. If there is a
|
||||
* group name, show them all in a dropdown.
|
||||
*/
|
||||
group?: string;
|
||||
|
||||
apps: WorkspaceApp[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups apps by their `group` property. Apps with the same group are placed
|
||||
* in the same section. Apps without a group are placed in their own section.
|
||||
*
|
||||
* The algorithm assumes that apps are already sorted by group, meaning that
|
||||
* every ungrouped section is expected to have a group in between, to make the
|
||||
* algorithm a little simpler to implement.
|
||||
*/
|
||||
export function organizeAgentApps(
|
||||
apps: readonly WorkspaceApp[],
|
||||
): AgentAppSection[] {
|
||||
let currentSection: AgentAppSection | undefined = undefined;
|
||||
const appGroups: AgentAppSection[] = [];
|
||||
const groupsByName = new Map<string, AgentAppSection>();
|
||||
|
||||
for (const app of apps) {
|
||||
if (app.hidden) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentSection || app.group !== currentSection.group) {
|
||||
const existingSection = groupsByName.get(app.group!);
|
||||
if (existingSection) {
|
||||
currentSection = existingSection;
|
||||
} else {
|
||||
currentSection = {
|
||||
group: app.group,
|
||||
apps: [],
|
||||
};
|
||||
appGroups.push(currentSection);
|
||||
if (app.group) {
|
||||
groupsByName.set(app.group, currentSection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentSection.apps.push(app);
|
||||
}
|
||||
|
||||
return appGroups;
|
||||
}
|
||||
@@ -1,20 +1,45 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { getPreferredProxy } from "contexts/ProxyContext";
|
||||
import { chromatic } from "testHelpers/chromatic";
|
||||
import {
|
||||
MockListeningPortsResponse,
|
||||
MockPrimaryWorkspaceProxy,
|
||||
MockTemplate,
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
MockWorkspaceAgentContainer,
|
||||
MockWorkspaceAgentContainerPorts,
|
||||
MockWorkspaceAgentDevcontainer,
|
||||
MockWorkspaceApp,
|
||||
MockWorkspaceProxies,
|
||||
MockWorkspaceSubAgent,
|
||||
} from "testHelpers/entities";
|
||||
import {
|
||||
withDashboardProvider,
|
||||
withProxyProvider,
|
||||
} from "testHelpers/storybook";
|
||||
import { AgentDevcontainerCard } from "./AgentDevcontainerCard";
|
||||
|
||||
const meta: Meta<typeof AgentDevcontainerCard> = {
|
||||
title: "modules/resources/AgentDevcontainerCard",
|
||||
component: AgentDevcontainerCard,
|
||||
args: {
|
||||
container: MockWorkspaceAgentContainer,
|
||||
devcontainer: MockWorkspaceAgentDevcontainer,
|
||||
workspace: MockWorkspace,
|
||||
wildcardHostname: "*.wildcard.hostname",
|
||||
agent: MockWorkspaceAgent,
|
||||
parentAgent: MockWorkspaceAgent,
|
||||
template: MockTemplate,
|
||||
subAgents: [MockWorkspaceSubAgent],
|
||||
},
|
||||
decorators: [withProxyProvider(), withDashboardProvider],
|
||||
parameters: {
|
||||
chromatic,
|
||||
queries: [
|
||||
{
|
||||
key: ["portForward", MockWorkspaceSubAgent.id],
|
||||
data: MockListeningPortsResponse,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -25,30 +50,81 @@ export const NoPorts: Story = {};
|
||||
|
||||
export const WithPorts: Story = {
|
||||
args: {
|
||||
container: {
|
||||
...MockWorkspaceAgentContainer,
|
||||
ports: MockWorkspaceAgentContainerPorts,
|
||||
devcontainer: {
|
||||
...MockWorkspaceAgentDevcontainer,
|
||||
container: {
|
||||
...MockWorkspaceAgentContainer,
|
||||
ports: MockWorkspaceAgentContainerPorts,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Dirty: Story = {
|
||||
args: {
|
||||
container: {
|
||||
...MockWorkspaceAgentContainer,
|
||||
devcontainer_dirty: true,
|
||||
ports: MockWorkspaceAgentContainerPorts,
|
||||
devcontainer: {
|
||||
...MockWorkspaceAgentDevcontainer,
|
||||
dirty: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Recreating: Story = {
|
||||
args: {
|
||||
container: {
|
||||
...MockWorkspaceAgentContainer,
|
||||
devcontainer_dirty: true,
|
||||
devcontainer_status: "starting",
|
||||
ports: MockWorkspaceAgentContainerPorts,
|
||||
devcontainer: {
|
||||
...MockWorkspaceAgentDevcontainer,
|
||||
dirty: true,
|
||||
status: "starting",
|
||||
container: undefined,
|
||||
},
|
||||
subAgents: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const NoSubAgent: Story = {
|
||||
args: {
|
||||
devcontainer: {
|
||||
...MockWorkspaceAgentDevcontainer,
|
||||
agent: undefined,
|
||||
},
|
||||
subAgents: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const SubAgentConnecting: Story = {
|
||||
args: {
|
||||
subAgents: [
|
||||
{
|
||||
...MockWorkspaceSubAgent,
|
||||
status: "connecting",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAppsAndPorts: Story = {
|
||||
args: {
|
||||
devcontainer: {
|
||||
...MockWorkspaceAgentDevcontainer,
|
||||
container: {
|
||||
...MockWorkspaceAgentContainer,
|
||||
ports: MockWorkspaceAgentContainerPorts,
|
||||
},
|
||||
},
|
||||
subAgents: [
|
||||
{
|
||||
...MockWorkspaceSubAgent,
|
||||
apps: [MockWorkspaceApp],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithPortForwarding: Story = {
|
||||
decorators: [
|
||||
withProxyProvider({
|
||||
proxy: getPreferredProxy(MockWorkspaceProxies, MockPrimaryWorkspaceProxy),
|
||||
proxies: MockWorkspaceProxies,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,117 +1,245 @@
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import type {
|
||||
Template,
|
||||
Workspace,
|
||||
WorkspaceAgent,
|
||||
WorkspaceAgentContainer,
|
||||
WorkspaceAgentDevcontainer,
|
||||
WorkspaceAgentListContainersResponse,
|
||||
} from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import {
|
||||
HelpTooltip,
|
||||
HelpTooltipContent,
|
||||
HelpTooltipText,
|
||||
HelpTooltipTitle,
|
||||
HelpTooltipTrigger,
|
||||
} from "components/HelpTooltip/HelpTooltip";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useProxy } from "contexts/ProxyContext";
|
||||
import { Container, ExternalLinkIcon } from "lucide-react";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
import { AppStatuses } from "pages/WorkspacePage/AppStatuses";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useMutation, useQueryClient } from "react-query";
|
||||
import { portForwardURL } from "utils/portForward";
|
||||
import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps";
|
||||
import { AgentButton } from "./AgentButton";
|
||||
import { AgentDevcontainerSSHButton } from "./SSHButton/SSHButton";
|
||||
import { AgentLatency } from "./AgentLatency";
|
||||
import { SubAgentStatus } from "./AgentStatus";
|
||||
import { PortForwardButton } from "./PortForwardButton";
|
||||
import { AgentSSHButton } from "./SSHButton/SSHButton";
|
||||
import { SubAgentOutdatedTooltip } from "./SubAgentOutdatedTooltip";
|
||||
import { TerminalLink } from "./TerminalLink/TerminalLink";
|
||||
import { VSCodeDevContainerButton } from "./VSCodeDevContainerButton/VSCodeDevContainerButton";
|
||||
|
||||
type AgentDevcontainerCardProps = {
|
||||
agent: WorkspaceAgent;
|
||||
container: WorkspaceAgentContainer;
|
||||
parentAgent: WorkspaceAgent;
|
||||
subAgents: WorkspaceAgent[];
|
||||
devcontainer: WorkspaceAgentDevcontainer;
|
||||
workspace: Workspace;
|
||||
template: Template;
|
||||
wildcardHostname: string;
|
||||
};
|
||||
|
||||
export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
|
||||
agent,
|
||||
container,
|
||||
parentAgent,
|
||||
subAgents,
|
||||
devcontainer,
|
||||
workspace,
|
||||
template,
|
||||
wildcardHostname,
|
||||
}) => {
|
||||
const folderPath = container.labels["devcontainer.local_folder"];
|
||||
const containerFolder = container.volumes[folderPath];
|
||||
const [isRecreating, setIsRecreating] = useState(false);
|
||||
const { browser_only } = useFeatureVisibility();
|
||||
const { proxy } = useProxy();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleRecreateDevcontainer = async () => {
|
||||
setIsRecreating(true);
|
||||
let recreateSucceeded = false;
|
||||
try {
|
||||
// The sub agent comes from the workspace response whereas the devcontainer
|
||||
// comes from the agent containers endpoint. We need alignment between the
|
||||
// two, so if the sub agent is not present or the IDs do not match, we
|
||||
// assume it has been removed.
|
||||
const subAgent = subAgents.find((sub) => sub.id === devcontainer.agent?.id);
|
||||
|
||||
const appSections = (subAgent && organizeAgentApps(subAgent.apps)) || [];
|
||||
const displayApps =
|
||||
subAgent?.display_apps.filter((app) => {
|
||||
if (browser_only) {
|
||||
return ["web_terminal", "port_forwarding_helper"].includes(app);
|
||||
}
|
||||
return true;
|
||||
}) || [];
|
||||
const showVSCode =
|
||||
devcontainer.container &&
|
||||
(displayApps.includes("vscode") || displayApps.includes("vscode_insiders"));
|
||||
const hasAppsToDisplay =
|
||||
displayApps.includes("web_terminal") ||
|
||||
showVSCode ||
|
||||
appSections.some((it) => it.apps.length > 0);
|
||||
|
||||
const rebuildDevcontainerMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await fetch(
|
||||
`/api/v2/workspaceagents/${agent.id}/containers/devcontainers/container/${container.id}/recreate`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
`/api/v2/workspaceagents/${parentAgent.id}/containers/devcontainers/container/${devcontainer.container?.id}/recreate`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.message || `Failed to recreate: ${response.statusText}`,
|
||||
errorData.message || `Failed to rebuild: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
// If the request was accepted (e.g. 202), we mark it as succeeded.
|
||||
// Once complete, the component will unmount, so the spinner will
|
||||
// disappear with it.
|
||||
if (response.status === 202) {
|
||||
recreateSucceeded = true;
|
||||
return response;
|
||||
},
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["agents", parentAgent.id, "containers"],
|
||||
});
|
||||
|
||||
// Snapshot the previous data for rollback in case of error.
|
||||
const previousData = queryClient.getQueryData([
|
||||
"agents",
|
||||
parentAgent.id,
|
||||
"containers",
|
||||
]);
|
||||
|
||||
// Optimistically update the devcontainer status to
|
||||
// "starting" and zero the agent and container to mimic what
|
||||
// the API does.
|
||||
queryClient.setQueryData(
|
||||
["agents", parentAgent.id, "containers"],
|
||||
(oldData?: WorkspaceAgentListContainersResponse) => {
|
||||
if (!oldData?.devcontainers) return oldData;
|
||||
return {
|
||||
...oldData,
|
||||
devcontainers: oldData.devcontainers.map((dc) => {
|
||||
if (dc.id === devcontainer.id) {
|
||||
return {
|
||||
...dc,
|
||||
agent: null,
|
||||
container: null,
|
||||
status: "starting",
|
||||
};
|
||||
}
|
||||
return dc;
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { previousData };
|
||||
},
|
||||
onSuccess: async () => {
|
||||
// Invalidate the containers query to refetch updated data.
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["agents", parentAgent.id, "containers"],
|
||||
});
|
||||
},
|
||||
onError: (error, _, context) => {
|
||||
// If the mutation fails, use the context returned from
|
||||
// onMutate to roll back.
|
||||
if (context?.previousData) {
|
||||
queryClient.setQueryData(
|
||||
["agents", parentAgent.id, "containers"],
|
||||
context.previousData,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "An unknown error occurred.";
|
||||
displayError(`Failed to recreate devcontainer: ${errorMessage}`);
|
||||
console.error("Failed to recreate devcontainer:", error);
|
||||
} finally {
|
||||
if (!recreateSucceeded) {
|
||||
setIsRecreating(false);
|
||||
}
|
||||
displayError(`Failed to rebuild devcontainer: ${errorMessage}`);
|
||||
console.error("Failed to rebuild devcontainer:", error);
|
||||
},
|
||||
});
|
||||
|
||||
// Re-fetch containers when the subAgent changes to ensure data is
|
||||
// in sync. This relies on agent updates being pushed to the client
|
||||
// to trigger the re-fetch. That is why we match on name here
|
||||
// instead of ID as we need to fetch to get an up-to-date ID.
|
||||
const latestSubAgentByName = subAgents.find(
|
||||
(agent) => agent.name === devcontainer.name,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!latestSubAgentByName?.id || !latestSubAgentByName?.status) {
|
||||
return;
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["agents", parentAgent.id, "containers"],
|
||||
});
|
||||
}, [
|
||||
latestSubAgentByName?.id,
|
||||
latestSubAgentByName?.status,
|
||||
queryClient,
|
||||
parentAgent.id,
|
||||
]);
|
||||
|
||||
const showDevcontainerControls = subAgent && devcontainer.container;
|
||||
const showSubAgentApps =
|
||||
devcontainer.status !== "starting" &&
|
||||
subAgent?.status === "connected" &&
|
||||
hasAppsToDisplay;
|
||||
const showSubAgentAppsPlaceholders =
|
||||
devcontainer.status === "starting" || subAgent?.status === "connecting";
|
||||
|
||||
const handleRebuildDevcontainer = () => {
|
||||
rebuildDevcontainerMutation.mutate();
|
||||
};
|
||||
|
||||
// If the container is starting, reflect this in the recreate button.
|
||||
useEffect(() => {
|
||||
if (container.devcontainer_status === "starting") {
|
||||
setIsRecreating(true);
|
||||
} else {
|
||||
setIsRecreating(false);
|
||||
}
|
||||
}, [container.devcontainer_status]);
|
||||
const appsClasses = "flex flex-wrap gap-4 empty:hidden md:justify-start";
|
||||
|
||||
return (
|
||||
<section
|
||||
className="border border-border border-dashed rounded p-6 "
|
||||
key={container.id}
|
||||
<Stack
|
||||
key={devcontainer.id}
|
||||
direction="column"
|
||||
spacing={0}
|
||||
className="relative py-4 border border-dashed border-border rounded"
|
||||
>
|
||||
<header className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 text-xs font-medium text-content-secondary">
|
||||
dev container:{" "}
|
||||
<span className="font-semibold">{container.name}</span>
|
||||
</h3>
|
||||
{container.devcontainer_dirty && (
|
||||
<HelpTooltip>
|
||||
<HelpTooltipTrigger className="flex items-center text-xs text-content-warning ml-2">
|
||||
<span>Outdated</span>
|
||||
</HelpTooltipTrigger>
|
||||
<HelpTooltipContent>
|
||||
<HelpTooltipTitle>Devcontainer Outdated</HelpTooltipTitle>
|
||||
<HelpTooltipText>
|
||||
Devcontainer configuration has been modified and is outdated.
|
||||
Recreate to get an up-to-date container.
|
||||
</HelpTooltipText>
|
||||
</HelpTooltipContent>
|
||||
</HelpTooltip>
|
||||
<div
|
||||
className="absolute -top-2 left-5
|
||||
flex items-center gap-2
|
||||
bg-surface-primary px-2
|
||||
text-xs text-content-secondary"
|
||||
>
|
||||
<Container size={12} className="mr-1.5" />
|
||||
<span>dev container</span>
|
||||
</div>
|
||||
<header
|
||||
className="flex items-center justify-between flex-wrap
|
||||
gap-6 px-4 pl-8 leading-6
|
||||
md:gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-6 text-xs text-content-secondary">
|
||||
<div className="flex items-center gap-4 md:w-full">
|
||||
<SubAgentStatus agent={subAgent} />
|
||||
<span
|
||||
className="max-w-xs shrink-0
|
||||
overflow-hidden text-ellipsis whitespace-nowrap
|
||||
text-sm font-semibold text-content-primary
|
||||
md:overflow-visible"
|
||||
>
|
||||
{subAgent?.name ?? devcontainer.name}
|
||||
{devcontainer.container && (
|
||||
<span className="text-content-tertiary">
|
||||
{" "}
|
||||
({devcontainer.container.name})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{subAgent?.status === "connected" && (
|
||||
<>
|
||||
<SubAgentOutdatedTooltip
|
||||
devcontainer={devcontainer}
|
||||
agent={subAgent}
|
||||
onUpdate={handleRebuildDevcontainer}
|
||||
/>
|
||||
<AgentLatency agent={subAgent} />
|
||||
</>
|
||||
)}
|
||||
{subAgent?.status === "connecting" && (
|
||||
<>
|
||||
<Skeleton width={160} variant="text" />
|
||||
<Skeleton width={36} variant="text" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -119,73 +247,129 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRecreateDevcontainer}
|
||||
disabled={isRecreating}
|
||||
onClick={handleRebuildDevcontainer}
|
||||
disabled={devcontainer.status === "starting"}
|
||||
>
|
||||
<Spinner loading={isRecreating} />
|
||||
Recreate
|
||||
<Spinner loading={devcontainer.status === "starting"} />
|
||||
Rebuild
|
||||
</Button>
|
||||
|
||||
<AgentDevcontainerSSHButton
|
||||
workspace={workspace.name}
|
||||
container={container.name}
|
||||
/>
|
||||
{showDevcontainerControls && displayApps.includes("ssh_helper") && (
|
||||
<AgentSSHButton
|
||||
workspaceName={workspace.name}
|
||||
agentName={subAgent.name}
|
||||
workspaceOwnerUsername={workspace.owner_name}
|
||||
/>
|
||||
)}
|
||||
{showDevcontainerControls &&
|
||||
displayApps.includes("port_forwarding_helper") &&
|
||||
proxy.preferredWildcardHostname !== "" && (
|
||||
<PortForwardButton
|
||||
host={proxy.preferredWildcardHostname}
|
||||
workspace={workspace}
|
||||
agent={subAgent}
|
||||
template={template}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h4 className="m-0 text-xl font-semibold mb-2">Forwarded ports</h4>
|
||||
{(showSubAgentApps || showSubAgentAppsPlaceholders) && (
|
||||
<div className="flex flex-col gap-8 px-8 pt-4">
|
||||
{subAgent &&
|
||||
workspace.latest_app_status?.agent_id === subAgent.id && (
|
||||
<section>
|
||||
<h3 className="sr-only">App statuses</h3>
|
||||
<AppStatuses workspace={workspace} agent={subAgent} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4 flex-wrap mt-4">
|
||||
<VSCodeDevContainerButton
|
||||
userName={workspace.owner_name}
|
||||
workspaceName={workspace.name}
|
||||
devContainerName={container.name}
|
||||
devContainerFolder={containerFolder}
|
||||
displayApps={agent.display_apps}
|
||||
agentName={agent.name}
|
||||
/>
|
||||
{showSubAgentApps && (
|
||||
<section className={appsClasses}>
|
||||
<>
|
||||
{showVSCode && (
|
||||
<VSCodeDevContainerButton
|
||||
userName={workspace.owner_name}
|
||||
workspaceName={workspace.name}
|
||||
devContainerName={devcontainer.container.name}
|
||||
devContainerFolder={subAgent?.directory ?? ""}
|
||||
displayApps={displayApps} // TODO(mafredri): We could use subAgent display apps here but we currently set none.
|
||||
agentName={parentAgent.name}
|
||||
/>
|
||||
)}
|
||||
{appSections.map((section, i) => (
|
||||
<AgentApps
|
||||
key={section.group ?? i}
|
||||
section={section}
|
||||
agent={subAgent}
|
||||
workspace={workspace}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
<TerminalLink
|
||||
workspaceName={workspace.name}
|
||||
agentName={agent.name}
|
||||
containerName={container.name}
|
||||
userName={workspace.owner_name}
|
||||
/>
|
||||
{wildcardHostname !== "" &&
|
||||
container.ports.map((port) => {
|
||||
const portLabel = `${port.port}/${port.network.toUpperCase()}`;
|
||||
const hasHostBind =
|
||||
port.host_port !== undefined && port.host_ip !== undefined;
|
||||
const helperText = hasHostBind
|
||||
? `${port.host_ip}:${port.host_port}`
|
||||
: "Not bound to host";
|
||||
const linkDest = hasHostBind
|
||||
? portForwardURL(
|
||||
wildcardHostname,
|
||||
port.host_port,
|
||||
agent.name,
|
||||
workspace.name,
|
||||
workspace.owner_name,
|
||||
location.protocol === "https" ? "https" : "http",
|
||||
)
|
||||
: "";
|
||||
return (
|
||||
<TooltipProvider key={portLabel}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AgentButton disabled={!hasHostBind} asChild>
|
||||
<a href={linkDest}>
|
||||
<ExternalLinkIcon />
|
||||
{portLabel}
|
||||
</a>
|
||||
</AgentButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{helperText}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
{displayApps.includes("web_terminal") && (
|
||||
<TerminalLink
|
||||
workspaceName={workspace.name}
|
||||
agentName={subAgent.name}
|
||||
userName={workspace.owner_name}
|
||||
/>
|
||||
)}
|
||||
|
||||
{wildcardHostname !== "" &&
|
||||
devcontainer.container?.ports.map((port) => {
|
||||
const portLabel = `${port.port}/${port.network.toUpperCase()}`;
|
||||
const hasHostBind =
|
||||
port.host_port !== undefined && port.host_ip !== undefined;
|
||||
const helperText = hasHostBind
|
||||
? `${port.host_ip}:${port.host_port}`
|
||||
: "Not bound to host";
|
||||
const linkDest = hasHostBind
|
||||
? portForwardURL(
|
||||
wildcardHostname,
|
||||
port.host_port,
|
||||
subAgent.name,
|
||||
workspace.name,
|
||||
workspace.owner_name,
|
||||
location.protocol === "https" ? "https" : "http",
|
||||
)
|
||||
: "";
|
||||
return (
|
||||
<TooltipProvider key={portLabel}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AgentButton disabled={!hasHostBind} asChild>
|
||||
<a href={linkDest}>
|
||||
<ExternalLinkIcon />
|
||||
{portLabel}
|
||||
</a>
|
||||
</AgentButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{helperText}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{showSubAgentAppsPlaceholders && (
|
||||
<section className={appsClasses}>
|
||||
<Skeleton
|
||||
width={80}
|
||||
height={32}
|
||||
variant="rectangular"
|
||||
className="rounded"
|
||||
/>
|
||||
<Skeleton
|
||||
width={110}
|
||||
height={32}
|
||||
variant="rectangular"
|
||||
className="rounded"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -288,6 +288,7 @@ export const GroupApp: Story = {
|
||||
export const Devcontainer: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getAgentContainers").mockResolvedValue({
|
||||
devcontainers: [M.MockWorkspaceAgentDevcontainer],
|
||||
containers: [M.MockWorkspaceAgentContainer],
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MockWorkspaceApp } from "testHelpers/entities";
|
||||
import { organizeAgentApps } from "./AgentRow";
|
||||
import { organizeAgentApps } from "./AgentApps/AgentApps";
|
||||
|
||||
describe("organizeAgentApps", () => {
|
||||
test("returns one ungrouped app", () => {
|
||||
|
||||
@@ -8,20 +8,12 @@ import type {
|
||||
Workspace,
|
||||
WorkspaceAgent,
|
||||
WorkspaceAgentMetadata,
|
||||
WorkspaceApp,
|
||||
} from "api/typesGenerated";
|
||||
import { isAxiosError } from "axios";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "components/DropdownMenu/DropdownMenu";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { useProxy } from "contexts/ProxyContext";
|
||||
import { Folder } from "lucide-react";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
import { AppStatuses } from "pages/WorkspacePage/AppStatuses";
|
||||
import {
|
||||
@@ -36,7 +28,7 @@ import {
|
||||
import { useQuery } from "react-query";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import type { FixedSizeList as List, ListOnScrollProps } from "react-window";
|
||||
import { AgentButton } from "./AgentButton";
|
||||
import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps";
|
||||
import { AgentDevcontainerCard } from "./AgentDevcontainerCard";
|
||||
import { AgentLatency } from "./AgentLatency";
|
||||
import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogs/AgentLogLine";
|
||||
@@ -44,7 +36,6 @@ import { AgentLogs } from "./AgentLogs/AgentLogs";
|
||||
import { AgentMetadata } from "./AgentMetadata";
|
||||
import { AgentStatus } from "./AgentStatus";
|
||||
import { AgentVersion } from "./AgentVersion";
|
||||
import { AppLink } from "./AppLink/AppLink";
|
||||
import { DownloadAgentLogsButton } from "./DownloadAgentLogsButton";
|
||||
import { PortForwardButton } from "./PortForwardButton";
|
||||
import { AgentSSHButton } from "./SSHButton/SSHButton";
|
||||
@@ -54,6 +45,7 @@ import { useAgentLogs } from "./useAgentLogs";
|
||||
|
||||
interface AgentRowProps {
|
||||
agent: WorkspaceAgent;
|
||||
subAgents?: WorkspaceAgent[];
|
||||
workspace: Workspace;
|
||||
template: Template;
|
||||
initialMetadata?: WorkspaceAgentMetadata[];
|
||||
@@ -62,6 +54,7 @@ interface AgentRowProps {
|
||||
|
||||
export const AgentRow: FC<AgentRowProps> = ({
|
||||
agent,
|
||||
subAgents,
|
||||
workspace,
|
||||
template,
|
||||
onUpdateAgent,
|
||||
@@ -140,16 +133,11 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
setBottomOfLogs(distanceFromBottom < AGENT_LOG_LINE_HEIGHT);
|
||||
}, []);
|
||||
|
||||
const { data: containers } = useQuery({
|
||||
const { data: devcontainers } = useQuery({
|
||||
queryKey: ["agents", agent.id, "containers"],
|
||||
queryFn: () =>
|
||||
// Only return devcontainers
|
||||
API.getAgentContainers(agent.id, [
|
||||
"devcontainer.config_file=",
|
||||
"devcontainer.local_folder=",
|
||||
]),
|
||||
queryFn: () => API.getAgentContainers(agent.id),
|
||||
enabled: agent.status === "connected",
|
||||
select: (res) => res.containers.filter((c) => c.status === "running"),
|
||||
select: (res) => res.devcontainers,
|
||||
// TODO: Implement a websocket connection to get updates on containers
|
||||
// without having to poll.
|
||||
refetchInterval: ({ state }) => {
|
||||
@@ -164,7 +152,7 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
const [showParentApps, setShowParentApps] = useState(false);
|
||||
|
||||
let shouldDisplayAppsSection = shouldDisplayAgentApps;
|
||||
if (containers && containers.length > 0 && !showParentApps) {
|
||||
if (devcontainers && devcontainers.length > 0 && !showParentApps) {
|
||||
shouldDisplayAppsSection = false;
|
||||
}
|
||||
|
||||
@@ -200,7 +188,7 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{containers && containers.length > 0 && (
|
||||
{devcontainers && devcontainers.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -252,7 +240,7 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
/>
|
||||
)}
|
||||
{appSections.map((section, i) => (
|
||||
<Apps
|
||||
<AgentApps
|
||||
key={section.group ?? i}
|
||||
section={section}
|
||||
agent={agent}
|
||||
@@ -289,16 +277,18 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{containers && containers.length > 0 && (
|
||||
{devcontainers && devcontainers.length > 0 && (
|
||||
<section className="flex flex-col gap-4">
|
||||
{containers.map((container) => {
|
||||
{devcontainers.map((devcontainer) => {
|
||||
return (
|
||||
<AgentDevcontainerCard
|
||||
key={container.id}
|
||||
container={container}
|
||||
key={devcontainer.id}
|
||||
devcontainer={devcontainer}
|
||||
workspace={workspace}
|
||||
template={template}
|
||||
wildcardHostname={proxy.preferredWildcardHostname}
|
||||
agent={agent}
|
||||
parentAgent={agent}
|
||||
subAgents={subAgents ?? []}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -355,93 +345,6 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
type AppSection = {
|
||||
/**
|
||||
* If there is no `group`, just render all of the apps inline. If there is a
|
||||
* group name, show them all in a dropdown.
|
||||
*/
|
||||
group?: string;
|
||||
|
||||
apps: WorkspaceApp[];
|
||||
};
|
||||
|
||||
/**
|
||||
* organizeAgentApps returns an ordering of agent apps that accounts for
|
||||
* grouping. When we receive the list of apps from the backend, they have
|
||||
* already been "ordered" by their `order` attribute, but we are not given that
|
||||
* value. We must be careful to preserve that ordering, while also properly
|
||||
* grouping together all apps of any given group.
|
||||
*
|
||||
* The position of the group overall is determined by the `order` position of
|
||||
* the first app in the group. There may be several sections returned without
|
||||
* a group name, to allow placing grouped apps in between non-grouped apps. Not
|
||||
* every ungrouped section is expected to have a group in between, to make the
|
||||
* algorithm a little simpler to implement.
|
||||
*/
|
||||
export function organizeAgentApps(apps: readonly WorkspaceApp[]): AppSection[] {
|
||||
let currentSection: AppSection | undefined = undefined;
|
||||
const appGroups: AppSection[] = [];
|
||||
const groupsByName = new Map<string, AppSection>();
|
||||
|
||||
for (const app of apps) {
|
||||
if (app.hidden) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentSection || app.group !== currentSection.group) {
|
||||
const existingSection = groupsByName.get(app.group!);
|
||||
if (existingSection) {
|
||||
currentSection = existingSection;
|
||||
} else {
|
||||
currentSection = {
|
||||
group: app.group,
|
||||
apps: [],
|
||||
};
|
||||
appGroups.push(currentSection);
|
||||
if (app.group) {
|
||||
groupsByName.set(app.group, currentSection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentSection.apps.push(app);
|
||||
}
|
||||
|
||||
return appGroups;
|
||||
}
|
||||
|
||||
type AppsProps = {
|
||||
section: AppSection;
|
||||
agent: WorkspaceAgent;
|
||||
workspace: Workspace;
|
||||
};
|
||||
|
||||
const Apps: FC<AppsProps> = ({ section, agent, workspace }) => {
|
||||
return section.group ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<AgentButton>
|
||||
<Folder />
|
||||
{section.group}
|
||||
</AgentButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{section.apps.map((app) => (
|
||||
<DropdownMenuItem key={app.slug}>
|
||||
<AppLink grouped app={app} agent={agent} workspace={workspace} />
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<>
|
||||
{section.apps.map((app) => (
|
||||
<AppLink key={app.slug} app={app} agent={agent} workspace={workspace} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
agentRow: (theme) => ({
|
||||
fontSize: 14,
|
||||
|
||||
@@ -46,6 +46,10 @@ interface AgentStatusProps {
|
||||
agent: WorkspaceAgent;
|
||||
}
|
||||
|
||||
interface SubAgentStatusProps {
|
||||
agent?: WorkspaceAgent;
|
||||
}
|
||||
|
||||
const StartTimeoutLifecycle: FC<AgentStatusProps> = ({ agent }) => {
|
||||
return (
|
||||
<HelpTooltip>
|
||||
@@ -270,6 +274,28 @@ export const AgentStatus: FC<AgentStatusProps> = ({ agent }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const SubAgentStatus: FC<SubAgentStatusProps> = ({ agent }) => {
|
||||
if (!agent) {
|
||||
return <DisconnectedStatus />;
|
||||
}
|
||||
return (
|
||||
<ChooseOne>
|
||||
<Cond condition={agent.status === "connected"}>
|
||||
<ConnectedStatus agent={agent} />
|
||||
</Cond>
|
||||
<Cond condition={agent.status === "disconnected"}>
|
||||
<DisconnectedStatus />
|
||||
</Cond>
|
||||
<Cond condition={agent.status === "timeout"}>
|
||||
<TimeoutStatus agent={agent} />
|
||||
</Cond>
|
||||
<Cond>
|
||||
<ConnectingStatus />
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
status: {
|
||||
width: 6,
|
||||
|
||||
@@ -85,56 +85,6 @@ export const AgentSSHButton: FC<AgentSSHButtonProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface AgentDevcontainerSSHButtonProps {
|
||||
workspace: string;
|
||||
container: string;
|
||||
}
|
||||
|
||||
export const AgentDevcontainerSSHButton: FC<
|
||||
AgentDevcontainerSSHButtonProps
|
||||
> = ({ workspace, container }) => {
|
||||
const paper = useClassName(classNames.paper, []);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
css={{ fontSize: 13, padding: "8px 12px" }}
|
||||
>
|
||||
Connect via SSH
|
||||
<ChevronDownIcon className="size-4 ml-2" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent horizontal="right" classes={{ paper }}>
|
||||
<HelpTooltipText>
|
||||
Run the following commands to connect with SSH:
|
||||
</HelpTooltipText>
|
||||
|
||||
<ol style={{ margin: 0, padding: 0 }}>
|
||||
<Stack spacing={0.5} css={styles.codeExamples}>
|
||||
<SSHStep
|
||||
helpText="Connect to the container:"
|
||||
codeExample={`coder ssh ${workspace} -c ${container}`}
|
||||
/>
|
||||
</Stack>
|
||||
</ol>
|
||||
|
||||
<HelpTooltipLinksGroup>
|
||||
<HelpTooltipLink href={docs("/install")}>
|
||||
Install Coder CLI
|
||||
</HelpTooltipLink>
|
||||
<HelpTooltipLink href={docs("/user-guides/workspace-access#ssh")}>
|
||||
SSH configuration
|
||||
</HelpTooltipLink>
|
||||
</HelpTooltipLinksGroup>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
interface SSHStepProps {
|
||||
helpText: string;
|
||||
codeExample: string;
|
||||
@@ -151,11 +101,11 @@ const SSHStep: FC<SSHStepProps> = ({ helpText, codeExample }) => (
|
||||
|
||||
const classNames = {
|
||||
paper: (css, theme) => css`
|
||||
padding: 16px 24px 24px;
|
||||
width: 304px;
|
||||
color: ${theme.palette.text.secondary};
|
||||
margin-top: 2px;
|
||||
`,
|
||||
padding: 16px 24px 24px;
|
||||
width: 304px;
|
||||
color: ${theme.palette.text.secondary};
|
||||
margin-top: 2px;
|
||||
`,
|
||||
} satisfies Record<string, ClassName>;
|
||||
|
||||
const styles = {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type {
|
||||
WorkspaceAgent,
|
||||
WorkspaceAgentDevcontainer,
|
||||
} from "api/typesGenerated";
|
||||
import {
|
||||
HelpTooltip,
|
||||
HelpTooltipAction,
|
||||
HelpTooltipContent,
|
||||
HelpTooltipLinksGroup,
|
||||
HelpTooltipText,
|
||||
HelpTooltipTitle,
|
||||
HelpTooltipTrigger,
|
||||
} from "components/HelpTooltip/HelpTooltip";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { RotateCcwIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
|
||||
type SubAgentOutdatedTooltipProps = {
|
||||
devcontainer: WorkspaceAgentDevcontainer;
|
||||
agent: WorkspaceAgent;
|
||||
onUpdate: () => void;
|
||||
};
|
||||
|
||||
export const SubAgentOutdatedTooltip: FC<SubAgentOutdatedTooltipProps> = ({
|
||||
devcontainer,
|
||||
agent,
|
||||
onUpdate,
|
||||
}) => {
|
||||
if (!devcontainer.agent || devcontainer.agent.id !== agent.id) {
|
||||
return null;
|
||||
}
|
||||
if (!devcontainer.dirty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = "Dev Container Outdated";
|
||||
const opener = "This Dev Container is outdated.";
|
||||
const text = `${opener} This can happen if you modify your devcontainer.json file after the Dev Container has been created. To fix this, you can rebuild the Dev Container.`;
|
||||
|
||||
return (
|
||||
<HelpTooltip>
|
||||
<HelpTooltipTrigger>
|
||||
<span role="status" className="cursor-pointer">
|
||||
Outdated
|
||||
</span>
|
||||
</HelpTooltipTrigger>
|
||||
<HelpTooltipContent>
|
||||
<Stack spacing={1}>
|
||||
<div>
|
||||
<HelpTooltipTitle>{title}</HelpTooltipTitle>
|
||||
<HelpTooltipText>{text}</HelpTooltipText>
|
||||
</div>
|
||||
|
||||
<HelpTooltipLinksGroup>
|
||||
<HelpTooltipAction
|
||||
icon={RotateCcwIcon}
|
||||
onClick={onUpdate}
|
||||
ariaLabel="Rebuild Dev Container"
|
||||
>
|
||||
Rebuild Dev Container
|
||||
</HelpTooltipAction>
|
||||
</HelpTooltipLinksGroup>
|
||||
</Stack>
|
||||
</HelpTooltipContent>
|
||||
</HelpTooltip>
|
||||
);
|
||||
};
|
||||
@@ -101,9 +101,9 @@ export const VSCodeDevContainerButton: FC<VSCodeDevContainerButtonProps> = (
|
||||
</>
|
||||
) : includesVSCodeDesktop ? (
|
||||
<VSCodeButton {...props} />
|
||||
) : (
|
||||
) : includesVSCodeInsiders ? (
|
||||
<VSCodeInsidersButton {...props} />
|
||||
);
|
||||
) : null;
|
||||
};
|
||||
|
||||
const VSCodeButton: FC<VSCodeDevContainerButtonProps> = ({
|
||||
|
||||
@@ -97,7 +97,7 @@ export const RunningWithChildAgent: Story = {
|
||||
lifecycle_state: "ready",
|
||||
},
|
||||
{
|
||||
...Mocks.MockWorkspaceChildAgent,
|
||||
...Mocks.MockWorkspaceSubAgent,
|
||||
lifecycle_state: "ready",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -242,6 +242,9 @@ export const Workspace: FC<WorkspaceProps> = ({
|
||||
<AgentRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
subAgents={selectedResource.agents?.filter(
|
||||
(a) => a.parent_id === agent.id,
|
||||
)}
|
||||
workspace={workspace}
|
||||
template={template}
|
||||
onUpdateAgent={handleUpdate} // On updating the workspace the agent version is also updated
|
||||
|
||||
@@ -970,38 +970,15 @@ export const MockWorkspaceAgent: TypesGen.WorkspaceAgent = {
|
||||
],
|
||||
};
|
||||
|
||||
export const MockWorkspaceChildAgent: TypesGen.WorkspaceAgent = {
|
||||
export const MockWorkspaceSubAgent: TypesGen.WorkspaceAgent = {
|
||||
...MockWorkspaceAgent,
|
||||
apps: [],
|
||||
architecture: "amd64",
|
||||
created_at: "",
|
||||
environment_variables: {},
|
||||
id: "test-workspace-child-agent",
|
||||
id: "test-workspace-sub-agent",
|
||||
parent_id: "test-workspace-agent",
|
||||
name: "a-workspace-child-agent",
|
||||
operating_system: "linux",
|
||||
resource_id: "",
|
||||
status: "connected",
|
||||
updated_at: "",
|
||||
version: MockBuildInfo.version,
|
||||
api_version: MockBuildInfo.agent_api_version,
|
||||
latency: {
|
||||
"Coder Embedded DERP": {
|
||||
latency_ms: 32.55,
|
||||
preferred: true,
|
||||
},
|
||||
},
|
||||
connection_timeout_seconds: 120,
|
||||
troubleshooting_url: "https://coder.com/troubleshoot",
|
||||
lifecycle_state: "starting",
|
||||
logs_length: 0,
|
||||
logs_overflowed: false,
|
||||
log_sources: [MockWorkspaceAgentLogSource],
|
||||
name: "a-workspace-sub-agent",
|
||||
log_sources: [],
|
||||
scripts: [],
|
||||
startup_script_behavior: "non-blocking",
|
||||
subsystems: ["envbox", "exectrace"],
|
||||
health: {
|
||||
healthy: true,
|
||||
},
|
||||
directory: "/workspace/test",
|
||||
display_apps: [
|
||||
"ssh_helper",
|
||||
"port_forwarding_helper",
|
||||
@@ -4397,9 +4374,24 @@ export const MockWorkspaceAgentContainer: TypesGen.WorkspaceAgentContainer = {
|
||||
volumes: {
|
||||
"/mnt/volume1": "/volume1",
|
||||
},
|
||||
devcontainer_dirty: false,
|
||||
};
|
||||
|
||||
export const MockWorkspaceAgentDevcontainer: TypesGen.WorkspaceAgentDevcontainer =
|
||||
{
|
||||
id: "test-devcontainer-id",
|
||||
name: "test-devcontainer",
|
||||
workspace_folder: "/workspace/test",
|
||||
config_path: "/workspace/test/.devcontainer/devcontainer.json",
|
||||
status: "running",
|
||||
dirty: false,
|
||||
container: MockWorkspaceAgentContainer,
|
||||
agent: {
|
||||
id: MockWorkspaceSubAgent.id,
|
||||
name: MockWorkspaceSubAgent.name,
|
||||
directory: MockWorkspaceSubAgent?.directory ?? "/workspace/test",
|
||||
},
|
||||
};
|
||||
|
||||
export const MockWorkspaceAppStatuses: TypesGen.WorkspaceAppStatus[] = [
|
||||
{
|
||||
// This is the latest status chronologically (15:04:38)
|
||||
|
||||
Reference in New Issue
Block a user