diff --git a/site/src/api/api.ts b/site/src/api/api.ts index ef50daad9508a..098191f96d580 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1392,7 +1392,7 @@ export const watchBuildLogsByTemplateVersionId = ( type WatchWorkspaceAgentLogsOptions = { after: number; onMessage: (logs: TypesGen.WorkspaceAgentLog[]) => void; - onDone: () => void; + onDone?: () => void; onError: (error: Error) => void; }; @@ -1423,7 +1423,7 @@ export const watchWorkspaceAgentLogs = ( onError(new Error("socket errored")); }); socket.addEventListener("close", () => { - onDone(); + onDone && onDone(); }); return socket; diff --git a/site/src/components/Resources/AgentRow.stories.tsx b/site/src/components/Resources/AgentRow.stories.tsx index 08a49a2fc0141..5c6e4e00345b4 100644 --- a/site/src/components/Resources/AgentRow.stories.tsx +++ b/site/src/components/Resources/AgentRow.stories.tsx @@ -17,7 +17,7 @@ import { MockWorkspaceApp, MockProxyLatencies, } from "testHelpers/entities"; -import { AgentRow } from "./AgentRow"; +import { AgentRow, LineWithID } from "./AgentRow"; import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"; import type { Meta, StoryObj } from "@storybook/react"; @@ -84,24 +84,27 @@ const defaultAgentMetadata = [ }, ]; +const storybookLogs: LineWithID[] = [ + "\x1b[91mCloning Git repository...", + "\x1b[2;37;41mStarting Docker Daemon...", + "\x1b[1;95mAdding some 🧙magic🧙...", + "Starting VS Code...", + "\r 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0\r100 1475 0 1475 0 0 4231 0 --:--:-- --:--:-- --:--:-- 4238", +].map((line, index) => ({ + id: index, + level: "info", + output: line, + time: "", +})); + const meta: Meta = { title: "components/AgentRow", component: AgentRow, args: { - storybookLogs: [ - "\x1b[91mCloning Git repository...", - "\x1b[2;37;41mStarting Docker Daemon...", - "\x1b[1;95mAdding some 🧙magic🧙...", - "Starting VS Code...", - "\r 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0\r100 1475 0 1475 0 0 4231 0 --:--:-- --:--:-- --:--:-- 4238", - ].map((line, index) => ({ - id: index, - level: "info", - output: line, - time: "", - })), + storybookLogs, agent: { ...MockWorkspaceAgent, + logs_length: storybookLogs.length, startup_script: 'set -eux -o pipefail\n\n# install and start code-server\ncurl -fsSL https://code-server.dev/install.sh | sh -s -- --method=standalone --prefix=/tmp/code-server --version 4.8.3\n/tmp/code-server/bin/code-server --auth none --port 13337 >/tmp/code-server.log 2>&1 &\n\n\nif [ ! -d ~/coder ]; then\n mkdir -p ~/coder\n\n git clone https://github.com/coder/coder ~/coder\nfi\n\nsudo service docker start\nDOTFILES_URI=" "\nrm -f ~/.personalize.log\nif [ -n "${DOTFILES_URI// }" ]; then\n coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee -a ~/.personalize.log\nfi\nif [ -x ~/personalize ]; then\n ~/personalize 2>&1 | tee -a ~/.personalize.log\nelif [ -f ~/personalize ]; then\n echo "~/personalize is not executable, skipping..." | tee -a ~/.personalize.log\nfi\n', }, diff --git a/site/src/components/Resources/AgentRow.tsx b/site/src/components/Resources/AgentRow.tsx index 7d0d84d0197dc..c9ad3312d69ed 100644 --- a/site/src/components/Resources/AgentRow.tsx +++ b/site/src/components/Resources/AgentRow.tsx @@ -1,13 +1,17 @@ import Popover from "@mui/material/Popover"; import { makeStyles, useTheme } from "@mui/styles"; import Skeleton from "@mui/material/Skeleton"; -import { useMachine } from "@xstate/react"; +import * as API from "api/api"; import CodeOutlined from "@mui/icons-material/CodeOutlined"; import { CloseDropdown, OpenDropdown, } from "components/DropdownArrows/DropdownArrows"; -import { LogLine, logLineHeight } from "components/WorkspaceBuildLogs/Logs"; +import { + Line, + LogLine, + logLineHeight, +} from "components/WorkspaceBuildLogs/Logs"; import { PortForwardButton } from "./PortForwardButton"; import { VSCodeDesktopButton } from "components/Resources/VSCodeDesktopButton/VSCodeDesktopButton"; import { @@ -25,15 +29,11 @@ import AutoSizer from "react-virtualized-auto-sizer"; import { FixedSizeList as List, ListOnScrollProps } from "react-window"; import { colors } from "theme/colors"; import { combineClasses } from "utils/combineClasses"; -import { - LineWithID, - workspaceAgentLogsMachine, -} from "xServices/workspaceAgentLogs/workspaceAgentLogsXService"; import { Workspace, WorkspaceAgent, WorkspaceAgentMetadata, -} from "../../api/typesGenerated"; +} from "api/typesGenerated"; import { AppLink } from "./AppLink/AppLink"; import { SSHButton } from "./SSHButton/SSHButton"; import { Stack } from "../Stack/Stack"; @@ -44,6 +44,14 @@ import { AgentVersion } from "./AgentVersion"; import { AgentStatus } from "./AgentStatus"; import Collapse from "@mui/material/Collapse"; import { useProxy } from "contexts/ProxyContext"; +import { displayError } from "components/GlobalSnackbar/utils"; + +// 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 interface AgentRowProps { agent: WorkspaceAgent; @@ -68,24 +76,11 @@ export const AgentRow: FC = ({ hideVSCodeDesktopButton, serverVersion, onUpdateAgent, - storybookLogs, storybookAgentMetadata, sshPrefix, + storybookLogs, }) => { const styles = useStyles(); - const [logsMachine, sendLogsEvent] = useMachine(workspaceAgentLogsMachine, { - context: { agentID: agent.id }, - services: process.env.STORYBOOK - ? { - getLogs: async () => { - return storybookLogs || []; - }, - streamLogs: () => async () => { - // noop - }, - } - : undefined, - }); const theme = useTheme(); const startupScriptAnchorRef = useRef(null); const [startupScriptOpen, setStartupScriptOpen] = useState(false); @@ -94,36 +89,20 @@ export const AgentRow: FC = ({ showApps && ((agent.status === "connected" && hasAppsToDisplay) || agent.status === "connecting"); - const hasStartupFeatures = - Boolean(agent.logs_length) || Boolean(logsMachine.context.logs?.length); + const hasStartupFeatures = Boolean(agent.logs_length); const { proxy } = useProxy(); - const [showLogs, setShowLogs] = useState( ["starting", "start_timeout"].includes(agent.lifecycle_state) && hasStartupFeatures, ); - useEffect(() => { - setShowLogs(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_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 (showLogs && logsMachine.can("FETCH_LOGS")) { - sendLogsEvent("FETCH_LOGS"); - } - }, [logsMachine, sendLogsEvent, showLogs]); + const agentLogs = useAgentLogs(agent.id, { + enabled: showLogs, + initialData: process.env.STORYBOOK ? storybookLogs || [] : undefined, + }); const logListRef = useRef(null); const logListDivRef = useRef(null); const startupLogs = useMemo(() => { - const allLogs = logsMachine.context.logs || []; + const allLogs = agentLogs || []; const logs = [...allLogs]; if (agent.logs_overflowed) { @@ -135,8 +114,13 @@ export const AgentRow: FC = ({ }); } return logs; - }, [logsMachine.context.logs, agent.logs_overflowed]); + }, [agentLogs, agent.logs_overflowed]); const [bottomOfLogs, setBottomOfLogs] = useState(true); + + useEffect(() => { + setShowLogs(agent.lifecycle_state !== "ready" && hasStartupFeatures); + }, [agent.lifecycle_state, hasStartupFeatures]); + // 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. @@ -396,6 +380,51 @@ export const AgentRow: FC = ({ ); }; +const useAgentLogs = ( + agentId: string, + { enabled, initialData }: { enabled: boolean; initialData?: LineWithID[] }, +) => { + const [logs, setLogs] = useState(initialData); + const socket = useRef(null); + + useEffect(() => { + if (!enabled) { + socket.current?.close(); + return; + } + + socket.current = API.watchWorkspaceAgentLogs(agentId, { + // Get all logs + after: 0, + onMessage: (logs) => { + setLogs((previousLogs) => { + const newLogs: LineWithID[] = logs.map((log) => ({ + id: log.id, + level: log.level || "info", + output: log.output, + time: log.created_at, + })); + + if (!previousLogs) { + return newLogs; + } + + return [...previousLogs, ...newLogs]; + }); + }, + onError: () => { + displayError("Error on getting agent logs"); + }, + }); + + return () => { + socket.current?.close(); + }; + }, [agentId, enabled]); + + return logs; +}; + const useStyles = makeStyles((theme) => ({ agentRow: { backgroundColor: theme.palette.background.paperLight, diff --git a/site/src/components/WorkspaceBuildLogs/Logs.tsx b/site/src/components/WorkspaceBuildLogs/Logs.tsx index 0f45953f2cd8f..ca1830a865571 100644 --- a/site/src/components/WorkspaceBuildLogs/Logs.tsx +++ b/site/src/components/WorkspaceBuildLogs/Logs.tsx @@ -60,12 +60,25 @@ export const LogLine: FC<{ const output = useMemo(() => { return convert.toHtml(line.output.split(/\r/g).pop() as string); }, [line.output]); + const isUsingLineNumber = number !== undefined; return ( -
+
{!hideTimestamp && ( <> - + {number ? number : dayjs(line.time).format(`HH:mm:ss.SSS`)} @@ -119,6 +132,9 @@ const useStyles = makeStyles((theme) => ({ backgroundColor: theme.palette.warning.dark, }, }, + lineNumber: { + paddingLeft: theme.spacing(2), + }, space: { userSelect: "none", width: theme.spacing(3), @@ -132,4 +148,8 @@ const useStyles = makeStyles((theme) => ({ display: "inline-block", color: theme.palette.text.secondary, }, + number: { + width: theme.spacing(4), + textAlign: "right", + }, })); diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 4269e6e10c481..01da4ef08dc81 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -43,7 +43,7 @@ const renderWorkspacePage = async () => { jest .spyOn(api, "watchWorkspaceAgentLogs") .mockImplementation((_, options) => { - options.onDone(); + options.onDone && options.onDone(); return new WebSocket(""); }); renderWithAuth(, { diff --git a/site/src/xServices/workspaceAgentLogs/workspaceAgentLogsXService.ts b/site/src/xServices/workspaceAgentLogs/workspaceAgentLogsXService.ts deleted file mode 100644 index 01e542374bef1..0000000000000 --- a/site/src/xServices/workspaceAgentLogs/workspaceAgentLogsXService.ts +++ /dev/null @@ -1,130 +0,0 @@ -import * as API from "api/api"; -import { createMachine, assign } from "xstate"; -import { Line } from "components/WorkspaceBuildLogs/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_LOGS"; - logs: LineWithID[]; - } - | { - type: "FETCH_LOGS"; - } - | { - type: "DONE"; - }, - context: {} as { - agentID: string; - logs?: LineWithID[]; - }, - services: {} as { - getLogs: { - data: LineWithID[]; - }; - }, - }, - tsTypes: {} as import("./workspaceAgentLogsXService.typegen").Typegen0, - initial: "waiting", - states: { - waiting: { - on: { - FETCH_LOGS: "loading", - }, - }, - loading: { - invoke: { - src: "getLogs", - onDone: { - target: "watchLogs", - actions: ["assignLogs"], - }, - }, - }, - watchLogs: { - id: "watchingLogs", - invoke: { - id: "streamLogs", - src: "streamLogs", - }, - on: { - ADD_LOGS: { - actions: "addLogs", - }, - DONE: { - target: "loaded", - }, - }, - }, - loaded: { - type: "final", - }, - }, - }, - { - services: { - getLogs: (ctx) => - API.getWorkspaceAgentLogs(ctx.agentID).then((data) => - data.map((log) => ({ - id: log.id, - level: log.level || "info", - output: log.output, - time: log.created_at, - })), - ), - streamLogs: (ctx) => async (callback) => { - let after = 0; - if (ctx.logs && ctx.logs.length > 0) { - after = ctx.logs[ctx.logs.length - 1].id; - } - - const socket = API.watchWorkspaceAgentLogs(ctx.agentID, { - after, - onMessage: (logs) => { - callback({ - type: "ADD_LOGS", - logs: logs.map((log) => ({ - id: log.id, - level: log.level || "info", - output: log.output, - time: log.created_at, - })), - }); - }, - onDone: () => { - callback({ type: "DONE" }); - }, - onError: (error) => { - console.error(error); - }, - }); - - return () => { - socket.close(); - }; - }, - }, - actions: { - assignLogs: assign({ - logs: (_, { data }) => data, - }), - addLogs: assign({ - logs: (context, event) => { - const previousLogs = context.logs ?? []; - return [...previousLogs, ...event.logs]; - }, - }), - }, - }, -);