Skip to content

Commit 0c39f50

Browse files
committed
add AgentExternal component to display external agent connection details, extend CodeExample to redact parts of the code
1 parent fd2458b commit 0c39f50

File tree

7 files changed

+222
-6
lines changed

7 files changed

+222
-6
lines changed

site/src/api/api.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2008,6 +2008,16 @@ class ApiMethods {
20082008
return response.data;
20092009
};
20102010

2011+
getWorkspaceAgentCredentials = async (
2012+
workspaceID: string,
2013+
agentName: string,
2014+
): Promise<TypesGen.ExternalAgentCredentials> => {
2015+
const response = await this.axios.get(
2016+
`/api/v2/workspaces/${workspaceID}/external-agent/${agentName}/credentials`,
2017+
);
2018+
return response.data;
2019+
};
2020+
20112021
upsertWorkspaceAgentSharedPort = async (
20122022
workspaceID: string,
20132023
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+
redactShowButton: true,
41+
},
42+
};

site/src/components/CodeExample/CodeExample.tsx

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

69
interface CodeExampleProps {
710
code: string;
811
secret?: boolean;
12+
redactPattern?: RegExp;
13+
redactReplacement?: string;
14+
redactShowButton?: boolean;
915
className?: string;
1016
}
1117

@@ -19,7 +25,25 @@ export const CodeExample: FC<CodeExampleProps> = ({
1925
// Defaulting to true to be on the safe side; you should have to opt out of
2026
// the secure option, not remember to opt in
2127
secret = true,
28+
29+
// Redact parts of the code if the user doesn't want to obfuscate the whole code
30+
redactPattern,
31+
redactReplacement = "********",
32+
33+
// Show a button to show the redacted parts of the code
34+
redactShowButton = false,
2235
}) => {
36+
const [showFullValue, setShowFullValue] = useState(false);
37+
38+
const displayValue = secret
39+
? obfuscateText(code)
40+
: redactPattern && !showFullValue
41+
? code.replace(redactPattern, redactReplacement)
42+
: code;
43+
44+
const showButtonLabel = showFullValue ? "Hide sensitive data" : "Show sensitive data";
45+
const icon = showFullValue ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />;
46+
2347
return (
2448
<div css={styles.container} className={className}>
2549
<code css={[styles.code, secret && styles.secret]}>
@@ -33,17 +57,36 @@ export const CodeExample: FC<CodeExampleProps> = ({
3357
* 2. Even with it turned on and supported, the plaintext is still
3458
* readily available in the HTML itself
3559
*/}
36-
<span aria-hidden>{obfuscateText(code)}</span>
60+
<span aria-hidden>{displayValue}</span>
3761
<span className="sr-only">
3862
Encrypted text. Please access via the copy button.
3963
</span>
4064
</>
4165
) : (
42-
code
66+
displayValue
4367
)}
4468
</code>
4569

46-
<CopyButton text={code} label="Copy code" />
70+
<div css={styles.actions}>
71+
{redactShowButton && redactPattern && !secret && (
72+
<TooltipProvider>
73+
<Tooltip>
74+
<TooltipTrigger asChild>
75+
<Button
76+
size="icon"
77+
variant="subtle"
78+
onClick={() => setShowFullValue(!showFullValue)}
79+
>
80+
{icon}
81+
<span className="sr-only">{showButtonLabel}</span>
82+
</Button>
83+
</TooltipTrigger>
84+
<TooltipContent>{showButtonLabel}</TooltipContent>
85+
</Tooltip>
86+
</TooltipProvider>
87+
)}
88+
<CopyButton text={code} label="Copy code" />
89+
</div>
4790
</div>
4891
);
4992
};
@@ -80,4 +123,10 @@ const styles = {
80123
secret: {
81124
"-webkit-text-security": "disc", // also supported by firefox
82125
},
126+
127+
actions: {
128+
display: "flex",
129+
alignItems: "center",
130+
gap: 4,
131+
},
83132
} satisfies Record<string, Interpolation<Theme>>;
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { chromatic } from "testHelpers/chromatic";
3+
import {
4+
MockWorkspace,
5+
MockWorkspaceAgent,
6+
} from "testHelpers/entities";
7+
import {
8+
withDashboardProvider,
9+
} from "testHelpers/storybook";
10+
import { AgentExternal } from "./AgentExternal";
11+
12+
const meta: Meta<typeof AgentExternal> = {
13+
title: "modules/resources/AgentExternal",
14+
component: AgentExternal,
15+
args: {
16+
isExternalAgent: true,
17+
agent: {
18+
...MockWorkspaceAgent,
19+
status: "connecting",
20+
operating_system: "linux",
21+
architecture: "amd64",
22+
},
23+
workspace: MockWorkspace,
24+
},
25+
decorators: [withDashboardProvider],
26+
parameters: {
27+
chromatic,
28+
},
29+
};
30+
31+
export default meta;
32+
type Story = StoryObj<typeof AgentExternal>;
33+
34+
export const Connecting: Story = {
35+
args: {
36+
agent: {
37+
...MockWorkspaceAgent,
38+
status: "connecting",
39+
operating_system: "linux",
40+
architecture: "amd64",
41+
},
42+
},
43+
};
44+
45+
export const Timeout: Story = {
46+
args: {
47+
agent: {
48+
...MockWorkspaceAgent,
49+
status: "timeout",
50+
operating_system: "linux",
51+
architecture: "amd64",
52+
},
53+
},
54+
};
55+
56+
export const DifferentOS: Story = {
57+
args: {
58+
agent: {
59+
...MockWorkspaceAgent,
60+
status: "connecting",
61+
operating_system: "darwin",
62+
architecture: "arm64",
63+
},
64+
},
65+
};
66+
67+
export const NotExternalAgent: Story = {
68+
args: {
69+
isExternalAgent: false,
70+
agent: {
71+
...MockWorkspaceAgent,
72+
status: "connecting",
73+
operating_system: "linux",
74+
architecture: "amd64",
75+
},
76+
},
77+
};
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { Interpolation, Theme } from "@emotion/react";
2+
import { API } from "api/api";
3+
import type { Workspace, WorkspaceAgent } from "api/typesGenerated";
4+
import isChromatic from "chromatic/isChromatic";
5+
import { CodeExample } from "components/CodeExample/CodeExample";
6+
import { useEffect, useState, type FC } from "react";
7+
8+
interface AgentExternalProps {
9+
isExternalAgent: boolean;
10+
agent: WorkspaceAgent;
11+
workspace: Workspace;
12+
}
13+
14+
export const AgentExternal: FC<AgentExternalProps> = ({
15+
isExternalAgent,
16+
agent,
17+
workspace,
18+
}) => {
19+
const [externalAgentToken, setExternalAgentToken] = useState<string | null>(null);
20+
21+
const origin = isChromatic() ? "https://example.com" : window.location.origin;
22+
let initScriptURL = `${origin}/api/v2/init-script`;
23+
if (agent.operating_system !== "linux" || agent.architecture !== "amd64") {
24+
initScriptURL = `${initScriptURL}?os=${agent.operating_system}&arch=${agent.architecture}`;
25+
}
26+
27+
useEffect(() => {
28+
if (isExternalAgent && (agent.status === "timeout" || agent.status === "connecting")) {
29+
API.getWorkspaceAgentCredentials(workspace.id, agent.name).then((res) => {
30+
setExternalAgentToken(res.agent_token);
31+
});
32+
}
33+
}, [isExternalAgent, agent.status, workspace.id, agent.name]);
34+
35+
return <section css={styles.externalAgentSection}>
36+
<p>
37+
Please run the following command to attach an agent to the {workspace.name} workspace:
38+
</p>
39+
<CodeExample
40+
code={`CODER_AGENT_TOKEN="${externalAgentToken}" curl -fsSL "${initScriptURL}" | sh`}
41+
secret={false}
42+
redactPattern={/CODER_AGENT_TOKEN="([^"]+)"/g}
43+
redactReplacement={`CODER_AGENT_TOKEN="********"`}
44+
redactShowButton={true}
45+
/>
46+
</section>;
47+
};
48+
49+
const styles = {
50+
externalAgentSection: (theme) => ({
51+
fontSize: 16,
52+
color: theme.palette.text.secondary,
53+
paddingBottom: 8,
54+
lineHeight: 1.4,
55+
}),
56+
} satisfies Record<string, Interpolation<Theme>>;

