mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: Add workspace build logs page (#1598)
This commit is contained in:
Vendored
+4
-2
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
}))
|
||||
@@ -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: "",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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))
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user