From 88e8c96ddd9c780a2cc51140be2a554a06fc2259 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Fri, 3 Jun 2022 09:23:45 -0500 Subject: [PATCH] feature: Load workspace build logs from streaming (#1997) --- .vscode/settings.json | 1 + site/can-ndjson-stream.d.ts | 4 + site/package.json | 1 + site/src/api/api.ts | 15 ++++ .../WorkspaceBuildLogs.stories.tsx | 6 ++ .../WorkspaceBuildLogs/WorkspaceBuildLogs.tsx | 25 +++++- .../WorkspaceBuildPage.test.tsx | 12 +++ .../WorkspaceBuildPage/WorkspaceBuildPage.tsx | 3 +- .../workspaceBuild/workspaceBuildXService.ts | 76 ++++++++++++++----- site/tsconfig.test.json | 2 +- site/yarn.lock | 12 +++ 11 files changed, 133 insertions(+), 24 deletions(-) create mode 100644 site/can-ndjson-stream.d.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index e14c039bb7..1ba61d11c0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -72,6 +72,7 @@ "VMID", "weblinks", "webrtc", + "workspacebuilds", "xerrors", "xstate", "yamux" diff --git a/site/can-ndjson-stream.d.ts b/site/can-ndjson-stream.d.ts new file mode 100644 index 0000000000..5859213801 --- /dev/null +++ b/site/can-ndjson-stream.d.ts @@ -0,0 +1,4 @@ +declare module "can-ndjson-stream" { + function ndjsonStream(body: ReadableStream | null): Promise> + export default ndjsonStream +} diff --git a/site/package.json b/site/package.json index 8e9fdfe5c5..20f7adaf0b 100644 --- a/site/package.json +++ b/site/package.json @@ -35,6 +35,7 @@ "@xstate/inspect": "0.6.5", "@xstate/react": "3.0.0", "axios": "0.26.1", + "can-ndjson-stream": "1.0.2", "cronstrue": "2.5.0", "dayjs": "1.11.2", "formik": "2.2.9", diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 4e06ccc530..54b4e2d566 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1,4 +1,5 @@ import axios, { AxiosRequestHeaders } from "axios" +import ndjsonStream from "can-ndjson-stream" import * as Types from "./types" import { WorkspaceBuildTransition } from "./types" import * as TypesGen from "./typesGenerated" @@ -271,6 +272,20 @@ export const getWorkspaceBuildLogs = async (buildname: string): Promise> => { + // Axios does not support HTTP stream in the browser + // https://github.com/axios/axios/issues/1474 + // So we are going to use window.fetch and return a "stream" reader + const reader = await window + .fetch(`/api/v2/workspacebuilds/${buildname}/logs?follow=true`) + .then((res) => ndjsonStream(res.body)) + .then((stream) => stream.getReader()) + + return reader +} + export const putWorkspaceExtension = async ( workspaceId: string, extendWorkspaceRequest: TypesGen.PutExtendWorkspaceRequest, diff --git a/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.stories.tsx b/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.stories.tsx index 71886186d2..05d5bcfe8d 100644 --- a/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.stories.tsx +++ b/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.stories.tsx @@ -13,3 +13,9 @@ export const Example = Template.bind({}) Example.args = { logs: MockWorkspaceBuildLogs, } + +export const Loading = Template.bind({}) +Loading.args = { + logs: MockWorkspaceBuildLogs, + isWaitingForLogs: true, +} diff --git a/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx b/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx index 3d2a0bff8b..b5e438c4b9 100644 --- a/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx +++ b/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx @@ -1,3 +1,4 @@ +import CircularProgress from "@material-ui/core/CircularProgress" import { makeStyles } from "@material-ui/core/styles" import dayjs from "dayjs" import { FC } from "react" @@ -5,6 +6,10 @@ import { ProvisionerJobLog } from "../../api/typesGenerated" import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" import { Logs } from "../Logs/Logs" +const Language = { + seconds: "seconds", +} + type Stage = ProvisionerJobLog["stage"] const groupLogsByStage = (logs: ProvisionerJobLog[]) => { @@ -35,16 +40,17 @@ const getStageDurationInSeconds = (logs: ProvisionerJobLog[]) => { export interface WorkspaceBuildLogsProps { logs: ProvisionerJobLog[] + isWaitingForLogs: boolean } -export const WorkspaceBuildLogs: FC = ({ logs }) => { +export const WorkspaceBuildLogs: FC = ({ logs, isWaitingForLogs }) => { const groupedLogsByStage = groupLogsByStage(logs) const stages = Object.keys(groupedLogsByStage) const styles = useStyles() return (
- {stages.map((stage) => { + {stages.map((stage, stageIndex) => { const logs = groupedLogsByStage[stage] const isEmpty = logs.every((log) => log.output === "") const lines = logs.map((log) => ({ @@ -52,12 +58,20 @@ export const WorkspaceBuildLogs: FC = ({ logs }) => { output: log.output, })) const duration = getStageDurationInSeconds(logs) + const isLastStage = stageIndex === stages.length - 1 + const shouldDisplaySpinner = isWaitingForLogs && isLastStage + const shouldDisplayDuration = !isWaitingForLogs && duration return (
{stage}
- {duration &&
{duration} seconds
} + {shouldDisplaySpinner && } + {shouldDisplayDuration && ( +
+ {duration} {Language.seconds} +
+ )}
{!isEmpty && }
@@ -78,6 +92,7 @@ const useStyles = makeStyles((theme) => ({ fontSize: theme.typography.body1.fontSize, padding: theme.spacing(2), paddingLeft: theme.spacing(4), + paddingRight: theme.spacing(4), borderBottom: `1px solid ${theme.palette.divider}`, backgroundColor: theme.palette.background.paper, display: "flex", @@ -94,4 +109,8 @@ const useStyles = makeStyles((theme) => ({ padding: theme.spacing(2), paddingLeft: theme.spacing(4), }, + + spinner: { + marginLeft: "auto", + }, })) diff --git a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.test.tsx b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.test.tsx index b4f3e3f29e..cebff84da7 100644 --- a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.test.tsx +++ b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.test.tsx @@ -1,9 +1,21 @@ import { screen } from "@testing-library/react" +import * as API from "../../api/api" import { MockWorkspaceBuild, MockWorkspaceBuildLogs, renderWithAuth } from "../../testHelpers/renderHelpers" import { WorkspaceBuildPage } from "./WorkspaceBuildPage" describe("WorkspaceBuildPage", () => { it("renders the stats and logs", async () => { + jest.spyOn(API, "streamWorkspaceBuildLogs").mockResolvedValueOnce({ + read() { + return Promise.resolve({ + value: undefined, + done: true, + }) + }, + releaseLock: jest.fn(), + closed: Promise.resolve(undefined), + cancel: jest.fn(), + }) renderWithAuth(, { route: `/builds/${MockWorkspaceBuild.id}`, path: "/builds/:buildId" }) await screen.findByText(MockWorkspaceBuild.workspace_name) diff --git a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.tsx b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.tsx index 458cdf5f2b..2f09b302db 100644 --- a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.tsx +++ b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.tsx @@ -29,6 +29,7 @@ export const WorkspaceBuildPage: FC = () => { const buildId = useBuildId() const [buildState] = useMachine(workspaceBuildMachine, { context: { buildId } }) const { logs, build } = buildState.context + const isWaitingForLogs = !buildState.matches("logs.loaded") const styles = useStyles() return ( @@ -40,7 +41,7 @@ export const WorkspaceBuildPage: FC = () => { {build && } {!logs && } - {logs && } + {logs && } ) diff --git a/site/src/xServices/workspaceBuild/workspaceBuildXService.ts b/site/src/xServices/workspaceBuild/workspaceBuildXService.ts index b6cfa8bf3e..88008892fe 100644 --- a/site/src/xServices/workspaceBuild/workspaceBuildXService.ts +++ b/site/src/xServices/workspaceBuild/workspaceBuildXService.ts @@ -9,19 +9,28 @@ type LogsContext = { getBuildError?: Error | unknown // Logs logs?: ProvisionerJobLog[] - getBuildLogsError?: Error | unknown } +type LogsEvent = + | { + type: "ADD_LOG" + log: ProvisionerJobLog + } + | { + type: "NO_MORE_LOGS" + } + export const workspaceBuildMachine = createMachine( { id: "workspaceBuildState", schema: { context: {} as LogsContext, + events: {} as LogsEvent, services: {} as { getWorkspaceBuild: { data: WorkspaceBuild } - getWorkspaceBuildLogs: { + getLogs: { data: ProvisionerJobLog[] } }, @@ -50,23 +59,36 @@ export const workspaceBuildMachine = createMachine( }, }, logs: { - initial: "gettingLogs", + initial: "gettingExistentLogs", states: { - gettingLogs: { - entry: "clearGetBuildLogsError", + gettingExistentLogs: { invoke: { - src: "getWorkspaceBuildLogs", + id: "getLogs", + src: "getLogs", onDone: { - target: "idle", - actions: "assignLogs", - }, - onError: { - target: "idle", - actions: "assignGetBuildLogsError", + actions: ["assignLogs"], + target: "watchingLogs", }, }, }, - idle: {}, + watchingLogs: { + id: "watchingLogs", + invoke: { + id: "streamWorkspaceBuildLogs", + src: "streamWorkspaceBuildLogs", + }, + }, + loaded: { + type: "final", + }, + }, + on: { + ADD_LOG: { + actions: "addLog", + }, + NO_MORE_LOGS: { + target: "logs.loaded", + }, }, }, }, @@ -87,16 +109,32 @@ export const workspaceBuildMachine = createMachine( assignLogs: assign({ logs: (_, event) => event.data, }), - assignGetBuildLogsError: assign({ - getBuildLogsError: (_, event) => event.data, - }), - clearGetBuildLogsError: assign({ - getBuildLogsError: (_) => undefined, + addLog: assign({ + logs: (context, event) => { + const previousLogs = context.logs ?? [] + return [...previousLogs, event.log] + }, }), }, services: { getWorkspaceBuild: (ctx) => API.getWorkspaceBuild(ctx.buildId), - getWorkspaceBuildLogs: (ctx) => API.getWorkspaceBuildLogs(ctx.buildId), + getLogs: async (ctx) => API.getWorkspaceBuildLogs(ctx.buildId), + streamWorkspaceBuildLogs: (ctx) => async (callback) => { + const reader = await API.streamWorkspaceBuildLogs(ctx.buildId) + + // Watching for the stream + // eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition + while (true) { + const { value, done } = await reader.read() + + if (done) { + callback("NO_MORE_LOGS") + break + } + + callback({ type: "ADD_LOG", log: value }) + } + }, }, }, ) diff --git a/site/tsconfig.test.json b/site/tsconfig.test.json index 416150b4d4..4e497e63e8 100644 --- a/site/tsconfig.test.json +++ b/site/tsconfig.test.json @@ -1,5 +1,5 @@ { "extends": "./tsconfig.json", "exclude": ["node_modules", "_jest"], - "include": ["**/*.stories.tsx", "**/*.test.tsx"] + "include": ["**/*.stories.tsx", "**/*.test.tsx", "**/*.d.ts"] } diff --git a/site/yarn.lock b/site/yarn.lock index abc20e7ca7..d1b29591f7 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -4731,6 +4731,18 @@ camelcase@^6.2.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== +can-namespace@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/can-namespace/-/can-namespace-1.0.0.tgz#0b8fafafbb11352b9ead4222ffe3822405b43e99" + integrity sha512-1sBY/SLwwcmxz3NhyVhLjt2uD/dZ7V1mII82/MIXSDn5QXnslnosJnjlP8+yTx2uTCRvw1jlFDElRs4pX7AG5w== + +can-ndjson-stream@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/can-ndjson-stream/-/can-ndjson-stream-1.0.2.tgz#6a8131f9c8c697215163b3fe49a0c02e4439cb47" + integrity sha512-//tM8wcTV42SyD1JGua7WMVftZEeTwapcHJTTe3vJwuVywXD01CJbdEkgwRYjy2evIByVJV21ZKBdSv5ygIw1w== + dependencies: + can-namespace "^1.0.0" + caniuse-api@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0"