feat: add agent metadata (#6614)

This commit is contained in:
Ammar Bandukwala
2023-03-31 15:26:19 -05:00
committed by GitHub
parent c191692751
commit ca4fa81570
62 changed files with 3139 additions and 727 deletions
+12
View File
@@ -1046,6 +1046,18 @@ const getMissingParameters = (
return missingParameters
}
/**
*
* @param agentId
* @returns An EventSource that emits agent metadata event objects (ServerSentEvent)
*/
export const watchAgentMetadata = (agentId: string): EventSource => {
return new EventSource(
`${location.protocol}//${location.host}/api/v2/workspaceagents/${agentId}/watch-metadata`,
{ withCredentials: true },
)
}
export const watchBuildLogs = (
versionId: string,
onMessage: (log: TypesGen.ProvisionerJobLog) => void,
+23
View File
@@ -1086,6 +1086,29 @@ export interface WorkspaceAgentListeningPortsResponse {
readonly ports: WorkspaceAgentListeningPort[]
}
// From codersdk/workspaceagents.go
export interface WorkspaceAgentMetadata {
readonly result: WorkspaceAgentMetadataResult
readonly description: WorkspaceAgentMetadataDescription
}
// From codersdk/workspaceagents.go
export interface WorkspaceAgentMetadataDescription {
readonly display_name: string
readonly key: string
readonly script: string
readonly interval: number
readonly timeout: number
}
// From codersdk/workspaceagents.go
export interface WorkspaceAgentMetadataResult {
readonly collected_at: string
readonly age: number
readonly value: string
readonly error: string
}
// From codersdk/workspaceagents.go
export interface WorkspaceAgentStartupLog {
readonly id: number
@@ -0,0 +1,107 @@
import { Story } from "@storybook/react"
import {
WorkspaceAgentMetadataDescription,
WorkspaceAgentMetadataResult,
} from "api/typesGenerated"
import { AgentMetadataView, AgentMetadataViewProps } from "./AgentMetadata"
export default {
title: "components/AgentMetadata",
component: AgentMetadataView,
}
const Template: Story<AgentMetadataViewProps> = (args) => (
<AgentMetadataView {...args} />
)
const resultDefaults: WorkspaceAgentMetadataResult = {
collected_at: "2021-05-05T00:00:00Z",
error: "",
value: "defvalue",
age: 5,
}
const descriptionDefaults: WorkspaceAgentMetadataDescription = {
display_name: "DisPlay",
key: "defkey",
interval: 10,
timeout: 10,
script: "some command",
}
export const Example = Template.bind({})
Example.args = {
metadata: [
{
result: {
...resultDefaults,
value: "110%",
},
description: {
...descriptionDefaults,
display_name: "CPU",
key: "CPU",
},
},
{
result: {
...resultDefaults,
value: "50GB",
},
description: {
...descriptionDefaults,
display_name: "Memory",
key: "Memory",
},
},
{
result: {
...resultDefaults,
value: "cant see it",
age: 300,
},
description: {
...descriptionDefaults,
interval: 5,
display_name: "Stale",
key: "stale",
},
},
{
result: {
...resultDefaults,
value: "oops",
error: "fatal error",
},
description: {
...descriptionDefaults,
display_name: "Error",
},
},
{
result: {
...resultDefaults,
value: "oops",
error: "fatal error",
},
description: {
...descriptionDefaults,
display_name: "Error",
key: "stale",
},
},
{
result: {
...resultDefaults,
value: "",
collected_at: "0001-01-01T00:00:00Z",
age: 1000000,
},
description: {
...descriptionDefaults,
display_name: "Never loads",
key: "nloads",
},
},
],
}
@@ -0,0 +1,319 @@
import Popover from "@material-ui/core/Popover"
import CircularProgress from "@material-ui/core/CircularProgress"
import makeStyles from "@material-ui/core/styles/makeStyles"
import { watchAgentMetadata } from "api/api"
import { WorkspaceAgent, WorkspaceAgentMetadata } from "api/typesGenerated"
import { CodeExample } from "components/CodeExample/CodeExample"
import { Stack } from "components/Stack/Stack"
import {
HelpTooltipText,
HelpTooltipTitle,
} from "components/Tooltips/HelpTooltip"
import dayjs from "dayjs"
import {
createContext,
FC,
PropsWithChildren,
useContext,
useEffect,
useRef,
useState,
} from "react"
export const WatchAgentMetadataContext = createContext(watchAgentMetadata)
const MetadataItemValue: FC<
PropsWithChildren<{ item: WorkspaceAgentMetadata }>
> = ({ item, children }) => {
const [isOpen, setIsOpen] = useState(false)
const anchorRef = useRef<HTMLDivElement>(null)
const styles = useStyles()
return (
<>
<div
ref={anchorRef}
onMouseEnter={() => setIsOpen(true)}
role="presentation"
>
{children}
</div>
<Popover
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
open={isOpen}
anchorEl={anchorRef.current}
onClose={() => setIsOpen(false)}
PaperProps={{
onMouseEnter: () => setIsOpen(true),
onMouseLeave: () => setIsOpen(false),
}}
classes={{ paper: styles.metadataPopover }}
>
<HelpTooltipTitle>{item.description.display_name}</HelpTooltipTitle>
{item.result.value.length > 0 && (
<>
<HelpTooltipText>Last result:</HelpTooltipText>
<HelpTooltipText>
<CodeExample code={item.result.value} />
</HelpTooltipText>
</>
)}
{item.result.error.length > 0 && (
<>
<HelpTooltipText>Last error:</HelpTooltipText>
<HelpTooltipText>
<CodeExample code={item.result.error} />
</HelpTooltipText>
</>
)}
</Popover>
</>
)
}
const MetadataItem: FC<{ item: WorkspaceAgentMetadata }> = ({ item }) => {
const styles = useStyles()
const [isOpen, setIsOpen] = useState(false)
const labelAnchorRef = useRef<HTMLDivElement>(null)
if (item.result === undefined) {
throw new Error("Metadata item result is undefined")
}
if (item.description === undefined) {
throw new Error("Metadata item description is undefined")
}
const staleThreshold = Math.max(
item.description.interval + item.description.timeout * 2,
5,
)
const status: "stale" | "valid" | "loading" = (() => {
const year = dayjs(item.result.collected_at).year()
if (year <= 1970 || isNaN(year)) {
return "loading"
}
if (item.result.age > staleThreshold) {
return "stale"
}
return "valid"
})()
// Stale data is as good as no data. Plus, we want to build confidence in our
// users that what's shown is real. If times aren't correctly synced this
// could be buggy. But, how common is that anyways?
const value =
status === "stale" || status === "loading" ? (
<CircularProgress size={12} />
) : (
<div
className={
styles.metadataValue +
" " +
(item.result.error.length === 0
? styles.metadataValueSuccess
: styles.metadataValueError)
}
>
{item.result.value}
</div>
)
const updatesInSeconds = -(item.description.interval - item.result.age)
return (
<>
<div className={styles.metadata}>
<div
className={styles.metadataLabel}
onMouseEnter={() => setIsOpen(true)}
// onMouseLeave={() => setIsOpen(false)}
role="presentation"
ref={labelAnchorRef}
>
{item.description.display_name}
</div>
<MetadataItemValue item={item}>{value}</MetadataItemValue>
</div>
<Popover
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
open={isOpen}
anchorEl={labelAnchorRef.current}
onClose={() => setIsOpen(false)}
PaperProps={{
onMouseEnter: () => setIsOpen(true),
onMouseLeave: () => setIsOpen(false),
}}
classes={{ paper: styles.metadataPopover }}
>
<HelpTooltipTitle>{item.description.display_name}</HelpTooltipTitle>
{status === "stale" ? (
<HelpTooltipText>
This item is now stale because the agent hasn{"'"}t reported a new
value in {dayjs.duration(item.result.age, "s").humanize()}.
</HelpTooltipText>
) : (
<></>
)}
{status === "valid" ? (
<HelpTooltipText>
The agent collected this value{" "}
{dayjs.duration(item.result.age, "s").humanize()} ago and will
update it in{" "}
{dayjs.duration(Math.min(updatesInSeconds, 0), "s").humanize()}.
</HelpTooltipText>
) : (
<></>
)}
{status === "loading" ? (
<HelpTooltipText>
This value is loading for the first time...
</HelpTooltipText>
) : (
<></>
)}
<HelpTooltipText>
This value is produced by the following script:
</HelpTooltipText>
<HelpTooltipText>
<CodeExample code={item.description.script}></CodeExample>
</HelpTooltipText>
</Popover>
</>
)
}
export interface AgentMetadataViewProps {
metadata: WorkspaceAgentMetadata[]
}
export const AgentMetadataView: FC<AgentMetadataViewProps> = ({ metadata }) => {
const styles = useStyles()
if (metadata.length === 0) {
return <></>
}
return (
<Stack
alignItems="flex-start"
direction="row"
spacing={5}
className={styles.metadataStack}
>
<div className={styles.metadataHeader}>
{metadata.map((m) => {
if (m.description === undefined) {
throw new Error("Metadata item description is undefined")
}
return <MetadataItem key={m.description.key} item={m} />
})}
</div>
</Stack>
)
}
export const AgentMetadata: FC<{
agent: WorkspaceAgent
}> = ({ agent }) => {
const [metadata, setMetadata] = useState<
WorkspaceAgentMetadata[] | undefined
>(undefined)
const watchAgentMetadata = useContext(WatchAgentMetadataContext)
useEffect(() => {
const source = watchAgentMetadata(agent.id)
source.onerror = (e) => {
console.error("received error in watch stream", e)
}
source.addEventListener("data", (e) => {
const data = JSON.parse(e.data)
setMetadata(data)
})
return () => {
source.close()
}
}, [agent.id, watchAgentMetadata])
if (metadata === undefined) {
return <CircularProgress size={16} />
}
return <AgentMetadataView metadata={metadata} />
}
// These are more or less copied from
// site/src/components/Resources/ResourceCard.tsx
const useStyles = makeStyles((theme) => ({
metadataStack: {
border: `2px dashed ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
width: "100%",
},
metadataHeader: {
padding: "8px",
display: "grid",
gridTemplateColumns: "repeat(4, minmax(0, 1fr))",
gap: theme.spacing(5),
rowGap: theme.spacing(3),
},
metadata: {
fontSize: 16,
},
metadataLabel: {
fontSize: 12,
color: theme.palette.text.secondary,
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
fontWeight: "bold",
},
metadataValue: {
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
},
metadataValueSuccess: {
color: theme.palette.success.light,
},
metadataValueError: {
color: theme.palette.error.main,
},
metadataPopover: {
marginTop: theme.spacing(0.5),
padding: theme.spacing(2.5),
color: theme.palette.text.secondary,
pointerEvents: "auto",
maxWidth: "480px",
"& .MuiButton-root": {
padding: theme.spacing(1, 2),
borderRadius: 0,
border: 0,
"&:hover": {
background: theme.palette.action.hover,
},
},
},
}))
+175 -150
View File
@@ -35,6 +35,7 @@ import { SSHButton } from "../SSHButton/SSHButton"
import { Stack } from "../Stack/Stack"
import { TerminalLink } from "../TerminalLink/TerminalLink"
import { AgentLatency } from "./AgentLatency"
import { AgentMetadata } from "./AgentMetadata"
import { AgentStatus } from "./AgentStatus"
import { AgentVersion } from "./AgentVersion"
@@ -169,178 +170,202 @@ export const AgentRow: FC<AgentRowProps> = ({
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
className={styles.agentRow}
spacing={4}
style={{
justifyContent: "none",
alignItems: "center",
}}
>
<Stack direction="row" alignItems="baseline">
<div className={styles.agentStatusWrapper}>
<AgentStatus agent={agent} />
</div>
<div>
<div className={styles.agentName}>{agent.name}</div>
<div className={styles.agentStatusWrapper}>
<AgentStatus agent={agent} />
</div>
<Stack
direction="column"
alignItems="flex-start"
key={agent.id}
spacing={2}
style={{
flex: 1,
}}
>
<Stack
direction="row"
alignItems="center"
style={{
width: "100%",
justifyContent: "space-between",
}}
>
<Stack direction="row" alignItems="baseline">
<div>
<div className={styles.agentName}>{agent.name}</div>
<Stack
direction="row"
alignItems="baseline"
className={styles.agentData}
spacing={1}
>
<span className={styles.agentOS}>
{agent.operating_system}
</span>
<Maybe condition={agent.status === "connected"}>
<AgentVersion
agent={agent}
serverVersion={serverVersion}
onUpdate={onUpdateAgent}
/>
</Maybe>
<AgentLatency agent={agent} />
<Maybe condition={agent.status === "connecting"}>
<Skeleton width={160} variant="text" />
<Skeleton width={36} variant="text" />
</Maybe>
<Maybe condition={agent.status === "timeout"}>
{t("unableToConnect")}
</Maybe>
</Stack>
</div>
</Stack>
<Stack
direction="row"
alignItems="center"
spacing={0.5}
wrap="wrap"
maxWidth="750px"
>
{showApps && agent.status === "connected" && (
<>
{agent.apps.map((app) => (
<AppLink
key={app.slug}
appsHost={applicationsHost}
app={app}
agent={agent}
workspace={workspace}
/>
))}
<TerminalLink
workspaceName={workspace.name}
agentName={agent.name}
userName={workspace.owner_name}
/>
{!hideSSHButton && (
<SSHButton
workspaceName={workspace.name}
agentName={agent.name}
sshPrefix={sshPrefix}
/>
)}
{!hideVSCodeDesktopButton && (
<VSCodeDesktopButton
userName={workspace.owner_name}
workspaceName={workspace.name}
agentName={agent.name}
folderPath={agent.expanded_directory}
/>
)}
{applicationsHost !== undefined &&
applicationsHost !== "" && (
<PortForwardButton
host={applicationsHost}
workspaceName={workspace.name}
agentId={agent.id}
agentName={agent.name}
username={workspace.owner_name}
/>
)}
</>
)}
{showApps && agent.status === "connecting" && (
<>
<AppLinkSkeleton width={84} />
<AppLinkSkeleton width={112} />
</>
)}
</Stack>
</Stack>
<AgentMetadata agent={agent} />
{hasStartupFeatures && (
<Stack
direction="row"
alignItems="baseline"
className={styles.agentData}
spacing={1}
className={styles.startupLinks}
>
<span className={styles.agentOS}>{agent.operating_system}</span>
<Maybe condition={agent.status === "connected"}>
<AgentVersion
agent={agent}
serverVersion={serverVersion}
onUpdate={onUpdateAgent}
/>
</Maybe>
<AgentLatency agent={agent} />
<Maybe condition={agent.status === "connecting"}>
<Skeleton width={160} variant="text" />
<Skeleton width={36} variant="text" />
</Maybe>
<Maybe condition={agent.status === "timeout"}>
{t("unableToConnect")}
</Maybe>
</Stack>
{hasStartupFeatures && (
<Stack
direction="row"
alignItems="baseline"
spacing={1}
className={styles.startupLinks}
<Link
className={styles.startupLink}
variant="body2"
onClick={() => {
setShowStartupLogs(!showStartupLogs)
}}
>
{showStartupLogs ? (
<VisibilityOffOutlined />
) : (
<VisibilityOutlined />
)}
{showStartupLogs ? "Hide" : "Show"} Startup Logs
</Link>
{agent.startup_script && (
<Link
className={styles.startupLink}
variant="body2"
ref={startupScriptAnchorRef}
onClick={() => {
setShowStartupLogs(!showStartupLogs)
setStartupScriptOpen(!startupScriptOpen)
}}
>
{showStartupLogs ? (
<VisibilityOffOutlined />
) : (
<VisibilityOutlined />
)}
{showStartupLogs ? "Hide" : "Show"} Startup Logs
<PlayCircleOutlined />
View Startup Script
</Link>
)}
{agent.startup_script && (
<Link
className={styles.startupLink}
variant="body2"
ref={startupScriptAnchorRef}
onClick={() => {
setStartupScriptOpen(!startupScriptOpen)
<Popover
classes={{
paper: styles.startupScriptPopover,
}}
open={startupScriptOpen}
onClose={() => setStartupScriptOpen(false)}
anchorEl={startupScriptAnchorRef.current}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
>
<div>
<SyntaxHighlighter
style={darcula}
language="shell"
showLineNumbers
// Use inline styles does not work correctly
// https://github.com/react-syntax-highlighter/react-syntax-highlighter/issues/329
codeTagProps={{ style: {} }}
customStyle={{
background: theme.palette.background.default,
maxWidth: 600,
margin: 0,
}}
>
<PlayCircleOutlined />
View Startup Script
</Link>
)}
<Popover
classes={{
paper: styles.startupScriptPopover,
}}
open={startupScriptOpen}
onClose={() => setStartupScriptOpen(false)}
anchorEl={startupScriptAnchorRef.current}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
>
<div>
<SyntaxHighlighter
style={darcula}
language="shell"
showLineNumbers
// Use inline styles does not work correctly
// https://github.com/react-syntax-highlighter/react-syntax-highlighter/issues/329
codeTagProps={{ style: {} }}
customStyle={{
background: theme.palette.background.default,
maxWidth: 600,
margin: 0,
}}
>
{agent.startup_script || ""}
</SyntaxHighlighter>
</div>
</Popover>
</Stack>
)}
</div>
</Stack>
<Stack
direction="row"
alignItems="center"
spacing={0.5}
wrap="wrap"
maxWidth="750px"
>
{showApps && agent.status === "connected" && (
<>
{agent.apps.map((app) => (
<AppLink
key={app.slug}
appsHost={applicationsHost}
app={app}
agent={agent}
workspace={workspace}
/>
))}
<TerminalLink
workspaceName={workspace.name}
agentName={agent.name}
userName={workspace.owner_name}
/>
{!hideSSHButton && (
<SSHButton
workspaceName={workspace.name}
agentName={agent.name}
sshPrefix={sshPrefix}
/>
)}
{!hideVSCodeDesktopButton && (
<VSCodeDesktopButton
userName={workspace.owner_name}
workspaceName={workspace.name}
agentName={agent.name}
folderPath={agent.expanded_directory}
/>
)}
{applicationsHost !== undefined && applicationsHost !== "" && (
<PortForwardButton
host={applicationsHost}
workspaceName={workspace.name}
agentId={agent.id}
agentName={agent.name}
username={workspace.owner_name}
/>
)}
</>
)}
{showApps && agent.status === "connecting" && (
<>
<AppLinkSkeleton width={84} />
<AppLinkSkeleton width={112} />
</>
{agent.startup_script || ""}
</SyntaxHighlighter>
</div>
</Popover>
</Stack>
)}
</Stack>
</Stack>
{showStartupLogs && (
<AutoSizer disableHeight>
{({ width }) => (
@@ -1,13 +1,25 @@
import { action } from "@storybook/addon-actions"
import { Story } from "@storybook/react"
import { WatchAgentMetadataContext } from "components/Resources/AgentMetadata"
import { ProvisionerJobLog } from "api/typesGenerated"
import * as Mocks from "../../testHelpers/entities"
import { Workspace, WorkspaceErrors, WorkspaceProps } from "./Workspace"
import { withReactContext } from "storybook-react-context"
import EventSource from "eventsourcemock"
export default {
title: "components/Workspace",
component: Workspace,
argTypes: {},
decorators: [
withReactContext({
Context: WatchAgentMetadataContext,
initialState: (_: string): EventSource => {
// Need Bruno's help here.
return new EventSource()
},
}),
],
}
const Template: Story<WorkspaceProps> = (args) => <Workspace {...args} />