Skip to content

Commit 45735b8

Browse files
committed
feat: add support for external agents in the UI and extend CodeExample
1 parent d5f8141 commit 45735b8

File tree

7 files changed

+221
-9
lines changed

7 files changed

+221
-9
lines changed

site/src/api/api.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2022,6 +2022,16 @@ class ApiMethods {
20222022
return response.data;
20232023
};
20242024

2025+
getWorkspaceAgentCredentials = async (
2026+
workspaceID: string,
2027+
agentName: string,
2028+
): Promise<TypesGen.ExternalAgentCredentials> => {
2029+
const response = await this.axios.get(
2030+
`/api/v2/workspaces/${workspaceID}/external-agent/${agentName}/credentials`,
2031+
);
2032+
return response.data;
2033+
};
2034+
20252035
upsertWorkspaceAgentSharedPort = async (
20262036
workspaceID: string,
20272037
req: TypesGen.UpsertWorkspaceAgentPortShareRequest,

site/src/components/CodeExample/CodeExample.stories.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,12 @@ export const LongCode: Story = {
3131
code: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICnKzATuWwmmt5+CKTPuRGN0R1PBemA+6/SStpLiyX+L",
3232
},
3333
};
34+
35+
export const Redact: Story = {
36+
args: {
37+
secret: false,
38+
redactPattern: /CODER_AGENT_TOKEN="([^"]+)"/g,
39+
redactReplacement: `CODER_AGENT_TOKEN="********"`,
40+
showRevealButton: true,
41+
},
42+
};

site/src/components/CodeExample/CodeExample.tsx

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,26 @@
11
import type { Interpolation, Theme } from "@emotion/react";
2-
import type { FC } from "react";
2+
import { Button } from "components/Button/Button";
3+
import {
4+
Tooltip,
5+
TooltipContent,
6+
TooltipProvider,
7+
TooltipTrigger,
8+
} from "components/Tooltip/Tooltip";
9+
import { EyeIcon, EyeOffIcon } from "lucide-react";
10+
import { type FC, useState } from "react";
311
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
412
import { CopyButton } from "../CopyButton/CopyButton";
513

614
interface CodeExampleProps {
715
code: string;
16+
/** Defaulting to true to be on the safe side; you should have to opt out of the secure option, not remember to opt in */
817
secret?: boolean;
18+
/** Redact parts of the code if the user doesn't want to obfuscate the whole code */
19+
redactPattern?: RegExp;
20+
/** Replacement text for redacted content */
21+
redactReplacement?: string;
22+
/** Show a button to reveal the redacted parts of the code */
23+
showRevealButton?: boolean;
924
className?: string;
1025
}
1126

