Skip to content

Commit 88e8c96

Browse files
feature: Load workspace build logs from streaming (#1997)
1 parent d6e9eab commit 88e8c96

File tree

11 files changed

+133
-24
lines changed

11 files changed

+133
-24
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"VMID",
7373
"weblinks",
7474
"webrtc",
75+
"workspacebuilds",
7576
"xerrors",
7677
"xstate",
7778
"yamux"

site/can-ndjson-stream.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
declare module "can-ndjson-stream" {
2+
function ndjsonStream<TValueType>(body: ReadableStream<Uint8Array> | null): Promise<ReadableStream<TValueType>>
3+
export default ndjsonStream
4+
}

site/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"@xstate/inspect": "0.6.5",
3636
"@xstate/react": "3.0.0",
3737
"axios": "0.26.1",
38+
"can-ndjson-stream": "1.0.2",
3839
"cronstrue": "2.5.0",
3940
"dayjs": "1.11.2",
4041
"formik": "2.2.9",

site/src/api/api.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import axios, { AxiosRequestHeaders } from "axios"
2+
import ndjsonStream from "can-ndjson-stream"
23
import * as Types from "./types"
34
import { WorkspaceBuildTransition } from "./types"
45
import * as TypesGen from "./typesGenerated"
@@ -271,6 +272,20 @@ export const getWorkspaceBuildLogs = async (buildname: string): Promise<TypesGen
271272
return response.data
272273
}
273274

