mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
feat: add devcontainer in the UI (#16800)
 Related to https://github.com/coder/coder/issues/16422 --------- Co-authored-by: Cian Johnston <cian@coder.com>
This commit is contained in:
@ -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,
|
||||
|
74
site/src/modules/resources/AgentDevcontainerCard.tsx
Normal file
74
site/src/modules/resources/AgentDevcontainerCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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}
|
||||
|
@ -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: {
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user