@@ -15,11 +30,28 @@ interface CodeExampleProps {
1530
export const CodeExample: FC<CodeExampleProps> = ({
1631
code,
1732
className,
18-
19-
// Defaulting to true to be on the safe side; you should have to opt out of
20-
// the secure option, not remember to opt in
2133
secret = true,
34+
redactPattern,
35+
redactReplacement = "********",
36+
showRevealButton,
2237
}) => {
38+
const [showFullValue, setShowFullValue] = useState(false);
39+
40+
const displayValue = secret
41+
? obfuscateText(code)
42+
: redactPattern && !showFullValue
43+
? code.replace(redactPattern, redactReplacement)
44+
: code;
45+
46+
const showButtonLabel = showFullValue
47+
? "Hide sensitive data"
48+
: "Show sensitive data";
49+
const icon = showFullValue ? (
50+
<EyeOffIcon className="h-4 w-4" />
51+
) : (
52+
<EyeIcon className="h-4 w-4" />
53+
);
54+
2355
return (
2456
<div css={styles.container} className={className}>
2557
<code css={[styles.code, secret && styles.secret]}>
@@ -33,17 +65,36 @@ export const CodeExample: FC<CodeExampleProps> = ({
3365
* 2. Even with it turned on and supported, the plaintext is still
3466
* readily available in the HTML itself
3567
*/}
36-
<span aria-hidden>{obfuscateText(code)}</span>
68+
<span aria-hidden>{displayValue}</span>
3769
<span className="sr-only">
3870
Encrypted text. Please access via the copy button.
3971
</span>
4072
</>
4173
) : (
42-
code
74+
displayValue
4375
)}
4476
</code>
4577

46-
<CopyButton text={code} label="Copy code" />
78+
<div className="flex items-center gap-1">
79+
{showRevealButton && redactPattern && !secret && (
80+
<TooltipProvider>
81+
<Tooltip>
82+
<TooltipTrigger asChild>
83+
<Button
84+
size="icon"
85+
variant="subtle"
86+
onClick={() => setShowFullValue(!showFullValue)}
87+
>
88+
{icon}
89+
<span className="sr-only">{showButtonLabel}</span>
90+
</Button>
91+
</TooltipTrigger>
92+
<TooltipContent>{showButtonLabel}</TooltipContent>
93+
</Tooltip>
94+
</TooltipProvider>
95+
)}
96+
<CopyButton text={code} label="Copy code" />
97+
</div>
4798
</div>
4899
);
49100
};
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { chromatic } from "testHelpers/chromatic";
3+
import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities";
4+
import { withDashboardProvider } from "testHelpers/storybook";
5+
import { AgentExternal } from "./AgentExternal";
6+
7+
const meta: Meta<typeof AgentExternal> = {
8+
title: "modules/resources/AgentExternal",
9+
component: AgentExternal,
10+
args: {
11+
isExternalAgent: true,
12+
agent: {
13+
...MockWorkspaceAgent,
14+
status: "connecting",
15+
operating_system: "linux",
16+
architecture: "amd64",
17+
},
18+
workspace: MockWorkspace,
19+
},
20+
decorators: [withDashboardProvider],
21+
parameters: {
22+
chromatic,
23+
},
24+
};
25+
26+
export default meta;
27+
type Story = StoryObj<typeof AgentExternal>;
28+
29+
export const Connecting: Story = {
30+
args: {
31+
agent: {
32+
...MockWorkspaceAgent,
33+
status: "connecting",
34+
operating_system: "linux",
35+
architecture: "amd64",
36+
},
37+
},
38+
};
39+
40+
export const Timeout: Story = {
41+
args: {
42+
agent: {
43+
...MockWorkspaceAgent,
44+
status: "timeout",
45+
operating_system: "linux",
46+
architecture: "amd64",
47+
},
48+
},
49+
};
50+
51+
export const DifferentOS: Story = {
52+
args: {
53+
agent: {
54+
...MockWorkspaceAgent,
55+
status: "connecting",
56+
operating_system: "darwin",
57+
architecture: "arm64",
58+
},
59+
},
60+
};
61+
62+
export const NotExternalAgent: Story = {
63+
args: {
64+
isExternalAgent: false,
65+
agent: {
66+
...MockWorkspaceAgent,
67+
status: "connecting",
68+
operating_system: "linux",
69+
architecture: "amd64",
70+
},
71+
},
72+
};
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { API } from "api/api";
2+
import type { Workspace, WorkspaceAgent } from "api/typesGenerated";
3+
import isChromatic from "chromatic/isChromatic";
4+
import { CodeExample } from "components/CodeExample/CodeExample";
5+
import { type FC, useEffect, useState } from "react";
6+
7+
interface AgentExternalProps {
8+
isExternalAgent: boolean;
9+
agent: WorkspaceAgent;
10+
workspace: Workspace;
11+
}
12+
13+
export const AgentExternal: FC<AgentExternalProps> = ({
14+
isExternalAgent,
15+
agent,
16+
workspace,
17+
}) => {
18+
const [externalAgentToken, setExternalAgentToken] = useState<string | null>(
19+
null,
20+
);
21+
const [command, setCommand] = useState<string | null>(null);
22+
23+
const origin = isChromatic() ? "https://example.com" : window.location.origin;
24+
useEffect(() => {
25+
if (
26+
isExternalAgent &&
27+
(agent.status === "timeout" || agent.status === "connecting")
28+
) {
29+
API.getWorkspaceAgentCredentials(workspace.id, agent.name).then((res) => {
30+
setExternalAgentToken(res.agent_token);
31+
setCommand(res.command);
32+
});
33+
}
34+
}, [isExternalAgent, agent.status, workspace.id, agent.name]);
35+
36+
return (
37+
<section className="text-base text-muted-foreground pb-2 leading-relaxed">
38+
<p>
39+
Please run the following command to attach an agent to the{" "}
40+
{workspace.name} workspace:
41+
</p>
42+
<CodeExample
43+
code={command ?? ""}
44+
secret={false}
45+
redactPattern={/CODER_AGENT_TOKEN="([^"]+)"/g}
46+
redactReplacement={`CODER_AGENT_TOKEN="********"`}
47+
showRevealButton={true}
48+
/>
49+
</section>
50+
);
51+
};

site/src/modules/resources/AgentRow.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import AutoSizer from "react-virtualized-auto-sizer";
2727
import type { FixedSizeList as List, ListOnScrollProps } from "react-window";
2828
import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps";
2929
import { AgentDevcontainerCard } from "./AgentDevcontainerCard";
30+
import { AgentExternal } from "./AgentExternal";
3031
import { AgentLatency } from "./AgentLatency";
3132
import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogs/AgentLogLine";
3233
import { AgentLogs } from "./AgentLogs/AgentLogs";
@@ -62,9 +63,10 @@ export const AgentRow: FC<AgentRowProps> = ({
6263
const appSections = organizeAgentApps(agent.apps);
6364
const hasAppsToDisplay =
6465
!browser_only || appSections.some((it) => it.apps.length > 0);
66+
const isExternalAgent = workspace.latest_build.has_external_agent;
6567
const shouldDisplayAgentApps =
6668
(agent.status === "connected" && hasAppsToDisplay) ||
67-
agent.status === "connecting";
69+
(agent.status === "connecting" && !isExternalAgent);
6870
const hasVSCodeApp =
6971
agent.display_apps.includes("vscode") ||
7072
agent.display_apps.includes("vscode_insiders");
@@ -258,7 +260,7 @@ export const AgentRow: FC<AgentRowProps> = ({
258260
</section>
259261
)}
260262

261-
{agent.status === "connecting" && (
263+
{agent.status === "connecting" && !isExternalAgent && (
262264
<section css={styles.apps}>
263265
<Skeleton
264266
width={80}
@@ -293,6 +295,15 @@ export const AgentRow: FC<AgentRowProps> = ({
293295
</section>
294296
)}
295297

298+
{isExternalAgent &&
299+
(agent.status === "timeout" || agent.status === "connecting") && (
300+
<AgentExternal
301+
isExternalAgent={isExternalAgent}
302+
agent={agent}
303+
workspace={workspace}
304+
/>
305+
)}
306+
296307
<AgentMetadata initialMetadata={initialMetadata} agent={agent} />
297308
</div>
298309

site/src/modules/workspaces/actions.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ export const abilitiesByWorkspaceStatus = (
6363
};
6464
}
6565

66+
if (workspace.latest_build.has_external_agent) {
67+
return {
68+
actions: [],
69+
canCancel: false,
70+
canAcceptJobs: true,
71+
};
72+
}
73+
6674
const status = workspace.latest_build.status;
6775

6876
switch (status) {

0 commit comments

Comments
 (0)