Skip to content

Commit e87b4ee

Browse files
committed
feat: support agent metadata terminals
1 parent be40dc8 commit e87b4ee

File tree

3 files changed

+192
-8
lines changed

3 files changed

+192
-8
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: 166 additions & 8 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"
37
import { WorkspaceAgent, WorkspaceAgentMetadata } from "api/typesGenerated"
48
import { Stack } from "components/Stack/Stack"
59
import dayjs from "dayjs"
610
import {
7-
createContext,
811
FC,
12+
createContext,
913
useContext,
1014
useEffect,
1115
useRef,
1216
useState,
1317
} from "react"
14-
import Skeleton from "@mui/material/Skeleton"
18+
import { colors } from "theme/colors"
1519
import { MONOSPACE_FONT_FAMILY } from "theme/constants"
1620
import { combineClasses } from "utils/combineClasses"
17-
import Tooltip from "@mui/material/Tooltip"
18-
import Box, { BoxProps } from "@mui/material/Box"
21+
import * as XTerm from "xterm"
22+
import { FitAddon } from "xterm-addon-fit"
23+
import "xterm/css/xterm.css"
1924

2025
type ItemStatus = "stale" | "valid" | "loading"
2126

2227
export const WatchAgentMetadataContext = createContext(watchAgentMetadata)
2328

29+
const MetadataTerminalPopover: FC<{
30+
id: string
31+
value: string
32+
}> = ({ id, value: value }) => {
33+
const styles = useStyles()
34+
35+
const viewTermRef = useRef<HTMLDivElement>(null)
36+
const [open, setOpen] = useState(false)
37+
38+
const [xtermRef, setXtermRef] = useState<HTMLDivElement | null>(null)
39+
const [terminal, setTerminal] = useState<XTerm.Terminal | null>(null)
40+
const [fitAddon, setFitAddon] = useState<FitAddon | null>(null)
41+
42+
// Create the terminal.
43+
// Largely taken from TerminalPage.
44+
useEffect(() => {
45+
if (!xtermRef) {
46+
return
47+
}
48+
const terminal = new XTerm.Terminal({
49+
allowTransparency: true,
50+
disableStdin: true,
51+
fontFamily: MONOSPACE_FONT_FAMILY,
52+
fontSize: 16,
53+
theme: {
54+
background: colors.gray[16],
55+
},
56+
})
57+
console.log("created terminal", terminal)
58+
59+
const fitAddon = new FitAddon()
60+
setTerminal(terminal)
61+
setFitAddon(fitAddon)
62+
fitAddon.fit()
63+
terminal.open(xtermRef)
64+
terminal.write(value)
65+
const listener = () => {
66+
// This will trigger a resize event on the terminal.
67+
fitAddon.fit()
68+
console.log("fitting")
69+
}
70+
window.addEventListener("resize", listener)
71+
return () => {
72+
console.log("disposing")
73+
window.removeEventListener("resize", listener)
74+
terminal.dispose()
75+
}
76+
}, [xtermRef, open])
77+
78+
useEffect(() => {
79+
console.log("effect triggered", open, terminal, fitAddon)
80+
if (open && terminal !== null && fitAddon !== null) {
81+
console.log("fitting")
82+
fitAddon.fit()
83+
}
84+
}, [open, terminal, fitAddon])
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
5,
@@ -87,10 +192,15 @@ const MetadataItem: FC<{ item: WorkspaceAgentMetadata }> = ({ item }) => {
87192

88193
return (
89194
<div className={styles.metadata}>
90-
<div className={styles.metadataLabel}>
91-
{item.description.display_name}
92-
</div>
93-
<Box>{value}</Box>
195+
<div className={styles.metadataLabel}>{displayName}</div>
196+
{isTerminal ? (
197+
<MetadataTerminalPopover
198+
id={`metadata-terminal-${item.description.key}`}
199+
value={item.result.value}
200+
/>
201+
) : (
202+
<Box>{value}</Box>
203+
)}
94204
</div>
95205
)
96206
}
@@ -104,6 +214,7 @@ export const AgentMetadataView: FC<AgentMetadataViewProps> = ({ metadata }) => {
104214
if (metadata.length === 0) {
105215
return <></>
106216
}
217+
107218
return (
108219
<div className={styles.root}>
109220
<Stack alignItems="baseline" direction="row" spacing={6}>
@@ -227,6 +338,53 @@ const useStyles = makeStyles((theme) => ({
227338
scrollPadding: theme.spacing(0, 4),
228339
},
229340

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

0 commit comments

Comments
 (0)