Skip to content

Commit 3c57de7

Browse files
committed
feat: support agent metadata terminals
1 parent 6214117 commit 3c57de7

File tree

3 files changed

+193
-9
lines changed

3 files changed

+193
-9
lines changed

examples/templates/jfrog/docker/main.tf

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,14 @@ resource "coder_agent" "main" {
109109
JFROG_IDE_ACCESS_TOKEN : "${artifactory_scoped_token.me.access_token}"
110110
JFROG_IDE_STORE_CONNECTION : "true"
111111
}
112+
113+
metadata {
114+
key = "cpu"
115+
display_name = "CPU"
116+
script = "coder stat cpu"
117+
timeout = 1
118+
interval = 1
119+
}
112120
}
113121

114122
resource "coder_app" "code-server" {

site/src/components/Resources/AgentMetadata.stories.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,21 @@ Example.args = {
105105
},
106106
],
107107
}
108+
109+
export const Terminal = Template.bind({})
110+
Terminal.args = {
111+
metadata: [
112+
{
113+
result: {
114+
...resultDefaults,
115+
value: "\x1b[32mHello, World!\x1b[0m",
116+
},
117+
description: {
118+
...descriptionDefaults,
119+
display_name: "terminal:hello world",
120+
key: "term",
121+
script: "echo hello world",
122+
},
123+
},
124+
],
125+
}

site/src/components/Resources/AgentMetadata.tsx

Lines changed: 167 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,124 @@
1+
import Box, { BoxProps } from "@mui/material/Box"
2+
import Popover from "@mui/material/Popover"
3+
import Skeleton from "@mui/material/Skeleton"
4+
import Tooltip from "@mui/material/Tooltip"
15
import makeStyles from "@mui/styles/makeStyles"
26
import { watchAgentMetadata } from "api/api"
3-
import { WorkspaceAgent, WorkspaceAgentMetadata } from "api/typesGenerated"
7+
import {
8+
WorkspaceAgent,
9+
WorkspaceAgentMetadata,
10+
WorkspaceAgentMetadataResult,
11+
} from "api/typesGenerated"
412
import { Stack } from "components/Stack/Stack"
513
import dayjs from "dayjs"
614
import {
7-
createContext,
815
FC,
16+
createContext,
917
useContext,
1018
useEffect,
1119
useRef,
1220
useState,
1321
} from "react"
14-
import Skeleton from "@mui/material/Skeleton"
22+
import { colors } from "theme/colors"
1523
import { MONOSPACE_FONT_FAMILY } from "theme/constants"
1624
import { combineClasses } from "utils/combineClasses"
17-
import Tooltip from "@mui/material/Tooltip"
18-
import Box, { BoxProps } from "@mui/material/Box"
25+
import * as XTerm from "xterm"
26+
import { FitAddon } from "xterm-addon-fit"
27+
import "xterm/css/xterm.css"
1928

2029
type ItemStatus = "stale" | "valid" | "loading"
2130

2231
export const WatchAgentMetadataContext = createContext(watchAgentMetadata)
2332

