feat: Add workspace build logs page (#1598)

This commit is contained in:
Bruno Quaresma
2022-05-19 15:34:42 -05:00
committed by GitHub
parent d72c45e483
commit 0b1a35f7b8
18 changed files with 839 additions and 35 deletions
+4 -2
View File
@@ -1,5 +1,6 @@
{
"cSpell.words": [
"buildname",
"circbuf",
"cliflag",
"cliui",
@@ -55,6 +56,7 @@
"TCGETS",
"tcpip",
"TCSETS",
"testid",
"tfexec",
"tfjson",
"tfstate",
@@ -76,7 +78,7 @@
},
{
"match": "provisionerd/proto/provisionerd.proto",
"cmd": "make provisionerd/proto/provisionerd.pb.go",
"cmd": "make provisionerd/proto/provisionerd.pb.go"
}
]
},
@@ -104,5 +106,5 @@
},
// We often use a version of TypeScript that's ahead of the version shipped
// with VS Code.
"typescript.tsdk": "./site/node_modules/typescript/lib",
"typescript.tsdk": "./site/node_modules/typescript/lib"
}
+10
View File
@@ -15,6 +15,7 @@ import { SSHKeysPage } from "./pages/SettingsPages/SSHKeysPage/SSHKeysPage"
import TemplatesPage from "./pages/TemplatesPage/TemplatesPage"
import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage"
import { UsersPage } from "./pages/UsersPage/UsersPage"
import { WorkspaceBuildPage } from "./pages/WorkspaceBuildPage/WorkspaceBuildPage"
import { WorkspacePage } from "./pages/WorkspacePage/WorkspacePage"
import { WorkspaceSettingsPage } from "./pages/WorkspaceSettingsPage/WorkspaceSettingsPage"
@@ -138,6 +139,15 @@ export const AppRouter: React.FC = () => (
</Route>
</Route>
<Route
path="builds/:buildId"
element={
<AuthAndFrame>
<WorkspaceBuildPage />
</AuthAndFrame>
}
/>
{/* Using path="*"" means "match anything", so this route
acts like a catch-all for URLs that we don't have explicit
routes for. */}
+10
View File
@@ -248,3 +248,13 @@ export const getWorkspaceBuilds = async (workspaceId: string): Promise<TypesGen.
const response = await axios.get<TypesGen.WorkspaceBuild[]>(`/api/v2/workspaces/${workspaceId}/builds`)
return response.data
}
export const getWorkspaceBuild = async (workspaceId: string): Promise<TypesGen.WorkspaceBuild> => {
const response = await axios.get<TypesGen.WorkspaceBuild>(`/api/v2/workspacebuilds/${workspaceId}`)
return response.data
}
export const getWorkspaceBuildLogs = async (buildname: string): Promise<TypesGen.ProvisionerJobLog[]> => {
const response = await axios.get<TypesGen.ProvisionerJobLog[]>(`/api/v2/workspacebuilds/${buildname}/logs`)
return response.data
}
+43 -28
View File
@@ -1,23 +1,18 @@
import Box from "@material-ui/core/Box"
import { Theme } from "@material-ui/core/styles"
import { makeStyles, Theme } from "@material-ui/core/styles"
import Table from "@material-ui/core/Table"
import TableBody from "@material-ui/core/TableBody"
import TableCell from "@material-ui/core/TableCell"
import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow"
import useTheme from "@material-ui/styles/useTheme"
import dayjs from "dayjs"
import duration from "dayjs/plugin/duration"
import relativeTime from "dayjs/plugin/relativeTime"
import React from "react"
import { useNavigate } from "react-router-dom"
import * as TypesGen from "../../api/typesGenerated"
import { getDisplayStatus } from "../../util/workspace"
import { displayWorkspaceBuildDuration, getDisplayStatus } from "../../util/workspace"
import { EmptyState } from "../EmptyState/EmptyState"
import { TableLoader } from "../TableLoader/TableLoader"
dayjs.extend(relativeTime)
dayjs.extend(duration)
export const Language = {
emptyMessage: "No builds found",
inProgressLabel: "In progress",
@@ -27,19 +22,6 @@ export const Language = {
statusLabel: "Status",
}
const getDurationInSeconds = (build: TypesGen.WorkspaceBuild) => {
let display = Language.inProgressLabel
if (build.job.started_at && build.job.completed_at) {
const startedAt = dayjs(build.job.started_at)
const completedAt = dayjs(build.job.completed_at)
const diff = completedAt.diff(startedAt, "seconds")
display = `${diff} seconds`
}
return display
}
export interface BuildsTableProps {
builds?: TypesGen.WorkspaceBuild[]
className?: string
@@ -48,6 +30,8 @@ export interface BuildsTableProps {
export const BuildsTable: React.FC<BuildsTableProps> = ({ builds, className }) => {
const isLoading = !builds
const theme: Theme = useTheme()
const navigate = useNavigate()
const styles = useStyles()
return (
<Table className={className}>
@@ -62,18 +46,35 @@ export const BuildsTable: React.FC<BuildsTableProps> = ({ builds, className }) =
<TableBody>
{isLoading && <TableLoader />}
{builds &&
builds.map((b) => {
const status = getDisplayStatus(theme, b)
const duration = getDurationInSeconds(b)
builds.map((build) => {
const status = getDisplayStatus(theme, build)
const navigateToBuildPage = () => {
navigate(`/builds/${build.id}`)
}
return (
<TableRow key={b.id} data-testid={`build-${b.id}`}>
<TableCell>{b.transition}</TableCell>
<TableRow
hover
key={build.id}
data-testid={`build-${build.id}`}
tabIndex={0}
onClick={navigateToBuildPage}
onKeyDown={(event) => {
if (event.key === "Enter") {
navigateToBuildPage()
}
}}
className={styles.clickableTableRow}
>
<TableCell>{build.transition}</TableCell>
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>{duration}</span>
<span style={{ color: theme.palette.text.secondary }}>{displayWorkspaceBuildDuration(build)}</span>
</TableCell>
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>{new Date(b.created_at).toLocaleString()}</span>
<span style={{ color: theme.palette.text.secondary }}>
{new Date(build.created_at).toLocaleString()}
</span>
</TableCell>
<TableCell>
<span style={{ color: status.color }}>{status.status}</span>
@@ -95,3 +96,17 @@ export const BuildsTable: React.FC<BuildsTableProps> = ({ builds, className }) =
</Table>
)
}
const useStyles = makeStyles((theme) => ({
clickableTableRow: {
cursor: "pointer",
"&:hover td": {
backgroundColor: theme.palette.background.default,
},
"&:focus": {
outline: `1px solid ${theme.palette.primary.dark}`,
},
},
}))
+11
View File
@@ -0,0 +1,11 @@
import Box from "@material-ui/core/Box"
import CircularProgress from "@material-ui/core/CircularProgress"
import React from "react"
export const Loader: React.FC<{ size?: number }> = ({ size = 26 }) => {
return (
<Box p={4} width="100%" display="flex" alignItems="center" justifyContent="center">
<CircularProgress size={size} />
</Box>
)
}
+20
View File
@@ -0,0 +1,20 @@
import { ComponentMeta, Story } from "@storybook/react"
import React from "react"
import { MockWorkspaceBuildLogs } from "../../testHelpers/entities"
import { Logs, LogsProps } from "./Logs"
export default {
title: "components/Logs",
component: Logs,
} as ComponentMeta<typeof Logs>
const Template: Story<LogsProps> = (args) => <Logs {...args} />
const lines = MockWorkspaceBuildLogs.map((log) => ({
time: log.created_at,
output: log.output,
}))
export const Example = Template.bind({})
Example.args = {
lines,
}
+53
View File
@@ -0,0 +1,53 @@
import { makeStyles } from "@material-ui/core/styles"
import dayjs from "dayjs"
import React from "react"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
import { combineClasses } from "../../util/combineClasses"
interface Line {
time: string
output: string
}
export interface LogsProps {
lines: Line[]
className?: string
}
export const Logs: React.FC<LogsProps> = ({ lines, className = "" }) => {
const styles = useStyles()
return (
<div className={combineClasses([className, styles.root])}>
{lines.map((line, idx) => (
<div className={styles.line} key={idx}>
<div className={styles.time}>{dayjs(line.time).format(`HH:mm:ss.SSS`)}</div>
<div>{line.output}</div>
</div>
))}
</div>
)
}
const useStyles = makeStyles((theme) => ({
root: {
minHeight: 156,
background: theme.palette.background.default,
color: theme.palette.text.primary,
fontFamily: MONOSPACE_FONT_FAMILY,
fontSize: 13,
wordBreak: "break-all",
padding: theme.spacing(2),
borderRadius: theme.shape.borderRadius,
overflowX: "auto",
},
line: {
display: "flex",
alignItems: "baseline",
},
time: {
width: theme.spacing(12.5),
marginRight: theme.spacing(3),
flexShrink: 0,
},
}))
@@ -1,9 +1,8 @@
import Box from "@material-ui/core/Box"
import CircularProgress from "@material-ui/core/CircularProgress"
import { makeStyles } from "@material-ui/core/styles"
import TableCell from "@material-ui/core/TableCell"
import TableRow from "@material-ui/core/TableRow"
import React from "react"
import { Loader } from "../Loader/Loader"
export const TableLoader: React.FC = () => {
const styles = useStyles()
@@ -11,9 +10,7 @@ export const TableLoader: React.FC = () => {
return (
<TableRow>
<TableCell colSpan={999} className={styles.cell}>
<Box p={4}>
<CircularProgress size={26} />
</Box>
<Loader />
</TableCell>
</TableRow>
)
@@ -0,0 +1,16 @@
import { ComponentMeta, Story } from "@storybook/react"
import React from "react"
import { MockWorkspaceBuildLogs } from "../../testHelpers/entities"
import { WorkspaceBuildLogs, WorkspaceBuildLogsProps } from "./WorkspaceBuildLogs"
export default {
title: "components/WorkspaceBuildLogs",
component: WorkspaceBuildLogs,
} as ComponentMeta<typeof WorkspaceBuildLogs>
const Template: Story<WorkspaceBuildLogsProps> = (args) => <WorkspaceBuildLogs {...args} />
export const Example = Template.bind({})
Example.args = {
logs: MockWorkspaceBuildLogs,
}
@@ -0,0 +1,97 @@
import { makeStyles } from "@material-ui/core/styles"
import dayjs from "dayjs"
import React from "react"
import { ProvisionerJobLog } from "../../api/typesGenerated"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
import { Logs } from "../Logs/Logs"
type Stage = ProvisionerJobLog["stage"]
const groupLogsByStage = (logs: ProvisionerJobLog[]) => {
const logsByStage: Record<Stage, ProvisionerJobLog[]> = {}
for (const log of logs) {
// If there is no log in the stage record, add an empty array
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (logsByStage[log.stage] === undefined) {
logsByStage[log.stage] = []
}
logsByStage[log.stage].push(log)
}
return logsByStage
}
const getStageDurationInSeconds = (logs: ProvisionerJobLog[]) => {
if (logs.length < 2) {
return
}
const startedAt = dayjs(logs[0].created_at)
const completedAt = dayjs(logs[logs.length - 1].created_at)
return completedAt.diff(startedAt, "seconds")
}
export interface WorkspaceBuildLogsProps {
logs: ProvisionerJobLog[]
}
export const WorkspaceBuildLogs: React.FC<WorkspaceBuildLogsProps> = ({ logs }) => {
const groupedLogsByStage = groupLogsByStage(logs)
const stages = Object.keys(groupedLogsByStage)
const styles = useStyles()
return (
<div className={styles.logs}>
{stages.map((stage) => {
const logs = groupedLogsByStage[stage]
const isEmpty = logs.every((log) => log.output === "")
const lines = logs.map((log) => ({
time: log.created_at,
output: log.output,
}))
const duration = getStageDurationInSeconds(logs)
return (
<div key={stage}>
<div className={styles.header}>
<div>{stage}</div>
{duration && <div className={styles.duration}>{duration} seconds</div>}
</div>
{!isEmpty && <Logs lines={lines} className={styles.codeBlock} />}
</div>
)
})}
</div>
)
}
const useStyles = makeStyles((theme) => ({
logs: {
border: `1px solid ${theme.palette.divider}`,
borderRadius: 2,
fontFamily: MONOSPACE_FONT_FAMILY,
},
header: {
fontSize: theme.typography.body1.fontSize,
padding: theme.spacing(2),
paddingLeft: theme.spacing(4),
borderBottom: `1px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.background.paper,
display: "flex",
alignItems: "center",
},
duration: {
marginLeft: "auto",
color: theme.palette.text.secondary,
fontSize: theme.typography.body2.fontSize,
},
codeBlock: {
padding: theme.spacing(2),
paddingLeft: theme.spacing(4),
},
}))
@@ -0,0 +1,16 @@
import { ComponentMeta, Story } from "@storybook/react"
import React from "react"
import { MockWorkspaceBuild } from "../../testHelpers/entities"
import { WorkspaceBuildStats, WorkspaceBuildStatsProps } from "./WorkspaceBuildStats"
export default {
title: "components/WorkspaceBuildStats",
component: WorkspaceBuildStats,
} as ComponentMeta<typeof WorkspaceBuildStats>
const Template: Story<WorkspaceBuildStatsProps> = (args) => <WorkspaceBuildStats {...args} />
export const Example = Template.bind({})
Example.args = {
build: MockWorkspaceBuild,
}
@@ -0,0 +1,101 @@
import Link from "@material-ui/core/Link"
import { makeStyles, useTheme } from "@material-ui/core/styles"
import React from "react"
import { Link as RouterLink } from "react-router-dom"
import { WorkspaceBuild } from "../../api/typesGenerated"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
import { combineClasses } from "../../util/combineClasses"
import { displayWorkspaceBuildDuration, getDisplayStatus } from "../../util/workspace"
export interface WorkspaceBuildStatsProps {
build: WorkspaceBuild
}
export const WorkspaceBuildStats: React.FC<WorkspaceBuildStatsProps> = ({ build }) => {
const styles = useStyles()
const theme = useTheme()
const status = getDisplayStatus(theme, build)
return (
<div className={styles.stats}>
<div className={styles.statItem}>
<span className={styles.statsLabel}>Workspace ID</span>
<Link
component={RouterLink}
to={`/workspaces/${build.workspace_id}`}
className={combineClasses([styles.statsValue, styles.link])}
>
{build.workspace_id}
</Link>
</div>
<div className={styles.statsDivider} />
<div className={styles.statItem}>
<span className={styles.statsLabel}>Duration</span>
<span className={styles.statsValue}>{displayWorkspaceBuildDuration(build)}</span>
</div>
<div className={styles.statsDivider} />
<div className={styles.statItem}>
<span className={styles.statsLabel}>Started at</span>
<span className={styles.statsValue}>{new Date(build.created_at).toLocaleString()}</span>
</div>
<div className={styles.statsDivider} />
<div className={styles.statItem}>
<span className={styles.statsLabel}>Status</span>
<span className={styles.statsValue}>
<span style={{ color: status.color }}>{status.status}</span>
</span>
</div>
<div className={styles.statsDivider} />
<div className={styles.statItem}>
<span className={styles.statsLabel}>Action</span>
<span className={combineClasses([styles.statsValue, styles.capitalize])}>{build.transition}</span>
</div>
</div>
)
}
const useStyles = makeStyles((theme) => ({
stats: {
paddingTop: theme.spacing(3),
paddingBottom: theme.spacing(3),
display: "flex",
alignItems: "center",
color: theme.palette.text.secondary,
fontFamily: MONOSPACE_FONT_FAMILY,
},
statItem: {
minWidth: theme.spacing(20),
paddingRight: theme.spacing(3),
},
statsLabel: {
fontSize: 12,
textTransform: "uppercase",
display: "block",
fontWeight: 600,
},
statsValue: {
fontSize: 16,
marginTop: theme.spacing(0.25),
display: "block",
},
statsDivider: {
width: 1,
height: theme.spacing(5),
backgroundColor: theme.palette.divider,
marginRight: theme.spacing(3),
},
capitalize: {
textTransform: "capitalize",
},
link: {
color: theme.palette.text.primary,
fontWeight: 600,
},
}))
@@ -0,0 +1,13 @@
import { screen } from "@testing-library/react"
import React from "react"
import { MockWorkspaceBuild, MockWorkspaceBuildLogs, renderWithAuth } from "../../testHelpers/renderHelpers"
import { WorkspaceBuildPage } from "./WorkspaceBuildPage"
describe("WorkspaceBuildPage", () => {
it("renders the stats and logs", async () => {
renderWithAuth(<WorkspaceBuildPage />, { route: `/builds/${MockWorkspaceBuild.id}`, path: "/builds/:buildId" })
await screen.findByText(MockWorkspaceBuild.workspace_id)
await screen.findByText(MockWorkspaceBuildLogs[0].stage)
})
})
@@ -0,0 +1,48 @@
import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography"
import { useMachine } from "@xstate/react"
import React from "react"
import { useParams } from "react-router-dom"
import { Loader } from "../../components/Loader/Loader"
import { Margins } from "../../components/Margins/Margins"
import { Stack } from "../../components/Stack/Stack"
import { WorkspaceBuildLogs } from "../../components/WorkspaceBuildLogs/WorkspaceBuildLogs"
import { WorkspaceBuildStats } from "../../components/WorkspaceBuildStats/WorkspaceBuildStats"
import { workspaceBuildMachine } from "../../xServices/workspaceBuild/workspaceBuildXService"
const useBuildId = () => {
const { buildId } = useParams()
if (!buildId) {
throw new Error("buildId param is required.")
}
return buildId
}
export const WorkspaceBuildPage: React.FC = () => {
const buildId = useBuildId()
const [buildState] = useMachine(workspaceBuildMachine, { context: { buildId } })
const { logs, build } = buildState.context
const styles = useStyles()
return (
<Margins>
<Stack>
<Typography variant="h4" className={styles.title}>
Logs
</Typography>
{build && <WorkspaceBuildStats build={build} />}
{!logs && <Loader />}
{logs && <WorkspaceBuildLogs logs={logs} />}
</Stack>
</Margins>
)
}
const useStyles = makeStyles((theme) => ({
title: {
marginTop: theme.spacing(5),
},
}))
+269
View File
@@ -207,3 +207,272 @@ export const MockGitSSHKey: TypesGen.GitSSHKey = {
updated_at: "2022-05-16T15:29:10.302441433Z",
public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFJOQRIM7kE30rOzrfy+/+R+nQGCk7S9pioihy+2ARbq",
}
export const MockWorkspaceBuildLogs: TypesGen.ProvisionerJobLog[] = [
{
id: "836f8ab6-5202-4711-afa5-293394ced011",
created_at: "2022-05-19T16:45:31.005Z",
log_source: "provisioner_daemon",
log_level: "info",
stage: "Setting up",
output: "",
},
{
id: "2db0ae92-b310-4a6e-8b1f-23380b70ac7f",
created_at: "2022-05-19T16:45:31.006Z",
log_source: "provisioner_daemon",
log_level: "info",
stage: "Starting workspace",
output: "",
},
{
id: "37a5b7b1-b3eb-47cf-b80b-bd16e2e08a3d",
created_at: "2022-05-19T16:45:31.072Z",
log_source: "provisioner",
log_level: "debug",
stage: "Starting workspace",
output: "",
},
{
id: "5e4e37a1-c217-48bc-84f5-7f1c3efbd042",
created_at: "2022-05-19T16:45:31.073Z",
log_source: "provisioner",
log_level: "debug",
stage: "Starting workspace",
output: "Initializing the backend...",
},
{
id: "060ed132-5d12-4584-9005-5c9557febe2f",
created_at: "2022-05-19T16:45:31.077Z",
log_source: "provisioner",
log_level: "debug",
stage: "Starting workspace",
output: "",
},
{
id: "b2e70a1c-1943-4616-8ac9-25326c9f7e7b",
created_at: "2022-05-19T16:45:31.078Z",
log_source: "provisioner",
log_level: "debug",
stage: "Starting workspace",
output: "Initializing provider plugins...",
},
{
id: "993107fe-6dfb-42ec-912a-b32f50e60d62",
created_at: "2022-05-19T16:45:31.078Z",
log_source: "provisioner",
log_level: "debug",
stage: "Starting workspace",
output: '- Finding hashicorp/google versions matching "~\u003e 4.15"...',
},
{
id: "2ad2e2a1-7a75-4827-8cb9-928acfc6fc07",
created_at: "2022-05-19T16:45:31.123Z",
log_source: "provisioner",
log_level: "debug",
stage: "Starting workspace",
output: '- Finding coder/coder versions matching "0.3.4"...',
},
{
id: "7c723a90-0190-4c2f-9d97-ede39ef3d55f",
created_at: "2022-05-19T16:45:31.137Z",
log_source: "provisioner",
log_level: "debug",
stage: "Starting workspace",
output: "- Using hashicorp/google v4.21.0 from the shared cache directory",
},
{
id: "3910144b-411b-4a53-9900-88d406ed9bf4",
created_at: "2022-05-19T16:45:31.344Z",
log_source: "provisioner",
log_level: "debug",
stage: "Starting workspace",
output: "- Using coder/coder v0.3.4 from the shared cache directory",
},
{
id: "e3a02ad4-edc0-442f-8b9a-39d01d56b43b",
created_at: "2022-05-19T16:45:31.388Z",
log_source: "provisioner",
log_level: "debug",
stage: "Starting workspace",
output: "",
},
{
id: "440cceb3-aabf-4838-979b-1fd37fe2d8d8",
created_at: "2022-05-19T16:45:31.388Z",
log_source: "provisioner",
log_level: "debug",
stage: "Starting workspace",
output: "Terraform has created a lock file .terraform.lock.hcl to record the provider",
},
{
id: "90e1f244-78ff-4d95-871e-b2bebcabc39a",
created_at: "2022-05-19T16:45:31.389Z",
log_source: "provisioner",
log_level: "debug",
stage: "Starting workspace",
output: "selections it made above. Include this file in your version control repository",
},
{
id: "e4527d6c-2412-452b-a946-5870787caf6b",
created_at: "2022-05-19T16:45:31.389Z",
log_source: "provisioner",
log_level: "debug",
stage: "Starting workspace",
output: "so that Terraform can guarantee to make the same selections by default when",
},
{
id: "02f96d19-d94b-4d0e-a1c4-313a0d2ff9e3",
created_at: "2022-05-19T16:45:31.39Z",
log_source: "provisioner",
log_level: "debug",
stage: "Starting workspace",
output: 'you run "terraform init" in the future.',
},
{
id: "667c03ca-1b24-4f36-a598-f0322cf3e2a1",
created_at: "2022-05-19T16:45:31.39Z",
log_source: "provisioner",
log_level: "debug",
stage: "Starting workspace",
output: "",
},
{
id: "48039d6a-9b21-460f-9ca3-4b0e2becfd18",
created_at: "2022-05-19T16:45:31.391Z",
log_source: "provisioner",
log_level: "debug",
stage: "Starting workspace",
output: "Terraform has been successfully initialized!",
},
{
id: "6fe4b64f-3aa6-4850-96e9-6db8478a53be",
created_at: "2022-05-19T16:45:31.42Z",
log_source: "provisioner",
log_level: "info",
stage: "Starting workspace",
output: "Terraform 1.1.9",
},
{
id: "fa7b6321-7ecd-492d-a671-6366186fad08",
created_at: "2022-05-19T16:45:33.537Z",
log_source: "provisioner",
log_level: "info",
stage: "Starting workspace",
output: "coder_agent.dev: Plan to create",
},
{
id: "e677e49f-c5ba-417c-8c9d-78bdad744ce1",
created_at: "2022-05-19T16:45:33.537Z",
log_source: "provisioner",
log_level: "info",
stage: "Starting workspace",
output: "google_compute_disk.root: Plan to create",
},
{
id: "4b0e6168-29e4-4419-bf81-b57e31087666",
created_at: "2022-05-19T16:45:33.538Z",
log_source: "provisioner",
log_level: "info",
stage: "Starting workspace",
output: "google_compute_instance.dev[0]: Plan to create",
},
{
id: "5902f89c-8acd-45e2-9bd6-de4d6fd8fc9c",
created_at: "2022-05-19T16:45:33.539Z",
log_source: "provisioner",
log_level: "info",
stage: "Starting workspace",
output: "Plan: 3 to add, 0 to change, 0 to destroy.",
},
{
id: "a8107907-7c53-4aae-bb48-9a5f9759c7d5",
created_at: "2022-05-19T16:45:33.712Z",
log_source: "provisioner",
log_level: "info",
stage: "Starting workspace",
output: "coder_agent.dev: Creating...",
},
{
id: "aaf13503-2f1a-4f6c-aced-b8fc48304dc1",
created_at: "2022-05-19T16:45:33.719Z",
log_source: "provisioner",
log_level: "info",
stage: "Starting workspace",
output: "coder_agent.dev: Creation complete after 0s [id=d07f5bdc-4a8d-4919-9cdb-0ac6ba9e64d6]",
},
{
id: "4ada8886-f5b3-4fee-a1a3-72064b50d5ae",
created_at: "2022-05-19T16:45:34.139Z",
log_source: "provisioner",
log_level: "info",
stage: "Starting workspace",
output: "google_compute_disk.root: Creating...",
},
{
id: "8ffc59e8-a4d0-4ffe-9bcc-cb84ca51cc22",
created_at: "2022-05-19T16:45:44.14Z",
log_source: "provisioner",
log_level: "info",
stage: "Starting workspace",
output: "google_compute_disk.root: Still creating... [10s elapsed]",
},
{
id: "063189fd-75ad-415a-ac77-8c34b9e202b2",
created_at: "2022-05-19T16:45:47.106Z",
log_source: "provisioner",
log_level: "info",
stage: "Starting workspace",
output:
"google_compute_disk.root: Creation complete after 13s [id=projects/bruno-coder-v2/zones/europe-west4-b/disks/coder-developer-bruno-dev-123-root]",
},
{
id: "6fd554a1-a7a2-439f-b8d8-369d6c1ead21",
created_at: "2022-05-19T16:45:47.118Z",
log_source: "provisioner",
log_level: "info",
stage: "Starting workspace",
output: "google_compute_instance.dev[0]: Creating...",
},
{
id: "87388f7e-ab01-44b1-b35e-8e06636164d3",
created_at: "2022-05-19T16:45:57.122Z",
log_source: "provisioner",
log_level: "info",
stage: "Starting workspace",
output: "google_compute_instance.dev[0]: Still creating... [10s elapsed]",
},
{
id: "baa40120-3f18-40d2-a35c-b11f421a1ce1",
created_at: "2022-05-19T16:46:00.837Z",
log_source: "provisioner",
log_level: "info",
stage: "Starting workspace",
output:
"google_compute_instance.dev[0]: Creation complete after 14s [id=projects/bruno-coder-v2/zones/europe-west4-b/instances/coder-developer-bruno-dev-123]",
},
{
id: "00e18953-fba6-4b43-97a3-ecf376553c08",
created_at: "2022-05-19T16:46:00.846Z",
log_source: "provisioner",
log_level: "info",
stage: "Starting workspace",
output: "Apply complete! Resources: 3 added, 0 changed, 0 destroyed.",
},
{
id: "431811da-b534-4d92-b6e5-44814548c812",
created_at: "2022-05-19T16:46:00.847Z",
log_source: "provisioner",
log_level: "info",
stage: "Starting workspace",
output: "Outputs: 0",
},
{
id: "70459334-4878-4bda-a546-98eee166c4c6",
created_at: "2022-05-19T16:46:02.283Z",
log_source: "provisioner_daemon",
log_level: "info",
stage: "Cleaning Up",
output: "",
},
]
+6
View File
@@ -118,7 +118,13 @@ export const handlers = [
}),
// workspace builds
rest.get("/api/v2/workspacebuilds/:workspaceBuildId", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockWorkspaceBuild))
}),
rest.get("/api/v2/workspacebuilds/:workspaceBuildId/resources", (req, res, ctx) => {
return res(ctx.status(200), ctx.json([M.MockWorkspaceResource]))
}),
rest.get("/api/v2/workspacebuilds/:workspaceBuildId/logs", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockWorkspaceBuildLogs))
}),
]
+18
View File
@@ -1,4 +1,5 @@
import { Theme } from "@material-ui/core/styles"
import dayjs from "dayjs"
import { WorkspaceBuildTransition } from "../api/types"
import { WorkspaceBuild } from "../api/typesGenerated"
@@ -116,3 +117,20 @@ export const getDisplayStatus = (
}
throw new Error("unknown status " + status)
}
export const getWorkspaceBuildDurationInSeconds = (build: WorkspaceBuild): number | undefined => {
const isCompleted = build.job.started_at && build.job.completed_at
if (!isCompleted) {
return
}
const startedAt = dayjs(build.job.started_at)
const completedAt = dayjs(build.job.completed_at)
return completedAt.diff(startedAt, "seconds")
}
export const displayWorkspaceBuildDuration = (build: WorkspaceBuild, inProgressLabel = "In progress"): string => {
const duration = getWorkspaceBuildDurationInSeconds(build)
return duration ? `${duration} seconds` : inProgressLabel
}
@@ -0,0 +1,102 @@
import { assign, createMachine } from "xstate"
import * as API from "../../api/api"
import { ProvisionerJobLog, WorkspaceBuild } from "../../api/typesGenerated"
type LogsContext = {
// Build
buildId: string
build?: WorkspaceBuild
getBuildError?: Error | unknown
// Logs
logs?: ProvisionerJobLog[]
getBuildLogsError?: Error | unknown
}
export const workspaceBuildMachine = createMachine(
{
id: "workspaceBuildState",
schema: {
context: {} as LogsContext,
services: {} as {
getWorkspaceBuild: {
data: WorkspaceBuild
}
getWorkspaceBuildLogs: {
data: ProvisionerJobLog[]
}
},
},
tsTypes: {} as import("./workspaceBuildXService.typegen").Typegen0,
type: "parallel",
states: {
build: {
initial: "gettingBuild",
states: {
gettingBuild: {
entry: "clearGetBuildError",
invoke: {
src: "getWorkspaceBuild",
onDone: {
target: "idle",
actions: "assignBuild",
},
onError: {
target: "idle",
actions: "assignGetBuildError",
},
},
},
idle: {},
},
},
logs: {
initial: "gettingLogs",
states: {
gettingLogs: {
entry: "clearGetBuildLogsError",
invoke: {
src: "getWorkspaceBuildLogs",
onDone: {
target: "idle",
actions: "assignLogs",
},
onError: {
target: "idle",
actions: "assignGetBuildLogsError",
},
},
},
idle: {},
},
},
},
},
{
actions: {
// Build
assignBuild: assign({
build: (_, event) => event.data,
}),
assignGetBuildError: assign({
getBuildError: (_, event) => event.data,
}),
clearGetBuildError: assign({
getBuildError: (_) => undefined,
}),
// Logs
assignLogs: assign({
logs: (_, event) => event.data,
}),
assignGetBuildLogsError: assign({
getBuildLogsError: (_, event) => event.data,
}),
clearGetBuildLogsError: assign({
getBuildLogsError: (_) => undefined,
}),
},
services: {
getWorkspaceBuild: (ctx) => API.getWorkspaceBuild(ctx.buildId),
getWorkspaceBuildLogs: (ctx) => API.getWorkspaceBuildLogs(ctx.buildId),
},
},
)