mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08: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:
co-authored by
Cian Johnston
parent
73057eb7bd
commit
861c4b140b
@@ -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,
|
// This is a hard coded CSRF token/cookie pair for local development. In prod,
|
||||||
|
|||||||
@@ -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 Collapse from "@mui/material/Collapse";
|
||||||
import Divider from "@mui/material/Divider";
|
import Divider from "@mui/material/Divider";
|
||||||
import Skeleton from "@mui/material/Skeleton";
|
import Skeleton from "@mui/material/Skeleton";
|
||||||
|
import { API } from "api/api";
|
||||||
import { xrayScan } from "api/queries/integrations";
|
import { xrayScan } from "api/queries/integrations";
|
||||||
import type {
|
import type {
|
||||||
Template,
|
Template,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
import { useQuery } from "react-query";
|
import { useQuery } from "react-query";
|
||||||
import AutoSizer from "react-virtualized-auto-sizer";
|
import AutoSizer from "react-virtualized-auto-sizer";
|
||||||
import type { FixedSizeList as List, ListOnScrollProps } from "react-window";
|
import type { FixedSizeList as List, ListOnScrollProps } from "react-window";
|
||||||
|
import { AgentDevcontainerCard } from "./AgentDevcontainerCard";
|
||||||
import { AgentLatency } from "./AgentLatency";
|
import { AgentLatency } from "./AgentLatency";
|
||||||
import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogs/AgentLogLine";
|
import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogs/AgentLogLine";
|
||||||
import { AgentLogs } from "./AgentLogs/AgentLogs";
|
import { AgentLogs } from "./AgentLogs/AgentLogs";
|
||||||
@@ -35,7 +37,7 @@ import { AgentVersion } from "./AgentVersion";
|
|||||||
import { AppLink } from "./AppLink/AppLink";
|
import { AppLink } from "./AppLink/AppLink";
|
||||||
import { DownloadAgentLogsButton } from "./DownloadAgentLogsButton";
|
import { DownloadAgentLogsButton } from "./DownloadAgentLogsButton";
|
||||||
import { PortForwardButton } from "./PortForwardButton";
|
import { PortForwardButton } from "./PortForwardButton";
|
||||||
import { SSHButton } from "./SSHButton/SSHButton";
|
import { AgentSSHButton } from "./SSHButton/SSHButton";
|
||||||
import { TerminalLink } from "./TerminalLink/TerminalLink";
|
import { TerminalLink } from "./TerminalLink/TerminalLink";
|
||||||
import { VSCodeDesktopButton } from "./VSCodeDesktopButton/VSCodeDesktopButton";
|
import { VSCodeDesktopButton } from "./VSCodeDesktopButton/VSCodeDesktopButton";
|
||||||
import { XRayScanAlert } from "./XRayScanAlert";
|
import { XRayScanAlert } from "./XRayScanAlert";
|
||||||
@@ -152,6 +154,18 @@ export const AgentRow: FC<AgentRowProps> = ({
|
|||||||
setBottomOfLogs(distanceFromBottom < AGENT_LOG_LINE_HEIGHT);
|
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 (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
key={agent.id}
|
key={agent.id}
|
||||||
@@ -191,14 +205,13 @@ export const AgentRow: FC<AgentRowProps> = ({
|
|||||||
{showBuiltinApps && (
|
{showBuiltinApps && (
|
||||||
<div css={{ display: "flex" }}>
|
<div css={{ display: "flex" }}>
|
||||||
{!hideSSHButton && agent.display_apps.includes("ssh_helper") && (
|
{!hideSSHButton && agent.display_apps.includes("ssh_helper") && (
|
||||||
<SSHButton
|
<AgentSSHButton
|
||||||
workspaceName={workspace.name}
|
workspaceName={workspace.name}
|
||||||
agentName={agent.name}
|
agentName={agent.name}
|
||||||
sshPrefix={sshPrefix}
|
sshPrefix={sshPrefix}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{proxy.preferredWildcardHostname &&
|
{proxy.preferredWildcardHostname !== "" &&
|
||||||
proxy.preferredWildcardHostname !== "" &&
|
|
||||||
agent.display_apps.includes("port_forwarding_helper") && (
|
agent.display_apps.includes("port_forwarding_helper") && (
|
||||||
<PortForwardButton
|
<PortForwardButton
|
||||||
host={proxy.preferredWildcardHostname}
|
host={proxy.preferredWildcardHostname}
|
||||||
@@ -267,6 +280,22 @@ export const AgentRow: FC<AgentRowProps> = ({
|
|||||||
</section>
|
</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
|
<AgentMetadata
|
||||||
storybookMetadata={storybookAgentMetadata}
|
storybookMetadata={storybookAgentMetadata}
|
||||||
agent={agent}
|
agent={agent}
|
||||||
|
|||||||
@@ -2,15 +2,15 @@ import type { Meta, StoryObj } from "@storybook/react";
|
|||||||
import { userEvent, within } from "@storybook/test";
|
import { userEvent, within } from "@storybook/test";
|
||||||
import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities";
|
import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities";
|
||||||
import { withDesktopViewport } from "testHelpers/storybook";
|
import { withDesktopViewport } from "testHelpers/storybook";
|
||||||
import { SSHButton } from "./SSHButton";
|
import { AgentSSHButton } from "./SSHButton";
|
||||||
|
|
||||||
const meta: Meta<typeof SSHButton> = {
|
const meta: Meta<typeof AgentSSHButton> = {
|
||||||
title: "modules/resources/SSHButton",
|
title: "modules/resources/AgentSSHButton",
|
||||||
component: SSHButton,
|
component: AgentSSHButton,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof SSHButton>;
|
type Story = StoryObj<typeof AgentSSHButton>;
|
||||||
|
|
||||||
export const Closed: Story = {
|
export const Closed: Story = {
|
||||||
args: {
|
args: {
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ import { type ClassName, useClassName } from "hooks/useClassName";
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { docs } from "utils/docs";
|
import { docs } from "utils/docs";
|
||||||
|
|
||||||
export interface SSHButtonProps {
|
export interface AgentSSHButtonProps {
|
||||||
workspaceName: string;
|
workspaceName: string;
|
||||||
agentName: string;
|
agentName: string;
|
||||||
sshPrefix?: string;
|
sshPrefix?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SSHButton: FC<SSHButtonProps> = ({
|
export const AgentSSHButton: FC<AgentSSHButtonProps> = ({
|
||||||
workspaceName,
|
workspaceName,
|
||||||
agentName,
|
agentName,
|
||||||
sshPrefix,
|
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 {
|
interface SSHStepProps {
|
||||||
helpText: string;
|
helpText: string;
|
||||||
codeExample: string;
|
codeExample: string;
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ export const Language = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface TerminalLinkProps {
|
export interface TerminalLinkProps {
|
||||||
agentName?: TypesGen.WorkspaceAgent["name"];
|
workspaceName: string;
|
||||||
userName?: TypesGen.User["username"];
|
agentName?: string;
|
||||||
workspaceName: TypesGen.Workspace["name"];
|
userName?: string;
|
||||||
|
containerName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,11 +28,16 @@ export const TerminalLink: FC<TerminalLinkProps> = ({
|
|||||||
agentName,
|
agentName,
|
||||||
userName = "me",
|
userName = "me",
|
||||||
workspaceName,
|
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.
|
// Always use the primary for the terminal link. This is a relative link.
|
||||||
const href = `/@${userName}/${workspaceName}${
|
const href = `/@${userName}/${workspaceName}${
|
||||||
agentName ? `.${agentName}` : ""
|
agentName ? `.${agentName}` : ""
|
||||||
}/terminal`;
|
}/terminal?${params.toString()}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
Reference in New Issue
Block a user