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:
Mathias Fredriksson
2025-06-17 16:06:47 +03:00
committed by GitHub
parent cda9208580
commit 97474bb28b
22 changed files with 1265 additions and 666 deletions
+9 -7
View File
@@ -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 -1
View File
@@ -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", () => {
+16 -113
View File
@@ -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
+22 -30
View File
@@ -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)