mirror of https://github.com/coder/coder.git
feature: Load workspace build logs from streaming (#1997)
This commit is contained in:
parent
d6e9eab258
commit
88e8c96ddd
|
@ -72,6 +72,7 @@
|
||||||
"VMID",
|
"VMID",
|
||||||
"weblinks",
|
"weblinks",
|
||||||
"webrtc",
|
"webrtc",
|
||||||
|
"workspacebuilds",
|
||||||
"xerrors",
|
"xerrors",
|
||||||
"xstate",
|
"xstate",
|
||||||
"yamux"
|
"yamux"
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
declare module "can-ndjson-stream" {
|
||||||
|
function ndjsonStream<TValueType>(body: ReadableStream<Uint8Array> | null): Promise<ReadableStream<TValueType>>
|
||||||
|
export default ndjsonStream
|
||||||
|
}
|
|
@ -35,6 +35,7 @@
|
||||||
"@xstate/inspect": "0.6.5",
|
"@xstate/inspect": "0.6.5",
|
||||||
"@xstate/react": "3.0.0",
|
"@xstate/react": "3.0.0",
|
||||||
"axios": "0.26.1",
|
"axios": "0.26.1",
|
||||||
|
"can-ndjson-stream": "1.0.2",
|
||||||
"cronstrue": "2.5.0",
|
"cronstrue": "2.5.0",
|
||||||
"dayjs": "1.11.2",
|
"dayjs": "1.11.2",
|
||||||
"formik": "2.2.9",
|
"formik": "2.2.9",
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import axios, { AxiosRequestHeaders } from "axios"
|
import axios, { AxiosRequestHeaders } from "axios"
|
||||||
|
import ndjsonStream from "can-ndjson-stream"
|
||||||
import * as Types from "./types"
|
import * as Types from "./types"
|
||||||
import { WorkspaceBuildTransition } from "./types"
|
import { WorkspaceBuildTransition } from "./types"
|
||||||
import * as TypesGen from "./typesGenerated"
|
import * as TypesGen from "./typesGenerated"
|
||||||
|
@ -271,6 +272,20 @@ export const getWorkspaceBuildLogs = async (buildname: string): Promise<TypesGen
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const streamWorkspaceBuildLogs = async (
|
||||||
|
buildname: string,
|
||||||
|
): Promise<ReadableStreamDefaultReader<TypesGen.ProvisionerJobLog>> => {
|
||||||
|
// 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<TypesGen.ProvisionerJobLog>(res.body))
|
||||||
|
.then((stream) => stream.getReader())
|
||||||
|
|
||||||
|
return reader
|
||||||
|
}
|
||||||
|
|
||||||
export const putWorkspaceExtension = async (
|
export const putWorkspaceExtension = async (
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
extendWorkspaceRequest: TypesGen.PutExtendWorkspaceRequest,
|
extendWorkspaceRequest: TypesGen.PutExtendWorkspaceRequest,
|
||||||
|
|
|
@ -13,3 +13,9 @@ export const Example = Template.bind({})
|
||||||
Example.args = {
|
Example.args = {
|
||||||
logs: MockWorkspaceBuildLogs,
|
logs: MockWorkspaceBuildLogs,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const Loading = Template.bind({})
|
||||||
|
Loading.args = {
|
||||||
|
logs: MockWorkspaceBuildLogs,
|
||||||
|
isWaitingForLogs: true,
|
||||||
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import CircularProgress from "@material-ui/core/CircularProgress"
|
||||||
import { makeStyles } from "@material-ui/core/styles"
|
import { makeStyles } from "@material-ui/core/styles"
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import { FC } from "react"
|
import { FC } from "react"
|
||||||
|
@ -5,6 +6,10 @@ import { ProvisionerJobLog } from "../../api/typesGenerated"
|
||||||
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
|
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
|
||||||
import { Logs } from "../Logs/Logs"
|
import { Logs } from "../Logs/Logs"
|
||||||
|
|
||||||
|
const Language = {
|
||||||
|
seconds: "seconds",
|
||||||
|
}
|
||||||
|
|
||||||
type Stage = ProvisionerJobLog["stage"]
|
type Stage = ProvisionerJobLog["stage"]
|
||||||
|
|
||||||
const groupLogsByStage = (logs: ProvisionerJobLog[]) => {
|
const groupLogsByStage = (logs: ProvisionerJobLog[]) => {
|
||||||
|
@ -35,16 +40,17 @@ const getStageDurationInSeconds = (logs: ProvisionerJobLog[]) => {
|
||||||
|
|
||||||
export interface WorkspaceBuildLogsProps {
|
export interface WorkspaceBuildLogsProps {
|
||||||
logs: ProvisionerJobLog[]
|
logs: ProvisionerJobLog[]
|
||||||
|
isWaitingForLogs: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({ logs }) => {
|
export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({ logs, isWaitingForLogs }) => {
|
||||||
const groupedLogsByStage = groupLogsByStage(logs)
|
const groupedLogsByStage = groupLogsByStage(logs)
|
||||||
const stages = Object.keys(groupedLogsByStage)
|
const stages = Object.keys(groupedLogsByStage)
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.logs}>
|
<div className={styles.logs}>
|
||||||
{stages.map((stage) => {
|
{stages.map((stage, stageIndex) => {
|
||||||
const logs = groupedLogsByStage[stage]
|
const logs = groupedLogsByStage[stage]
|
||||||
const isEmpty = logs.every((log) => log.output === "")
|
const isEmpty = logs.every((log) => log.output === "")
|
||||||
const lines = logs.map((log) => ({
|
const lines = logs.map((log) => ({
|
||||||
|
@ -52,12 +58,20 @@ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({ logs }) => {
|
||||||
output: log.output,
|
output: log.output,
|
||||||
}))
|
}))
|
||||||
const duration = getStageDurationInSeconds(logs)
|
const duration = getStageDurationInSeconds(logs)
|
||||||
|
const isLastStage = stageIndex === stages.length - 1
|
||||||
|
const shouldDisplaySpinner = isWaitingForLogs && isLastStage
|
||||||
|
const shouldDisplayDuration = !isWaitingForLogs && duration
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={stage}>
|
<div key={stage}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div>{stage}</div>
|
<div>{stage}</div>
|
||||||
{duration && <div className={styles.duration}>{duration} seconds</div>}
|
{shouldDisplaySpinner && <CircularProgress size={14} className={styles.spinner} />}
|
||||||
|
{shouldDisplayDuration && (
|
||||||
|
<div className={styles.duration}>
|
||||||
|
{duration} {Language.seconds}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isEmpty && <Logs lines={lines} className={styles.codeBlock} />}
|
{!isEmpty && <Logs lines={lines} className={styles.codeBlock} />}
|
||||||
</div>
|
</div>
|
||||||
|
@ -78,6 +92,7 @@ const useStyles = makeStyles((theme) => ({
|
||||||
fontSize: theme.typography.body1.fontSize,
|
fontSize: theme.typography.body1.fontSize,
|
||||||
padding: theme.spacing(2),
|
padding: theme.spacing(2),
|
||||||
paddingLeft: theme.spacing(4),
|
paddingLeft: theme.spacing(4),
|
||||||
|
paddingRight: theme.spacing(4),
|
||||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||||
backgroundColor: theme.palette.background.paper,
|
backgroundColor: theme.palette.background.paper,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
@ -94,4 +109,8 @@ const useStyles = makeStyles((theme) => ({
|
||||||
padding: theme.spacing(2),
|
padding: theme.spacing(2),
|
||||||
paddingLeft: theme.spacing(4),
|
paddingLeft: theme.spacing(4),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
spinner: {
|
||||||
|
marginLeft: "auto",
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
|
@ -1,9 +1,21 @@
|
||||||
import { screen } from "@testing-library/react"
|
import { screen } from "@testing-library/react"
|
||||||
|
import * as API from "../../api/api"
|
||||||
import { MockWorkspaceBuild, MockWorkspaceBuildLogs, renderWithAuth } from "../../testHelpers/renderHelpers"
|
import { MockWorkspaceBuild, MockWorkspaceBuildLogs, renderWithAuth } from "../../testHelpers/renderHelpers"
|
||||||
import { WorkspaceBuildPage } from "./WorkspaceBuildPage"
|
import { WorkspaceBuildPage } from "./WorkspaceBuildPage"
|
||||||
|
|
||||||
describe("WorkspaceBuildPage", () => {
|
describe("WorkspaceBuildPage", () => {
|
||||||
it("renders the stats and logs", async () => {
|
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(<WorkspaceBuildPage />, { route: `/builds/${MockWorkspaceBuild.id}`, path: "/builds/:buildId" })
|
renderWithAuth(<WorkspaceBuildPage />, { route: `/builds/${MockWorkspaceBuild.id}`, path: "/builds/:buildId" })
|
||||||
|
|
||||||
await screen.findByText(MockWorkspaceBuild.workspace_name)
|
await screen.findByText(MockWorkspaceBuild.workspace_name)
|
||||||
|
|
|
@ -29,6 +29,7 @@ export const WorkspaceBuildPage: FC = () => {
|
||||||
const buildId = useBuildId()
|
const buildId = useBuildId()
|
||||||
const [buildState] = useMachine(workspaceBuildMachine, { context: { buildId } })
|
const [buildState] = useMachine(workspaceBuildMachine, { context: { buildId } })
|
||||||
const { logs, build } = buildState.context
|
const { logs, build } = buildState.context
|
||||||
|
const isWaitingForLogs = !buildState.matches("logs.loaded")
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -40,7 +41,7 @@ export const WorkspaceBuildPage: FC = () => {
|
||||||
|
|
||||||
{build && <WorkspaceBuildStats build={build} />}
|
{build && <WorkspaceBuildStats build={build} />}
|
||||||
{!logs && <Loader />}
|
{!logs && <Loader />}
|
||||||
{logs && <WorkspaceBuildLogs logs={sortLogsByCreatedAt(logs)} />}
|
{logs && <WorkspaceBuildLogs logs={sortLogsByCreatedAt(logs)} isWaitingForLogs={isWaitingForLogs} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Margins>
|
</Margins>
|
||||||
)
|
)
|
||||||
|
|
|
@ -9,19 +9,28 @@ type LogsContext = {
|
||||||
getBuildError?: Error | unknown
|
getBuildError?: Error | unknown
|
||||||
// Logs
|
// Logs
|
||||||
logs?: ProvisionerJobLog[]
|
logs?: ProvisionerJobLog[]
|
||||||
getBuildLogsError?: Error | unknown
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LogsEvent =
|
||||||
|
| {
|
||||||
|
type: "ADD_LOG"
|
||||||
|
log: ProvisionerJobLog
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "NO_MORE_LOGS"
|
||||||
|
}
|
||||||
|
|
||||||
export const workspaceBuildMachine = createMachine(
|
export const workspaceBuildMachine = createMachine(
|
||||||
{
|
{
|
||||||
id: "workspaceBuildState",
|
id: "workspaceBuildState",
|
||||||
schema: {
|
schema: {
|
||||||
context: {} as LogsContext,
|
context: {} as LogsContext,
|
||||||
|
events: {} as LogsEvent,
|
||||||
services: {} as {
|
services: {} as {
|
||||||
getWorkspaceBuild: {
|
getWorkspaceBuild: {
|
||||||
data: WorkspaceBuild
|
data: WorkspaceBuild
|
||||||
}
|
}
|
||||||
getWorkspaceBuildLogs: {
|
getLogs: {
|
||||||
data: ProvisionerJobLog[]
|
data: ProvisionerJobLog[]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -50,23 +59,36 @@ export const workspaceBuildMachine = createMachine(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
logs: {
|
logs: {
|
||||||
initial: "gettingLogs",
|
initial: "gettingExistentLogs",
|
||||||
states: {
|
states: {
|
||||||
gettingLogs: {
|
gettingExistentLogs: {
|
||||||
entry: "clearGetBuildLogsError",
|
|
||||||
invoke: {
|
invoke: {
|
||||||
src: "getWorkspaceBuildLogs",
|
id: "getLogs",
|
||||||
|
src: "getLogs",
|
||||||
onDone: {
|
onDone: {
|
||||||
target: "idle",
|
actions: ["assignLogs"],
|
||||||
actions: "assignLogs",
|
target: "watchingLogs",
|
||||||
},
|
|
||||||
onError: {
|
|
||||||
target: "idle",
|
|
||||||
actions: "assignGetBuildLogsError",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
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({
|
assignLogs: assign({
|
||||||
logs: (_, event) => event.data,
|
logs: (_, event) => event.data,
|
||||||
}),
|
}),
|
||||||
assignGetBuildLogsError: assign({
|
addLog: assign({
|
||||||
getBuildLogsError: (_, event) => event.data,
|
logs: (context, event) => {
|
||||||
}),
|
const previousLogs = context.logs ?? []
|
||||||
clearGetBuildLogsError: assign({
|
return [...previousLogs, event.log]
|
||||||
getBuildLogsError: (_) => undefined,
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
services: {
|
services: {
|
||||||
getWorkspaceBuild: (ctx) => API.getWorkspaceBuild(ctx.buildId),
|
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 })
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"exclude": ["node_modules", "_jest"],
|
"exclude": ["node_modules", "_jest"],
|
||||||
"include": ["**/*.stories.tsx", "**/*.test.tsx"]
|
"include": ["**/*.stories.tsx", "**/*.test.tsx", "**/*.d.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -4731,6 +4731,18 @@ camelcase@^6.2.0:
|
||||||
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
|
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
|
||||||
integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
|
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:
|
caniuse-api@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0"
|
resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0"
|
||||||
|
|
Loading…
Reference in New Issue