mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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}> </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}> </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({})
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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]
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user