275+
export const streamWorkspaceBuildLogs = async (
276+
buildname: string,
277+
): Promise<ReadableStreamDefaultReader<TypesGen.ProvisionerJobLog>> => {
278+
// Axios does not support HTTP stream in the browser
279+
// https://github.com/axios/axios/issues/1474
280+
// So we are going to use window.fetch and return a "stream" reader
281+
const reader = await window
282+
.fetch(`/api/v2/workspacebuilds/${buildname}/logs?follow=true`)
283+
.then((res) => ndjsonStream<TypesGen.ProvisionerJobLog>(res.body))
284+
.then((stream) => stream.getReader())
285+
286+
return reader
287+
}
288+
274289
export const putWorkspaceExtension = async (
275290
workspaceId: string,
276291
extendWorkspaceRequest: TypesGen.PutExtendWorkspaceRequest,

site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.stories.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,9 @@ export const Example = Template.bind({})
1313
Example.args = {
1414
logs: MockWorkspaceBuildLogs,
1515
}
16+
17+
export const Loading = Template.bind({})
18+
Loading.args = {
19+
logs: MockWorkspaceBuildLogs,
20+
isWaitingForLogs: true,
21+
}

site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1+
import CircularProgress from "@material-ui/core/CircularProgress"
12
import { makeStyles } from "@material-ui/core/styles"
23
import dayjs from "dayjs"
34
import { FC } from "react"
45
import { ProvisionerJobLog } from "../../api/typesGenerated"
56
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
67
import { Logs } from "../Logs/Logs"
78

9+
const Language = {
10+
seconds: "seconds",
11+
}
12+
813
type Stage = ProvisionerJobLog["stage"]
914

1015
const groupLogsByStage = (logs: ProvisionerJobLog[]) => {
@@ -35,29 +40,38 @@ const getStageDurationInSeconds = (logs: ProvisionerJobLog[]) => {
3540

3641
export interface WorkspaceBuildLogsProps {
3742
logs: ProvisionerJobLog[]
43+
isWaitingForLogs: boolean
3844
}
3945

40-
export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({ logs }) => {
46+
export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({ logs, isWaitingForLogs }) => {
4147
const groupedLogsByStage = groupLogsByStage(logs)
4248
const stages = Object.keys(groupedLogsByStage)
4349
const styles = useStyles()
4450

4551
return (
4652
<div className={styles.logs}>
47-
{stages.map((stage) => {
53+
{stages.map((stage, stageIndex) => {
4854
const logs = groupedLogsByStage[stage]
4955
const isEmpty = logs.every((log) => log.output === "")
5056
const lines = logs.map((log) => ({
5157
time: log.created_at,
5258
output: log.output,
5359
}))
5460
const duration = getStageDurationInSeconds(logs)
61+
const isLastStage = stageIndex === stages.length - 1
62+
const shouldDisplaySpinner = isWaitingForLogs && isLastStage
63+
const shouldDisplayDuration = !isWaitingForLogs && duration
5564

5665
return (
5766
<div key={stage}>
5867
<div className={styles.header}>
5968
<div>{stage}</div>
60-
{duration && <div className={styles.duration}>{duration} seconds</div>}
69+
{shouldDisplaySpinner && <CircularProgress size={14} className={styles.spinner} />}
70+
{shouldDisplayDuration && (
71+
<div className={styles.duration}>
72+
{duration} {Language.seconds}
73+
</div>
74+
)}
6175
</div>
6276
{!isEmpty && <Logs lines={lines} className={styles.codeBlock} />}
6377
</div>
@@ -78,6 +92,7 @@ const useStyles = makeStyles((theme) => ({
7892
fontSize: theme.typography.body1.fontSize,
7993
padding: theme.spacing(2),
8094
paddingLeft: theme.spacing(4),
95+
paddingRight: theme.spacing(4),
8196
borderBottom: `1px solid ${theme.palette.divider}`,
8297
backgroundColor: theme.palette.background.paper,
8398
display: "flex",
@@ -94,4 +109,8 @@ const useStyles = makeStyles((theme) => ({
94109
padding: theme.spacing(2),
95110
paddingLeft: theme.spacing(4),
96111
},
112+
113+
spinner: {
114+
marginLeft: "auto",
115+
},
97116
}))

site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.test.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
import { screen } from "@testing-library/react"
2+
import * as API from "../../api/api"
23
import { MockWorkspaceBuild, MockWorkspaceBuildLogs, renderWithAuth } from "../../testHelpers/renderHelpers"
34
import { WorkspaceBuildPage } from "./WorkspaceBuildPage"
45

56
describe("WorkspaceBuildPage", () => {
67
it("renders the stats and logs", async () => {
8+
jest.spyOn(API, "streamWorkspaceBuildLogs").mockResolvedValueOnce({
9+
read() {
10+
return Promise.resolve({
11+
value: undefined,
12+
done: true,
13+
})
14+
},
15+
releaseLock: jest.fn(),
16+
closed: Promise.resolve(undefined),
17+
cancel: jest.fn(),
18+
})
719
renderWithAuth(<WorkspaceBuildPage />, { route: `/builds/${MockWorkspaceBuild.id}`, path: "/builds/:buildId" })
820

921
await screen.findByText(MockWorkspaceBuild.workspace_name)

site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const WorkspaceBuildPage: FC = () => {
2929
const buildId = useBuildId()
3030
const [buildState] = useMachine(workspaceBuildMachine, { context: { buildId } })
3131
const { logs, build } = buildState.context
32+
const isWaitingForLogs = !buildState.matches("logs.loaded")
3233
const styles = useStyles()
3334

3435
return (
@@ -40,7 +41,7 @@ export const WorkspaceBuildPage: FC = () => {
4041

4142
{build && <WorkspaceBuildStats build={build} />}
4243
{!logs && <Loader />}
43-
{logs && <WorkspaceBuildLogs logs={sortLogsByCreatedAt(logs)} />}
44+
{logs && <WorkspaceBuildLogs logs={sortLogsByCreatedAt(logs)} isWaitingForLogs={isWaitingForLogs} />}
4445
</Stack>
4546
</Margins>
4647
)

site/src/xServices/workspaceBuild/workspaceBuildXService.ts

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,28 @@ type LogsContext = {
99
getBuildError?: Error | unknown
1010
// Logs
1111
logs?: ProvisionerJobLog[]
12-
getBuildLogsError?: Error | unknown
1312
}
1413

14+
type LogsEvent =
15+
| {
16+
type: "ADD_LOG"
17+
log: ProvisionerJobLog
18+
}
19+
| {
20+
type: "NO_MORE_LOGS"
21+
}
22+
1523
export const workspaceBuildMachine = createMachine(
1624
{
1725
id: "workspaceBuildState",
1826
schema: {
1927
context: {} as LogsContext,
28+
events: {} as LogsEvent,
2029
services: {} as {
2130
getWorkspaceBuild: {
2231
data: WorkspaceBuild
2332
}
24-
getWorkspaceBuildLogs: {
33+
getLogs: {
2534
data: ProvisionerJobLog[]
2635
}
2736
},
@@ -50,23 +59,36 @@ export const workspaceBuildMachine = createMachine(
5059
},
5160
},
5261
logs: {
53-
initial: "gettingLogs",
62+
initial: "gettingExistentLogs",
5463
states: {
55-
gettingLogs: {
56-
entry: "clearGetBuildLogsError",
64+
gettingExistentLogs: {
5765
invoke: {
58-
src: "getWorkspaceBuildLogs",
66+
id: "getLogs",
67+
src: "getLogs",
5968
onDone: {
60-
target: "idle",
61-
actions: "assignLogs",
62-
},
63-
onError: {
64-
target: "idle",
65-
actions: "assignGetBuildLogsError",
69+
actions: ["assignLogs"],
70+
target: "watchingLogs",
6671
},
6772
},
6873
},
69-
idle: {},
74+
watchingLogs: {
75+
id: "watchingLogs",
76+
invoke: {
77+
id: "streamWorkspaceBuildLogs",
78+
src: "streamWorkspaceBuildLogs",
79+
},
80+
},
81+
loaded: {
82+
type: "final",
83+
},
84+
},
85+
on: {
86+
ADD_LOG: {
87+
actions: "addLog",
88+
},
89+
NO_MORE_LOGS: {
90+
target: "logs.loaded",
91+
},
7092
},
7193
},
7294
},
@@ -87,16 +109,32 @@ export const workspaceBuildMachine = createMachine(
87109
assignLogs: assign({
88110
logs: (_, event) => event.data,
89111
}),
90-
assignGetBuildLogsError: assign({
91-
getBuildLogsError: (_, event) => event.data,
92-
}),
93-
clearGetBuildLogsError: assign({
94-
getBuildLogsError: (_) => undefined,
112+
addLog: assign({
113+
logs: (context, event) => {
114+
const previousLogs = context.logs ?? []
115+
return [...previousLogs, event.log]
116+
},
95117
}),
96118
},
97119
services: {
98120
getWorkspaceBuild: (ctx) => API.getWorkspaceBuild(ctx.buildId),
99-
getWorkspaceBuildLogs: (ctx) => API.getWorkspaceBuildLogs(ctx.buildId),
121+
getLogs: async (ctx) => API.getWorkspaceBuildLogs(ctx.buildId),
122+
streamWorkspaceBuildLogs: (ctx) => async (callback) => {
123+
const reader = await API.streamWorkspaceBuildLogs(ctx.buildId)
124+
125+
// Watching for the stream
126+
// eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition
127+
while (true) {
128+
const { value, done } = await reader.read()
129+
130+
if (done) {
131+
callback("NO_MORE_LOGS")
132+
break
133+
}
134+
135+
callback({ type: "ADD_LOG", log: value })
136+
}
137+
},
100138
},
101139
},
102140
)

site/tsconfig.test.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"extends": "./tsconfig.json",
33
"exclude": ["node_modules", "_jest"],
4-
"include": ["**/*.stories.tsx", "**/*.test.tsx"]
4+
"include": ["**/*.stories.tsx", "**/*.test.tsx", "**/*.d.ts"]
55
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy