feat: add devcontainer in the UI (#16800)

![image](https://github.com/user-attachments/assets/361f9e69-dec8-47c8-b075-7c13ce84c7e8)

Related to https://github.com/coder/coder/issues/16422

---------

Co-authored-by: Cian Johnston <cian@coder.com>
This commit is contained in:
Bruno Quaresma
2025-03-04 14:29:02 -03:00
committed by GitHub
parent 73057eb7bd
commit 861c4b140b
6 changed files with 186 additions and 15 deletions

View File

@ -2374,6 +2374,18 @@ class ApiMethods {
);
}
};
getAgentContainers = async (agentId: string, labels?: string[]) => {
const params = new URLSearchParams(
labels?.map((label) => ["label", label]),
);
const res =
await this.axios.get<TypesGen.WorkspaceAgentListContainersResponse>(
`/api/v2/workspaceagents/${agentId}/containers?${params.toString()}`,
);
return res.data;
};
}
// This is a hard coded CSRF token/cookie pair for local development. In prod,

View File

@ -0,0 +1,74 @@
import Link from "@mui/material/Link";
import type { Workspace, WorkspaceAgentDevcontainer } from "api/typesGenerated";
import { ExternalLinkIcon } from "lucide-react";
import type { FC } from "react";
import { portForwardURL } from "utils/portForward";
import { AgentButton } from "./AgentButton";
import { AgentDevcontainerSSHButton } from "./SSHButton/SSHButton";
import { TerminalLink } from "./TerminalLink/TerminalLink";
type AgentDevcontainerCardProps = {
container: WorkspaceAgentDevcontainer;
workspace: Workspace;
wildcardHostname: string;
agentName: string;
};
export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
container,
workspace,
agentName,
wildcardHostname,
}) => {
return (
<section
className="border border-border border-dashed rounded p-6 "
key={container.id}
>
<header className="flex justify-between">
<h3 className="m-0 text-xs font-medium text-content-secondary">
{container.name}
</h3>
<AgentDevcontainerSSHButton
workspace={workspace.name}
container={container.name}
/>
</header>
<h4 className="m-0 text-xl font-semibold">Forwarded ports</h4>
<div className="flex gap-4 flex-wrap mt-4">
<TerminalLink
workspaceName={workspace.name}
agentName={agentName}
containerName={container.name}
userName={workspace.owner_name}
/>
{wildcardHostname !== "" &&
container.ports.map((port) => {
return (
<Link
key={port.port}
color="inherit"
component={AgentButton}
underline="none"
startIcon={<ExternalLinkIcon className="size-icon-sm" />}
href={portForwardURL(
wildcardHostname,
port.port,
agentName,
workspace.name,
workspace.owner_name,
location.protocol === "https" ? "https" : "http",
)}
>
{port.process_name ||
`${port.port}/${port.network.toUpperCase()}`}
</Link>
);
})}
</div>
</section>
);
};

View File

@ -3,6 +3,7 @@ import Button from "@mui/material/Button";
import Collapse from "@mui/material/Collapse";
import Divider from "@mui/material/Divider";
import Skeleton from "@mui/material/Skeleton";
import { API } from "api/api";
import { xrayScan } from "api/queries/integrations";
import type {
Template,
@ -25,6 +26,7 @@ import {
import { useQuery } from "react-query";
import AutoSizer from "react-virtualized-auto-sizer";
import type { FixedSizeList as List, ListOnScrollProps } from "react-window";
import { AgentDevcontainerCard } from "./AgentDevcontainerCard";
import { AgentLatency } from "./AgentLatency";
import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogs/AgentLogLine";
import { AgentLogs } from "./AgentLogs/AgentLogs";
@ -35,7 +37,7 @@ import { AgentVersion } from "./AgentVersion";
import { AppLink } from "./AppLink/AppLink";
import { DownloadAgentLogsButton } from "./DownloadAgentLogsButton";
import { PortForwardButton } from "./PortForwardButton";
import { SSHButton } from "./SSHButton/SSHButton";
import { AgentSSHButton } from "./SSHButton/SSHButton";
import { TerminalLink } from "./TerminalLink/TerminalLink";
import { VSCodeDesktopButton } from "./VSCodeDesktopButton/VSCodeDesktopButton";
import { XRayScanAlert } from "./XRayScanAlert";
@ -152,6 +154,18 @@ export const AgentRow: FC<AgentRowProps> = ({
setBottomOfLogs(distanceFromBottom < AGENT_LOG_LINE_HEIGHT);
}, []);
const { data: containers } = useQuery({
queryKey: ["agents", agent.id, "containers"],
queryFn: () =>
// Only return devcontainers
API.getAgentContainers(agent.id, [
"devcontainer.config_file=",
"devcontainer.local_folder=",
]),
enabled: agent.status === "connected",
select: (res) => res.containers.filter((c) => c.status === "running"),
});
return (
<Stack
key={agent.id}
@ -191,14 +205,13 @@ export const AgentRow: FC<AgentRowProps> = ({
{showBuiltinApps && (
<div css={{ display: "flex" }}>
{!hideSSHButton && agent.display_apps.includes("ssh_helper") && (
<SSHButton
<AgentSSHButton
workspaceName={workspace.name}
agentName={agent.name}
sshPrefix={sshPrefix}
/>
)}
{proxy.preferredWildcardHostname &&
proxy.preferredWildcardHostname !== "" &&
{proxy.preferredWildcardHostname !== "" &&
agent.display_apps.includes("port_forwarding_helper") && (
<PortForwardButton
host={proxy.preferredWildcardHostname}
@ -267,6 +280,22 @@ export const AgentRow: FC<AgentRowProps> = ({
</section>
)}
{containers && containers.length > 0 && (
<section className="flex flex-col gap-4">
{containers.map((container) => {
return (
<AgentDevcontainerCard
key={container.id}
container={container}
workspace={workspace}
wildcardHostname={proxy.preferredWildcardHostname}
agentName={agent.name}
/>
);
})}
</section>
)}
<AgentMetadata
storybookMetadata={storybookAgentMetadata}
agent={agent}

View File

@ -2,15 +2,15 @@ import type { Meta, StoryObj } from "@storybook/react";
import { userEvent, within } from "@storybook/test";
import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities";
import { withDesktopViewport } from "testHelpers/storybook";
import { SSHButton } from "./SSHButton";
import { AgentSSHButton } from "./SSHButton";
const meta: Meta<typeof SSHButton> = {
title: "modules/resources/SSHButton",
component: SSHButton,
const meta: Meta<typeof AgentSSHButton> = {
title: "modules/resources/AgentSSHButton",
component: AgentSSHButton,
};
export default meta;
type Story = StoryObj<typeof SSHButton>;
type Story = StoryObj<typeof AgentSSHButton>;
export const Closed: Story = {
args: {

View File

@ -17,13 +17,13 @@ import { type ClassName, useClassName } from "hooks/useClassName";
import type { FC } from "react";
import { docs } from "utils/docs";
export interface SSHButtonProps {
export interface AgentSSHButtonProps {
workspaceName: string;
agentName: string;
sshPrefix?: string;
}
export const SSHButton: FC<SSHButtonProps> = ({
export const AgentSSHButton: FC<AgentSSHButtonProps> = ({
workspaceName,
agentName,
sshPrefix,
@ -82,6 +82,56 @@ export const SSHButton: FC<SSHButtonProps> = ({
);
};
export interface AgentDevcontainerSSHButtonProps {
workspace: string;
container: string;
}
export const AgentDevcontainerSSHButton: FC<
AgentDevcontainerSSHButtonProps
> = ({ workspace, container }) => {
const paper = useClassName(classNames.paper, []);
return (
<Popover>
<PopoverTrigger>
<Button
size="small"
variant="text"
endIcon={<KeyboardArrowDown />}
css={{ fontSize: 13, padding: "8px 12px" }}
>
Connect via SSH
</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;

View File

@ -11,9 +11,10 @@ export const Language = {
};
export interface TerminalLinkProps {
agentName?: TypesGen.WorkspaceAgent["name"];
userName?: TypesGen.User["username"];
workspaceName: TypesGen.Workspace["name"];
workspaceName: string;
agentName?: string;
userName?: string;
containerName?: string;
}
/**
@ -27,11 +28,16 @@ export const TerminalLink: FC<TerminalLinkProps> = ({
agentName,
userName = "me",
workspaceName,
containerName,
}) => {
const params = new URLSearchParams();
if (containerName) {
params.append("container", containerName);
}
// Always use the primary for the terminal link. This is a relative link.
const href = `/@${userName}/${workspaceName}${
agentName ? `.${agentName}` : ""
}/terminal`;
}/terminal?${params.toString()}`;
return (
<Link