feat: add startup script logs to the ui (#6558)

* Add startup script logs to the database

* Add coderd endpoints for startup script logs

* Push startup script logs from agent

* Pull startup script logs on frontend

* Rename queries

* Add constraint

* Start creating log sending loop

* Add log sending to the agent

* Add tests for streaming logs

* Shorten notify channel name

* Add FE

* Improve bulk log performance

* Finish UI display

* Fix startup log visibility

* Add warning for overflow

* Fix agent queue logs overflow

* Display staartup logs in a virtual DOM for performance

* Fix agent queue with loads of logs

* Fix authorize test

* Remove faulty test

* Fix startup and shutdown reporting error

* Fix gen

* Fix comments

* Periodically purge old database entries

* Add test fixture for migration

* Add Storybook

* Check if there are logs when displaying features

* Fix startup component overflow gap

* Fix startup log wrapping

---------

Co-authored-by: Asher <ash@coder.com>
This commit is contained in:
Kyle Carberry
2023-03-23 14:09:13 -05:00
committed by GitHub
co-authored by Asher
parent a6fa8cac58
commit cb7375450b
57 changed files with 2513 additions and 353 deletions
+9
View File
@@ -694,6 +694,15 @@ export const getWorkspaceBuildLogs = async (
return response.data
}
export const getWorkspaceAgentStartupLogs = async (
agentID: string,
): Promise<TypesGen.WorkspaceAgentStartupLog[]> => {
const response = await axios.get<TypesGen.WorkspaceAgentStartupLog[]>(
`/api/v2/workspaceagents/${agentID}/startup-logs`,
)
return response.data
}
export const putWorkspaceExtension = async (
workspaceId: string,
newDeadline: dayjs.Dayjs,
+9
View File
@@ -1042,6 +1042,8 @@ export interface WorkspaceAgent {
readonly environment_variables: Record<string, string>
readonly operating_system: string
readonly startup_script?: string
readonly startup_logs_length: number
readonly startup_logs_overflowed: boolean
readonly directory?: string
readonly expanded_directory?: string
readonly version: string
@@ -1067,6 +1069,13 @@ export interface WorkspaceAgentListeningPortsResponse {
readonly ports: WorkspaceAgentListeningPort[]
}
// From codersdk/workspaceagents.go
export interface WorkspaceAgentStartupLog {
readonly id: number
readonly created_at: string
readonly output: string
}
// From codersdk/workspaceapps.go
export interface WorkspaceApp {
readonly id: string
@@ -1,4 +1,5 @@
import { ComponentMeta, Story } from "@storybook/react"
import { LogLevel } from "api/typesGenerated"
import { MockWorkspaceBuildLogs } from "../../testHelpers/entities"
import { Logs, LogsProps } from "./Logs"
@@ -12,8 +13,15 @@ const Template: Story<LogsProps> = (args) => <Logs {...args} />
const lines = MockWorkspaceBuildLogs.map((log) => ({
time: log.created_at,
output: log.output,
level: "info" as LogLevel,
}))
export const Example = Template.bind({})
Example.args = {
lines,
}
export const WithLineNumbers = Template.bind({})
WithLineNumbers.args = {
lines,
lineNumbers: true,
}
+50 -10
View File
@@ -1,11 +1,11 @@
import { makeStyles } from "@material-ui/core/styles"
import { makeStyles, Theme } from "@material-ui/core/styles"
import { LogLevel } from "api/typesGenerated"
import dayjs from "dayjs"
import { FC } from "react"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
import { combineClasses } from "../../util/combineClasses"
interface Line {
export interface Line {
time: string
output: string
level: LogLevel
@@ -14,15 +14,19 @@ interface Line {
export interface LogsProps {
lines: Line[]
hideTimestamps?: boolean
lineNumbers?: boolean
className?: string
}
export const Logs: FC<React.PropsWithChildren<LogsProps>> = ({
hideTimestamps,
lines,
lineNumbers,
className = "",
}) => {
const styles = useStyles()
const styles = useStyles({
lineNumbers: Boolean(lineNumbers),
})
return (
<div className={combineClasses([className, styles.root])}>
@@ -32,7 +36,9 @@ export const Logs: FC<React.PropsWithChildren<LogsProps>> = ({
{!hideTimestamps && (
<>
<span className={styles.time}>
{dayjs(line.time).format(`HH:mm:ss.SSS`)}
{lineNumbers
? idx + 1
: dayjs(line.time).format(`HH:mm:ss.SSS`)}
</span>
<span className={styles.space}>&nbsp;&nbsp;&nbsp;&nbsp;</span>
</>
@@ -45,22 +51,55 @@ export const Logs: FC<React.PropsWithChildren<LogsProps>> = ({
)
}
const useStyles = makeStyles((theme) => ({
export const logLineHeight = 20
export const LogLine: FC<{
line: Line
hideTimestamp?: boolean
number?: number
style?: React.CSSProperties
}> = ({ line, hideTimestamp, number, style }) => {
const styles = useStyles({
lineNumbers: Boolean(number),
})
return (
<div className={combineClasses([styles.line, line.level])} style={style}>
{!hideTimestamp && (
<>
<span className={styles.time}>
{number ? number : dayjs(line.time).format(`HH:mm:ss.SSS`)}
</span>
<span className={styles.space}>&nbsp;&nbsp;&nbsp;&nbsp;</span>
</>
)}
<span>{line.output}</span>
</div>
)
}
const useStyles = makeStyles<
Theme,
{
lineNumbers: boolean
}
>((theme) => ({
root: {
minHeight: 156,
background: theme.palette.background.default,
color: theme.palette.text.primary,
fontFamily: MONOSPACE_FONT_FAMILY,
fontSize: 13,
wordBreak: "break-all",
padding: theme.spacing(2, 0),
borderRadius: theme.shape.borderRadius,
overflowX: "auto",
background: theme.palette.background.default,
},
scrollWrapper: {
width: "fit-content",
},
line: {
wordBreak: "break-all",
color: theme.palette.text.primary,
fontFamily: MONOSPACE_FONT_FAMILY,
height: ({ lineNumbers }) => (lineNumbers ? logLineHeight : "auto"),
// Whitespace is significant in terminal output for alignment
whiteSpace: "pre",
padding: theme.spacing(0, 3),
@@ -78,7 +117,8 @@ const useStyles = makeStyles((theme) => ({
},
time: {
userSelect: "none",
width: theme.spacing(12.5),
width: ({ lineNumbers }) => theme.spacing(lineNumbers ? 3.5 : 12.5),
whiteSpace: "pre",
display: "inline-block",
color: theme.palette.text.secondary,
},
@@ -5,6 +5,7 @@ import {
MockWorkspaceAgentConnecting,
MockWorkspaceAgentOff,
MockWorkspaceAgentOutdated,
MockWorkspaceAgentReady,
MockWorkspaceAgentShutdownError,
MockWorkspaceAgentShutdownTimeout,
MockWorkspaceAgentShuttingDown,
@@ -100,6 +101,41 @@ Starting.args = {
workspace: MockWorkspace,
applicationsHost: "",
showApps: true,
storybookStartupLogs: [
"Cloning Git repository...",
"Starting Docker Daemon...",
"Adding some 🧙magic🧙...",
"Starting VS Code...",
].map((line, index) => ({
id: index,
level: "info",
output: line,
time: "",
})),
}
export const Started = Template.bind({})
Started.args = {
agent: {
...MockWorkspaceAgentReady,
startup_logs_length: 1,
},
workspace: MockWorkspace,
applicationsHost: "",
showApps: true,
storybookStartupLogs: [
"Cloning Git repository...",
"Starting Docker Daemon...",
"Adding some 🧙magic🧙...",
"Starting VS Code...",
].map((line, index) => ({
id: index,
level: "info",
output: line,
time: "",
})),
}
export const StartTimeout = Template.bind({})
+356 -96
View File
@@ -1,19 +1,42 @@
import { makeStyles } from "@material-ui/core/styles"
import Link from "@material-ui/core/Link"
import Popover from "@material-ui/core/Popover"
import { makeStyles, useTheme } from "@material-ui/core/styles"
import PlayCircleOutlined from "@material-ui/icons/PlayCircleFilledOutlined"
import VisibilityOffOutlined from "@material-ui/icons/VisibilityOffOutlined"
import VisibilityOutlined from "@material-ui/icons/VisibilityOutlined"
import { Skeleton } from "@material-ui/lab"
import { useMachine } from "@xstate/react"
import { AppLinkSkeleton } from "components/AppLink/AppLinkSkeleton"
import { Maybe } from "components/Conditionals/Maybe"
import { LogLine, logLineHeight } from "components/Logs/Logs"
import { PortForwardButton } from "components/PortForwardButton/PortForwardButton"
import { FC } from "react"
import { VSCodeDesktopButton } from "components/VSCodeDesktopButton/VSCodeDesktopButton"
import {
FC,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react"
import { useTranslation } from "react-i18next"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { darcula } from "react-syntax-highlighter/dist/cjs/styles/prism"
import AutoSizer from "react-virtualized-auto-sizer"
import { FixedSizeList as List, ListOnScrollProps } from "react-window"
import {
LineWithID,
workspaceAgentLogsMachine,
} from "xServices/workspaceAgentLogs/workspaceAgentLogsXService"
import { Workspace, WorkspaceAgent } from "../../api/typesGenerated"
import { AppLink } from "../AppLink/AppLink"
import { SSHButton } from "../SSHButton/SSHButton"
import { Stack } from "../Stack/Stack"
import { TerminalLink } from "../TerminalLink/TerminalLink"
import { AgentLatency } from "./AgentLatency"
import { AgentVersion } from "./AgentVersion"
import { Maybe } from "components/Conditionals/Maybe"
import { AgentStatus } from "./AgentStatus"
import { AppLinkSkeleton } from "components/AppLink/AppLinkSkeleton"
import { useTranslation } from "react-i18next"
import { VSCodeDesktopButton } from "components/VSCodeDesktopButton/VSCodeDesktopButton"
import { AgentVersion } from "./AgentVersion"
export interface AgentRowProps {
agent: WorkspaceAgent
@@ -24,6 +47,8 @@ export interface AgentRowProps {
hideVSCodeDesktopButton?: boolean
serverVersion: string
onUpdateAgent: () => void
storybookStartupLogs?: LineWithID[]
}
export const AgentRow: FC<AgentRowProps> = ({
@@ -35,126 +60,355 @@ export const AgentRow: FC<AgentRowProps> = ({
hideVSCodeDesktopButton,
serverVersion,
onUpdateAgent,
storybookStartupLogs,
}) => {
const styles = useStyles()
const { t } = useTranslation("agent")
const [logsMachine, sendLogsEvent] = useMachine(workspaceAgentLogsMachine, {
context: { agentID: agent.id },
services: process.env.STORYBOOK
? {
getStartupLogs: async () => {
return storybookStartupLogs || []
},
streamStartupLogs: () => async () => {
// noop
},
}
: undefined,
})
const theme = useTheme()
const startupScriptAnchorRef = useRef<HTMLLinkElement>(null)
const [startupScriptOpen, setStartupScriptOpen] = useState(false)
const hasStartupFeatures =
Boolean(agent.startup_logs_length) ||
Boolean(logsMachine.context.startupLogs?.length)
const [showStartupLogs, setShowStartupLogs] = useState(
agent.lifecycle_state !== "ready" && hasStartupFeatures,
)
useEffect(() => {
setShowStartupLogs(agent.lifecycle_state !== "ready" && hasStartupFeatures)
}, [agent.lifecycle_state, hasStartupFeatures])
// External applications can provide startup logs for an agent during it's spawn.
// These could be Kubernetes logs, or other logs that are useful to the user.
// For this reason, we want to fetch these logs when the agent is starting.
useEffect(() => {
if (agent.lifecycle_state === "starting") {
sendLogsEvent("FETCH_STARTUP_LOGS")
}
}, [sendLogsEvent, agent.lifecycle_state])
useEffect(() => {
// We only want to fetch logs when they are actually shown,
// otherwise we can make a lot of requests that aren't necessary.
if (showStartupLogs) {
sendLogsEvent("FETCH_STARTUP_LOGS")
}
}, [sendLogsEvent, showStartupLogs])
const logListRef = useRef<List>(null)
const logListDivRef = useRef<HTMLDivElement>(null)
const startupLogs = useMemo(() => {
const allLogs = logsMachine.context.startupLogs || []
const logs = [...allLogs]
if (agent.startup_logs_overflowed) {
logs.push({
id: -1,
level: "error",
output: "Startup logs exceeded the max size of 1MB!",
time: new Date().toISOString(),
})
}
return logs
}, [logsMachine.context.startupLogs, agent.startup_logs_overflowed])
const [bottomOfLogs, setBottomOfLogs] = useState(true)
// This is a layout effect to remove flicker when we're scrolling to the bottom.
useLayoutEffect(() => {
// If we're currently watching the bottom, we always want to stay at the bottom.
if (bottomOfLogs && logListRef.current) {
logListRef.current.scrollToItem(startupLogs.length - 1, "end")
}
}, [showStartupLogs, startupLogs, logListRef, bottomOfLogs])
// This is a bit of a hack on the react-window API to get the scroll position.
// If we're scrolled to the bottom, we want to keep the list scrolled to the bottom.
// This makes it feel similar to a terminal that auto-scrolls downwards!
const handleLogScroll = useCallback(
(props: ListOnScrollProps) => {
if (
props.scrollOffset === 0 ||
props.scrollUpdateWasRequested ||
!logListDivRef.current
) {
return
}
// The parent holds the height of the list!
const parent = logListDivRef.current.parentElement
if (!parent) {
return
}
const distanceFromBottom =
logListDivRef.current.scrollHeight -
(props.scrollOffset + parent.clientHeight)
setBottomOfLogs(distanceFromBottom < logLineHeight)
},
[logListDivRef],
)
return (
<Stack
direction="column"
key={agent.id}
direction="row"
alignItems="center"
justifyContent="space-between"
className={styles.agentRow}
spacing={4}
spacing={0}
className={styles.agentWrapper}
>
<Stack direction="row" alignItems="baseline">
<div className={styles.agentStatusWrapper}>
<AgentStatus agent={agent} />
</div>
<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"
justifyContent="space-between"
className={styles.agentRow}
spacing={4}
>
{showApps && agent.status === "connected" && (
<>
{agent.apps.map((app) => (
<AppLink
key={app.slug}
appsHost={applicationsHost}
app={app}
agent={agent}
workspace={workspace}
/>
))}
<Stack direction="row" alignItems="baseline">
<div className={styles.agentStatusWrapper}>
<AgentStatus agent={agent} />
</div>
<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>
<TerminalLink
workspaceName={workspace.name}
agentName={agent.name}
userName={workspace.owner_name}
/>
{!hideSSHButton && (
<SSHButton
<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={() => {
setStartupScriptOpen(!startupScriptOpen)
}}
>
<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}
/>
)}
{!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} />
</>
)}
{!hideSSHButton && (
<SSHButton
workspaceName={workspace.name}
agentName={agent.name}
/>
)}
{!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>
{showStartupLogs && (
<AutoSizer disableHeight>
{({ width }) => (
<List
ref={logListRef}
innerRef={logListDivRef}
height={256}
itemCount={startupLogs.length}
itemSize={logLineHeight}
width={width}
className={styles.startupLogs}
onScroll={handleLogScroll}
>
{({ index, style }) => (
<LogLine
line={startupLogs[index]}
number={index + 1}
style={style}
/>
)}
</List>
)}
</AutoSizer>
)}
</Stack>
)
}
const useStyles = makeStyles((theme) => ({
agentWrapper: {
"&:not(:last-child)": {
borderBottom: `1px solid ${theme.palette.divider}`,
},
},
agentRow: {
padding: theme.spacing(3, 4),
backgroundColor: theme.palette.background.paperLight,
fontSize: 16,
},
"&:not(:last-child)": {
borderBottom: `1px solid ${theme.palette.divider}`,
startupLinks: {
display: "flex",
alignItems: "center",
gap: theme.spacing(2),
marginTop: theme.spacing(0.5),
},
startupLink: {
cursor: "pointer",
display: "flex",
gap: 4,
alignItems: "center",
userSelect: "none",
whiteSpace: "nowrap",
"& svg": {
width: 12,
height: 12,
},
},
startupLogs: {
maxHeight: 256,
background: theme.palette.background.default,
},
startupScriptPopover: {
backgroundColor: theme.palette.background.default,
},
agentStatusWrapper: {
width: theme.spacing(4.5),
display: "flex",
@@ -174,4 +428,10 @@ const useStyles = makeStyles((theme) => ({
color: theme.palette.text.secondary,
marginTop: theme.spacing(0.5),
},
agentStartupLogs: {
maxHeight: 200,
display: "flex",
flexDirection: "column-reverse",
},
}))
+10 -7
View File
@@ -1,8 +1,15 @@
import { makeStyles } from "@material-ui/core/styles"
import { Avatar } from "components/Avatar/Avatar"
import { AgentRow } from "components/Resources/AgentRow"
import {
ActiveTransition,
WorkspaceBuildProgress,
} from "components/WorkspaceBuildProgress/WorkspaceBuildProgress"
import { WorkspaceStatusBadge } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge"
import { FC } from "react"
import { useNavigate } from "react-router-dom"
import * as TypesGen from "../../api/typesGenerated"
import { AlertBanner } from "../AlertBanner/AlertBanner"
import { BuildsTable } from "../BuildsTable/BuildsTable"
import { Margins } from "../Margins/Margins"
import {
@@ -16,13 +23,6 @@ import { WorkspaceActions } from "../WorkspaceActions/WorkspaceActions"
import { WorkspaceDeletedBanner } from "../WorkspaceDeletedBanner/WorkspaceDeletedBanner"
import { WorkspaceScheduleButton } from "../WorkspaceScheduleButton/WorkspaceScheduleButton"
import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats"
import { AlertBanner } from "../AlertBanner/AlertBanner"
import {
ActiveTransition,
WorkspaceBuildProgress,
} from "components/WorkspaceBuildProgress/WorkspaceBuildProgress"
import { AgentRow } from "components/Resources/AgentRow"
import { Avatar } from "components/Avatar/Avatar"
export enum WorkspaceErrors {
GET_BUILDS_ERROR = "getBuildsError",
@@ -249,5 +249,8 @@ export const useStyles = makeStyles((theme) => {
timelineContents: {
margin: 0,
},
logs: {
border: `1px solid ${theme.palette.divider}`,
},
}
})
@@ -2,13 +2,13 @@ import { makeStyles } from "@material-ui/core/styles"
import { useMachine } from "@xstate/react"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
import { Loader } from "components/Loader/Loader"
import { FC, useEffect } from "react"
import { useParams } from "react-router-dom"
import { Loader } from "components/Loader/Loader"
import { firstOrItem } from "util/array"
import { quotaMachine } from "xServices/quotas/quotasXService"
import { workspaceMachine } from "xServices/workspace/workspaceXService"
import { WorkspaceReadyPage } from "./WorkspaceReadyPage"
import { quotaMachine } from "xServices/quotas/quotasXService"
export const WorkspacePage: FC = () => {
const { username: usernameQueryParam, workspace: workspaceQueryParam } =
+10
View File
@@ -405,6 +405,8 @@ export const MockWorkspaceAgent: TypesGen.WorkspaceAgent = {
troubleshooting_url: "https://coder.com/troubleshoot",
lifecycle_state: "starting",
login_before_ready: false,
startup_logs_length: 0,
startup_logs_overflowed: false,
startup_script_timeout_seconds: 120,
shutdown_script_timeout_seconds: 120,
}
@@ -470,6 +472,13 @@ export const MockWorkspaceAgentStarting: TypesGen.WorkspaceAgent = {
lifecycle_state: "starting",
}
export const MockWorkspaceAgentReady: TypesGen.WorkspaceAgent = {
...MockWorkspaceAgent,
id: "test-workspace-agent-ready",
name: "a-ready-workspace-agent",
lifecycle_state: "ready",
}
export const MockWorkspaceAgentStartTimeout: TypesGen.WorkspaceAgent = {
...MockWorkspaceAgent,
id: "test-workspace-agent-start-timeout",
@@ -655,6 +664,7 @@ export const MockWorkspace: TypesGen.Workspace = {
MockTemplate.allow_user_cancel_workspace_jobs,
outdated: false,
owner_id: MockUser.id,
organization_id: MockOrganization.id,
owner_name: MockUser.username,
autostart_schedule: MockWorkspaceAutostartEnabled.schedule,
ttl_ms: 2 * 60 * 60 * 1000, // 2 hours as milliseconds
@@ -699,6 +699,7 @@ export const workspaceMachine = createMachine(
checkRefresh: true,
data: JSON.parse(event.data),
})
// refresh
})
// handle any error events returned by our sse
@@ -0,0 +1,127 @@
import * as API from "api/api"
import { createMachine, assign } from "xstate"
import * as TypesGen from "api/typesGenerated"
import { Line } from "components/Logs/Logs"
// Logs are stored as the Line interface to make rendering
// much more efficient. Instead of mapping objects each time, we're
// able to just pass the array of logs to the component.
export interface LineWithID extends Line {
id: number
}
export const workspaceAgentLogsMachine = createMachine(
{
predictableActionArguments: true,
id: "workspaceAgentLogsMachine",
schema: {
events: {} as
| {
type: "ADD_STARTUP_LOGS"
logs: LineWithID[]
}
| {
type: "FETCH_STARTUP_LOGS"
},
context: {} as {
agentID: string
startupLogs?: LineWithID[]
},
services: {} as {
getStartupLogs: {
data: LineWithID[]
}
},
},
tsTypes: {} as import("./workspaceAgentLogsXService.typegen").Typegen0,
initial: "waiting",
states: {
waiting: {
on: {
FETCH_STARTUP_LOGS: "loading",
},
},
loading: {
invoke: {
src: "getStartupLogs",
onDone: {
target: "watchStartupLogs",
actions: ["assignStartupLogs"],
},
},
},
watchStartupLogs: {
id: "watchingStartupLogs",
invoke: {
id: "streamStartupLogs",
src: "streamStartupLogs",
},
},
loaded: {
type: "final",
},
},
on: {
ADD_STARTUP_LOGS: {
actions: "addStartupLogs",
},
},
},
{
services: {
getStartupLogs: (ctx) =>
API.getWorkspaceAgentStartupLogs(ctx.agentID).then((data) =>
data.map((log) => ({
id: log.id,
level: "info" as TypesGen.LogLevel,
output: log.output,
time: log.created_at,
})),
),
streamStartupLogs: (ctx) => async (callback) => {
return new Promise<void>((resolve, reject) => {
const proto = location.protocol === "https:" ? "wss:" : "ws:"
let after = 0
if (ctx.startupLogs && ctx.startupLogs.length > 0) {
after = ctx.startupLogs[ctx.startupLogs.length - 1].id
}
const socket = new WebSocket(
`${proto}//${location.host}/api/v2/workspaceagents/${ctx.agentID}/startup-logs?follow&after=${after}`,
)
socket.binaryType = "blob"
socket.addEventListener("message", (event) => {
const logs = JSON.parse(
event.data,
) as TypesGen.WorkspaceAgentStartupLog[]
callback({
type: "ADD_STARTUP_LOGS",
logs: logs.map((log) => ({
id: log.id,
level: "info" as TypesGen.LogLevel,
output: log.output,
time: log.created_at,
})),
})
})
socket.addEventListener("error", () => {
reject(new Error("socket errored"))
})
socket.addEventListener("open", () => {
resolve()
})
})
},
},
actions: {
assignStartupLogs: assign({
startupLogs: (_, { data }) => data,
}),
addStartupLogs: assign({
startupLogs: (context, event) => {
const previousLogs = context.startupLogs ?? []
return [...previousLogs, ...event.logs]
},
}),
},
},
)