Skip to content

Commit b587ca2

Browse files
BrunoQuaresmakylecarbs
authored andcommitted
feature: Load workspace build logs from streaming (#1997)
1 parent 4adc546 commit b587ca2

File tree

11 files changed

+133
-24
lines changed

11 files changed

+133
-24
lines changed

.vscode/settings.json

+1
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

+4
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

+1
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

+15
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

+6
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

+22-3
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

+12
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

+2-1
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

+57-19
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

+1-1
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
}

site/yarn.lock

+12
Original file line numberDiff line numberDiff line change
@@ -4731,6 +4731,18 @@ camelcase@^6.2.0:
47314731
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
47324732
integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
47334733

4734+
can-namespace@^1.0.0:
4735+
version "1.0.0"
4736+
resolved "https://registry.yarnpkg.com/can-namespace/-/can-namespace-1.0.0.tgz#0b8fafafbb11352b9ead4222ffe3822405b43e99"
4737+
integrity sha512-1sBY/SLwwcmxz3NhyVhLjt2uD/dZ7V1mII82/MIXSDn5QXnslnosJnjlP8+yTx2uTCRvw1jlFDElRs4pX7AG5w==
4738+
4739+
can-ndjson-stream@1.0.2:
4740+
version "1.0.2"
4741+
resolved "https://registry.yarnpkg.com/can-ndjson-stream/-/can-ndjson-stream-1.0.2.tgz#6a8131f9c8c697215163b3fe49a0c02e4439cb47"
4742+
integrity sha512-//tM8wcTV42SyD1JGua7WMVftZEeTwapcHJTTe3vJwuVywXD01CJbdEkgwRYjy2evIByVJV21ZKBdSv5ygIw1w==
4743+
dependencies:
4744+
can-namespace "^1.0.0"
4745+
47344746
caniuse-api@^3.0.0:
47354747
version "3.0.0"
47364748
resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0"

0 commit comments

Comments
 (0)