33+
const MetadataTerminalPopover: FC<{
34+
id: string
35+
result: WorkspaceAgentMetadataResult
36+
}> = ({ id, result }) => {
37+
const styles = useStyles()
38+
39+
const viewTermRef = useRef<HTMLDivElement>(null)
40+
const [open, setOpen] = useState(false)
41+
42+
const [xtermRef, setXtermRef] = useState<HTMLDivElement | null>(null)
43+
const [terminal, setTerminal] = useState<XTerm.Terminal | null>(null)
44+
const [fitAddon, setFitAddon] = useState<FitAddon | null>(null)
45+
46+
// Create the terminal.
47+
// Largely taken from TerminalPage.
48+
useEffect(() => {
49+
if (!xtermRef) {
50+
return
51+
}
52+
const terminal = new XTerm.Terminal({
53+
allowTransparency: true,
54+
disableStdin: true,
55+
fontFamily: MONOSPACE_FONT_FAMILY,
56+
fontSize: 16,
57+
theme: {
58+
background: colors.gray[16],
59+
},
60+
})
61+
console.log("created terminal", terminal)
62+
63+
const fitAddon = new FitAddon()
64+
setTerminal(terminal)
65+
setFitAddon(fitAddon)
66+
fitAddon.fit()
67+
terminal.open(xtermRef)
68+
terminal.write(result.value)
69+
70+
return () => {
71+
terminal.dispose()
72+
}
73+
}, [xtermRef, open])
74+
75+
useEffect(() => {
76+
if (!xtermRef || !open || !terminal) {
77+
return
78+
}
79+
80+
// We write the clearCode with the new value to avoid a flash of blankness
81+
// when the result value updates.
82+
const clearCode = "\x1B[2J\x1B[H"
83+
terminal.write(clearCode + result.value)
84+
}, [xtermRef, open, result])
85+
86+
return (
87+
<>
88+
<div
89+
className={styles.viewTerminal}
90+
ref={viewTermRef}
91+
onMouseOver={() => {
92+
setOpen(true)
93+
}}
94+
>
95+
View Terminal
96+
</div>
97+
98+
<Popover
99+
id={id}
100+
open={open}
101+
onClose={() => setOpen(false)}
102+
anchorEl={viewTermRef.current}
103+
anchorOrigin={{
104+
vertical: "bottom",
105+
horizontal: "left",
106+
}}
107+
>
108+
<Box p={1}>
109+
<div
110+
className={styles.terminal}
111+
ref={(el) => {
112+
setXtermRef(el)
113+
}}
114+
data-testid="terminal"
115+
/>
116+
</Box>
117+
</Popover>
118+
</>
119+
)
120+
}
121+
24122
const MetadataItem: FC<{ item: WorkspaceAgentMetadata }> = ({ item }) => {
25123
const styles = useStyles()
26124

@@ -31,6 +129,13 @@ const MetadataItem: FC<{ item: WorkspaceAgentMetadata }> = ({ item }) => {
31129
throw new Error("Metadata item description is undefined")
32130
}
33131

132+
const terminalPrefix = "terminal:"
133+
const isTerminal = item.description.display_name.startsWith(terminalPrefix)
134+
135+
const displayName = isTerminal
136+
? item.description.display_name.slice(terminalPrefix.length)
137+
: item.description.display_name
138+
34139
const staleThreshold = Math.max(
35140
item.description.interval + item.description.timeout * 2,
36141
// In case there is intense backpressure, we give a little bit of slack.
@@ -88,10 +193,15 @@ const MetadataItem: FC<{ item: WorkspaceAgentMetadata }> = ({ item }) => {
88193

89194
return (
90195
<div className={styles.metadata}>
91-
<div className={styles.metadataLabel}>
92-
{item.description.display_name}
93-
</div>
94-
<Box>{value}</Box>
196+
<div className={styles.metadataLabel}>{displayName}</div>
197+
{isTerminal ? (
198+
<MetadataTerminalPopover
199+
id={`metadata-terminal-${item.description.key}`}
200+
result={item.result}
201+
/>
202+
) : (
203+
<Box>{value}</Box>
204+
)}
95205
</div>
96206
)
97207
}
@@ -105,6 +215,7 @@ export const AgentMetadataView: FC<AgentMetadataViewProps> = ({ metadata }) => {
105215
if (metadata.length === 0) {
106216
return <></>
107217
}
218+
108219
return (
109220
<div className={styles.root}>
110221
<Stack alignItems="baseline" direction="row" spacing={6}>
@@ -228,6 +339,53 @@ const useStyles = makeStyles((theme) => ({
228339
scrollPadding: theme.spacing(0, 4),
229340
},
230341

342+
viewTerminal: {
343+
fontFamily: MONOSPACE_FONT_FAMILY,
344+
display: "inline-block",
345+
textDecoration: "underline",
346+
fontWeight: 600,
347+
margin: 0,
348+
fontSize: 14,
349+
borderRadius: 4,
350+
color: theme.palette.text.primary,
351+
},
352+
353+
terminal: {
354+
width: "100vw",
355+
height: "30vh",
356+
overflow: "hidden",
357+
backgroundColor: theme.palette.background.paper,
358+
flex: 1,
359+
// These styles attempt to mimic the VS Code scrollbar.
360+
"& .xterm": {
361+
padding: 4,
362+
width: "100vw",
363+
height: "100vh",
364+
},
365+
"& .xterm-viewport": {
366+
// This is required to force full-width on the terminal.
367+
// Otherwise there's a small white bar to the right of the scrollbar.
368+
width: "auto !important",
369+
},
370+
"& .xterm-viewport::-webkit-scrollbar": {
371+
width: "10px",
372+
},
373+
"& .xterm-viewport::-webkit-scrollbar-track": {
374+
backgroundColor: "inherit",
375+
},
376+
"& .xterm-viewport::-webkit-scrollbar-thumb": {
377+
minHeight: 20,
378+
backgroundColor: "rgba(255, 255, 255, 0.18)",
379+
},
380+
},
381+
382+
popover: {
383+
padding: 0,
384+
width: theme.spacing(38),
385+
color: theme.palette.text.secondary,
386+
marginTop: theme.spacing(0.5),
387+
},
388+
231389
metadata: {
232390
fontSize: 12,
233391
lineHeight: "normal",

0 commit comments

Comments
 (0)