Skip to content

Commit a30c8b4

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

File tree

5 files changed

+233
-9
lines changed

5 files changed

+233
-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/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
"xterm-addon-canvas": "0.4.0",
105105
"xterm-addon-fit": "0.7.0",
106106
"xterm-addon-web-links": "0.8.0",
107+
"xterm-addon-webgl": "0.15.0",
107108
"yup": "1.2.0"
108109
},
109110
"devDependencies": {

site/pnpm-lock.yaml

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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: 195 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,152 @@
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 { WebglAddon } from "xterm-addon-webgl"
28+
29+
import "xterm/css/xterm.css"
1930

2031
type ItemStatus = "stale" | "valid" | "loading"
2132

2233
export const WatchAgentMetadataContext = createContext(watchAgentMetadata)
2334

35+
const MetadataTerminalPopover: FC<{
36+
id: string
37+
result: WorkspaceAgentMetadataResult
38+
}> = ({ id, result }) => {
39+
const styles = useStyles()
40+
41+
const viewTermRef = useRef<HTMLDivElement>(null)
42+
const [open, setOpen] = useState(false)
43+
44+
const [xtermRef, setXtermRef] = useState<HTMLDivElement | null>(null)
45+
const [terminal, setTerminal] = useState<XTerm.Terminal | null>(null)
46+
const [fitAddon, setFitAddon] = useState<FitAddon | null>(null)
47+
48+
const writeTerminal = () => {
49+
if (!terminal || !fitAddon) {
50+
return
51+
}
52+
53+
// We write the clearCode with the new value to avoid a flash of blankness
54+
// when the result value updates.
55+
const clearCode = "\x1B[2J\x1B[H"
56+
terminal.write(clearCode + result.value, () => {
57+
fitAddon.fit()
58+
})
59+
}
60+
61+
// Create the terminal.
62+
// Largely taken from TerminalPage.
63+
useEffect(() => {
64+
if (!xtermRef) {
65+
return
66+
}
67+
const terminal = new XTerm.Terminal({
68+
allowTransparency: true,
69+
disableStdin: true,
70+
fontFamily: MONOSPACE_FONT_FAMILY,
71+
fontSize: 16,
72+
// Disable cursor since there is no stdin.
73+
cursorWidth: 0,
74+
theme: {
75+
background: colors.gray[16],
76+
},
77+
})
78+
terminal.loadAddon(new WebglAddon())
79+
terminal.loadAddon(new FitAddon())
80+
81+
console.log("created terminal", terminal)
82+
83+
const fitAddon = new FitAddon()
84+
setTerminal(terminal)
85+
setFitAddon(fitAddon)
86+
terminal.open(xtermRef)
87+
writeTerminal()
88+
89+
// This listener doesn't appear to have an effect, but it's used
90+
// in TerminalPage.tsx so I've copied it here.
91+
const listener = () => {
92+
fitAddon.fit()
93+
}
94+
window.addEventListener("resize", listener)
95+
96+
const w: any = window
97+
w.terminal = terminal
98+
99+
// TODO: this is obviously immensely janky. But, it's the only
100+
// way I'm able to get the text to render correctly.
101+
const resizeInterval = setInterval(() => {
102+
window.dispatchEvent(new Event("resize"))
103+
}, 100)
104+
105+
return () => {
106+
window.removeEventListener("resize", listener)
107+
clearInterval(resizeInterval)
108+
terminal.dispose()
109+
}
110+
}, [xtermRef, open])
111+
112+
useEffect(() => {
113+
writeTerminal()
114+
}, [xtermRef, open, result])
115+
116+
return (
117+
<>
118+
<div
119+
className={styles.viewTerminal}
120+
ref={viewTermRef}
121+
onMouseOver={() => {
122+
setOpen(true)
123+
}}
124+
>
125+
View Terminal
126+
</div>
127+
128+
<Popover
129+
id={id}
130+
open={open}
131+
onClose={() => setOpen(false)}
132+
anchorEl={viewTermRef.current}
133+
anchorOrigin={{
134+
vertical: "bottom",
135+
horizontal: "left",
136+
}}
137+
>
138+
<div
139+
className={styles.terminal}
140+
ref={(el) => {
141+
setXtermRef(el)
142+
}}
143+
data-testid="terminal"
144+
/>
145+
</Popover>
146+
</>
147+
)
148+
}
149+
24150
const MetadataItem: FC<{ item: WorkspaceAgentMetadata }> = ({ item }) => {
25151
const styles = useStyles()
26152

@@ -31,6 +157,13 @@ const MetadataItem: FC<{ item: WorkspaceAgentMetadata }> = ({ item }) => {
31157
throw new Error("Metadata item description is undefined")
32158
}
33159

160+
const terminalPrefix = "terminal:"
161+
const isTerminal = item.description.display_name.startsWith(terminalPrefix)
162+
163+
const displayName = isTerminal
164+
? item.description.display_name.slice(terminalPrefix.length)
165+
: item.description.display_name
166+
34167
const staleThreshold = Math.max(
35168
item.description.interval + item.description.timeout * 2,
36169
// In case there is intense backpressure, we give a little bit of slack.
@@ -88,10 +221,15 @@ const MetadataItem: FC<{ item: WorkspaceAgentMetadata }> = ({ item }) => {
88221

89222
return (
90223
<div className={styles.metadata}>
91-
<div className={styles.metadataLabel}>
92-
{item.description.display_name}
93-
</div>
94-
<Box>{value}</Box>
224+
<div className={styles.metadataLabel}>{displayName}</div>
225+
{isTerminal ? (
226+
<MetadataTerminalPopover
227+
id={`metadata-terminal-${item.description.key}`}
228+
result={item.result}
229+
/>
230+
) : (
231+
<Box>{value}</Box>
232+
)}
95233
</div>
96234
)
97235
}
@@ -105,6 +243,7 @@ export const AgentMetadataView: FC<AgentMetadataViewProps> = ({ metadata }) => {
105243
if (metadata.length === 0) {
106244
return <></>
107245
}
246+
108247
return (
109248
<div className={styles.root}>
110249
<Stack alignItems="baseline" direction="row" spacing={6}>
@@ -228,6 +367,53 @@ const useStyles = makeStyles((theme) => ({
228367
scrollPadding: theme.spacing(0, 4),
229368
},
230369

370+
viewTerminal: {
371+
fontFamily: MONOSPACE_FONT_FAMILY,
372+
display: "inline-block",
373+
textDecoration: "underline",
374+
fontWeight: 600,
375+
margin: 0,
376+
fontSize: 14,
377+
borderRadius: 4,
378+
color: theme.palette.text.primary,
379+
},
380+
381+
terminal: {
382+
width: "100vw",
383+
height: "30vh",
384+
overflow: "hidden",
385+
backgroundColor: theme.palette.background.paper,
386+
flex: 1,
387+
// These styles attempt to mimic the VS Code scrollbar.
388+
"& .xterm": {
389+
padding: 4,
390+
width: "100vw",
391+
height: "100vh",
392+
},
393+
"& .xterm-viewport": {
394+
// This is required to force full-width on the terminal.
395+
// Otherwise there's a small white bar to the right of the scrollbar.
396+
width: "auto !important",
397+
},
398+
"& .xterm-viewport::-webkit-scrollbar": {
399+
width: "10px",
400+
},
401+
"& .xterm-viewport::-webkit-scrollbar-track": {
402+
backgroundColor: "inherit",
403+
},
404+
"& .xterm-viewport::-webkit-scrollbar-thumb": {
405+
minHeight: 20,
406+
backgroundColor: "rgba(255, 255, 255, 0.18)",
407+
},
408+
},
409+
410+
popover: {
411+
padding: 0,
412+
width: theme.spacing(38),
413+
color: theme.palette.text.secondary,
414+
marginTop: theme.spacing(0.5),
415+
},
416+
231417
metadata: {
232418
fontSize: 12,
233419
lineHeight: "normal",

0 commit comments

Comments
 (0)