mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat(site): add support for external agents in the UI and extend CodeExample (#19288)
This pull request introduces support for external workspace management, allowing users to register and manage workspaces that are provisioned and managed outside of the Coder. * Added a new component AgentExternal which shows instructions for connecting external agents. * Added redacted fields to CodeExample so you can now hide specific parts of the code instead of the full line * Hides workspace actions if workspace is using external agent. <img width="1719" height="646" alt="image" src="https://github.com/user-attachments/assets/45b7bfae-7006-461f-a96d-e61f97084819" />
This commit is contained in:
@@ -2022,6 +2022,16 @@ class ApiMethods {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getWorkspaceAgentCredentials = async (
|
||||
workspaceID: string,
|
||||
agentName: string,
|
||||
): Promise<TypesGen.ExternalAgentCredentials> => {
|
||||
const response = await this.axios.get(
|
||||
`/api/v2/workspaces/${workspaceID}/external-agent/${agentName}/credentials`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
upsertWorkspaceAgentSharedPort = async (
|
||||
workspaceID: string,
|
||||
req: TypesGen.UpsertWorkspaceAgentPortShareRequest,
|
||||
|
||||
@@ -430,3 +430,13 @@ export const updateWorkspaceACL = (workspaceId: string) => {
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const workspaceAgentCredentials = (
|
||||
workspaceId: string,
|
||||
agentName: string,
|
||||
) => {
|
||||
return {
|
||||
queryKey: ["workspaces", workspaceId, "agents", agentName, "credentials"],
|
||||
queryFn: () => API.getWorkspaceAgentCredentials(workspaceId, agentName),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -31,3 +31,12 @@ export const LongCode: Story = {
|
||||
code: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICnKzATuWwmmt5+CKTPuRGN0R1PBemA+6/SStpLiyX+L",
|
||||
},
|
||||
};
|
||||
|
||||
export const Redact: Story = {
|
||||
args: {
|
||||
secret: false,
|
||||
redactPattern: /CODER_AGENT_TOKEN="([^"]+)"/g,
|
||||
redactReplacement: `CODER_AGENT_TOKEN="********"`,
|
||||
showRevealButton: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import type { FC } from "react";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
|
||||
import { CopyButton } from "../CopyButton/CopyButton";
|
||||
|
||||
interface CodeExampleProps {
|
||||
code: string;
|
||||
/** Defaulting to true to be on the safe side; you should have to opt out of the secure option, not remember to opt in */
|
||||
secret?: boolean;
|
||||
/** Redact parts of the code if the user doesn't want to obfuscate the whole code */
|
||||
redactPattern?: RegExp;
|
||||
/** Replacement text for redacted content */
|
||||
redactReplacement?: string;
|
||||
/** Show a button to reveal the redacted parts of the code */
|
||||
showRevealButton?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -15,11 +30,28 @@ interface CodeExampleProps {
|
||||
export const CodeExample: FC<CodeExampleProps> = ({
|
||||
code,
|
||||
className,
|
||||
|
||||
// Defaulting to true to be on the safe side; you should have to opt out of
|
||||
// the secure option, not remember to opt in
|
||||
secret = true,
|
||||
redactPattern,
|
||||
redactReplacement = "********",
|
||||
showRevealButton,
|
||||
}) => {
|
||||
const [showFullValue, setShowFullValue] = useState(false);
|
||||
|
||||
const displayValue = secret
|
||||
? obfuscateText(code)
|
||||
: redactPattern && !showFullValue
|
||||
? code.replace(redactPattern, redactReplacement)
|
||||
: code;
|
||||
|
||||
const showButtonLabel = showFullValue
|
||||
? "Hide sensitive data"
|
||||
: "Show sensitive data";
|
||||
const icon = showFullValue ? (
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div css={styles.container} className={className}>
|
||||
<code css={[styles.code, secret && styles.secret]}>
|
||||
@@ -33,17 +65,36 @@ export const CodeExample: FC<CodeExampleProps> = ({
|
||||
* 2. Even with it turned on and supported, the plaintext is still
|
||||
* readily available in the HTML itself
|
||||
*/}
|
||||
<span aria-hidden>{obfuscateText(code)}</span>
|
||||
<span aria-hidden>{displayValue}</span>
|
||||
<span className="sr-only">
|
||||
Encrypted text. Please access via the copy button.
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
code
|
||||
displayValue
|
||||
)}
|
||||
</code>
|
||||
|
||||
<CopyButton text={code} label="Copy code" />
|
||||
<div className="flex items-center gap-1">
|
||||
{showRevealButton && redactPattern && !secret && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
onClick={() => setShowFullValue(!showFullValue)}
|
||||
>
|
||||
{icon}
|
||||
<span className="sr-only">{showButtonLabel}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{showButtonLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<CopyButton text={code} label="Copy code" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { chromatic } from "testHelpers/chromatic";
|
||||
import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities";
|
||||
import { withDashboardProvider } from "testHelpers/storybook";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AgentExternal } from "./AgentExternal";
|
||||
|
||||
const meta: Meta<typeof AgentExternal> = {
|
||||
title: "modules/resources/AgentExternal",
|
||||
component: AgentExternal,
|
||||
args: {
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
status: "connecting",
|
||||
operating_system: "linux",
|
||||
architecture: "amd64",
|
||||
},
|
||||
workspace: MockWorkspace,
|
||||
},
|
||||
decorators: [withDashboardProvider],
|
||||
parameters: {
|
||||
chromatic,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AgentExternal>;
|
||||
|
||||
export const Connecting: Story = {
|
||||
args: {
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
status: "connecting",
|
||||
operating_system: "linux",
|
||||
architecture: "amd64",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Timeout: Story = {
|
||||
args: {
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
status: "timeout",
|
||||
operating_system: "linux",
|
||||
architecture: "amd64",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DifferentOS: Story = {
|
||||
args: {
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
status: "connecting",
|
||||
operating_system: "darwin",
|
||||
architecture: "arm64",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NotExternalAgent: Story = {
|
||||
args: {
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
status: "connecting",
|
||||
operating_system: "linux",
|
||||
architecture: "amd64",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { workspaceAgentCredentials } from "api/queries/workspaces";
|
||||
import type { Workspace, WorkspaceAgent } from "api/typesGenerated";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { CodeExample } from "components/CodeExample/CodeExample";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import type { FC } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
|
||||
interface AgentExternalProps {
|
||||
agent: WorkspaceAgent;
|
||||
workspace: Workspace;
|
||||
}
|
||||
|
||||
export const AgentExternal: FC<AgentExternalProps> = ({ agent, workspace }) => {
|
||||
const {
|
||||
data: credentials,
|
||||
error,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery(workspaceAgentCredentials(workspace.id, agent.name));
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <ErrorAlert error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="text-base text-muted-foreground pb-2 leading-relaxed">
|
||||
<p>
|
||||
Please run the following command to attach an agent to the{" "}
|
||||
{workspace.name} workspace:
|
||||
</p>
|
||||
<CodeExample
|
||||
code={credentials?.command ?? ""}
|
||||
secret={false}
|
||||
redactPattern={/CODER_AGENT_TOKEN="([^"]+)"/g}
|
||||
redactReplacement={`CODER_AGENT_TOKEN="********"`}
|
||||
showRevealButton={true}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -27,6 +27,7 @@ import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import type { FixedSizeList as List, ListOnScrollProps } from "react-window";
|
||||
import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps";
|
||||
import { AgentDevcontainerCard } from "./AgentDevcontainerCard";
|
||||
import { AgentExternal } from "./AgentExternal";
|
||||
import { AgentLatency } from "./AgentLatency";
|
||||
import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogs/AgentLogLine";
|
||||
import { AgentLogs } from "./AgentLogs/AgentLogs";
|
||||
@@ -58,13 +59,14 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
onUpdateAgent,
|
||||
initialMetadata,
|
||||
}) => {
|
||||
const { browser_only } = useFeatureVisibility();
|
||||
const { browser_only, workspace_external_agent } = useFeatureVisibility();
|
||||
const appSections = organizeAgentApps(agent.apps);
|
||||
const hasAppsToDisplay =
|
||||
!browser_only || appSections.some((it) => it.apps.length > 0);
|
||||
const isExternalAgent = workspace.latest_build.has_external_agent;
|
||||
const shouldDisplayAgentApps =
|
||||
(agent.status === "connected" && hasAppsToDisplay) ||
|
||||
agent.status === "connecting";
|
||||
(agent.status === "connecting" && !isExternalAgent);
|
||||
const hasVSCodeApp =
|
||||
agent.display_apps.includes("vscode") ||
|
||||
agent.display_apps.includes("vscode_insiders");
|
||||
@@ -258,7 +260,7 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{agent.status === "connecting" && (
|
||||
{agent.status === "connecting" && !isExternalAgent && (
|
||||
<section css={styles.apps}>
|
||||
<Skeleton
|
||||
width={80}
|
||||
@@ -293,6 +295,12 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isExternalAgent &&
|
||||
(agent.status === "timeout" || agent.status === "connecting") &&
|
||||
workspace_external_agent && (
|
||||
<AgentExternal agent={agent} workspace={workspace} />
|
||||
)}
|
||||
|
||||
<AgentMetadata initialMetadata={initialMetadata} agent={agent} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -63,6 +63,14 @@ export const abilitiesByWorkspaceStatus = (
|
||||
};
|
||||
}
|
||||
|
||||
if (workspace.latest_build.has_external_agent) {
|
||||
return {
|
||||
actions: [],
|
||||
canCancel: false,
|
||||
canAcceptJobs: true,
|
||||
};
|
||||
}
|
||||
|
||||
const status = workspace.latest_build.status;
|
||||
|
||||
switch (status) {
|
||||
|
||||
Reference in New Issue
Block a user