site/src/modules/resources/AgentRow.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { TerminalLink } from "./TerminalLink/TerminalLink";
4040
import { VSCodeDesktopButton } from "./VSCodeDesktopButton/VSCodeDesktopButton";
4141
import { useAgentContainers } from "./useAgentContainers";
4242
import { useAgentLogs } from "./useAgentLogs";
43+
import { AgentExternal } from "./AgentExternal";
4344

4445
interface AgentRowProps {
4546
agent: WorkspaceAgent;
@@ -62,6 +63,7 @@ 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) ||
6769
agent.status === "connecting";
@@ -74,7 +76,7 @@ export const AgentRow: FC<AgentRowProps> = ({
7476
const { proxy } = useProxy();
7577
const [showLogs, setShowLogs] = useState(
7678
["starting", "start_timeout"].includes(agent.lifecycle_state) &&
77-
hasStartupFeatures,
79+
hasStartupFeatures,
7880
);
7981
const agentLogs = useAgentLogs(agent, showLogs);
8082
const logListRef = useRef<List>(null);
@@ -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,11 @@ export const AgentRow: FC<AgentRowProps> = ({
293295
</section>
294296
)}
295297

298+
299+
{isExternalAgent && (agent.status === "timeout" || agent.status === "connecting") && (
300+
<AgentExternal isExternalAgent={isExternalAgent} agent={agent} workspace={workspace} />
301+
)}
302+
296303
<AgentMetadata initialMetadata={initialMetadata} agent={agent} />
297304
</div>
298305

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)