feat(site): display build logs history in the build log page (#9150)

This commit is contained in:
Bruno Quaresma
2023-08-17 18:59:26 -03:00
committed by GitHub
parent be40dc85ab
commit 9caa4cfde0
15 changed files with 401 additions and 187 deletions
+2 -2
View File
@@ -488,7 +488,7 @@ export function waitForBuild(build: TypesGen.WorkspaceBuild) {
const { job } = await getWorkspaceBuildByNumber(
build.workspace_owner_name,
build.workspace_name,
String(build.build_number),
build.build_number,
)
latestJobInfo = job
@@ -772,7 +772,7 @@ export const getWorkspaceBuilds = async (
export const getWorkspaceBuildByNumber = async (
username = "me",
workspaceName: string,
buildNumber: string,
buildNumber: number,
): Promise<TypesGen.WorkspaceBuild> => {
const response = await axios.get<TypesGen.WorkspaceBuild>(
`/api/v2/users/${username}/workspace/${workspaceName}/builds/${buildNumber}`,
@@ -0,0 +1,22 @@
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined"
import StopOutlined from "@mui/icons-material/StopOutlined"
import DeleteOutlined from "@mui/icons-material/DeleteOutlined"
import { WorkspaceTransition } from "api/typesGenerated"
import { ComponentProps } from "react"
type SVGIcon = typeof PlayArrowOutlined
type SVGIconProps = ComponentProps<SVGIcon>
const iconByTransition: Record<WorkspaceTransition, SVGIcon> = {
start: PlayArrowOutlined,
stop: StopOutlined,
delete: DeleteOutlined,
}
export const BuildIcon = (
props: SVGIconProps & { transition: WorkspaceTransition },
) => {
const Icon = iconByTransition[props.transition]
return <Icon {...props} />
}
@@ -1,14 +1,12 @@
import Badge from "@mui/material/Badge"
import { useTheme, withStyles } from "@mui/styles"
import { FC } from "react"
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined"
import PauseOutlined from "@mui/icons-material/PauseOutlined"
import DeleteOutlined from "@mui/icons-material/DeleteOutlined"
import { WorkspaceBuild, WorkspaceTransition } from "api/typesGenerated"
import { WorkspaceBuild } from "api/typesGenerated"
import { getDisplayWorkspaceBuildStatus } from "utils/workspace"
import { Avatar, AvatarProps } from "components/Avatar/Avatar"
import { PaletteIndex } from "theme/theme"
import { Theme } from "@mui/material/styles"
import { BuildIcon } from "components/BuildIcon/BuildIcon"
interface StylesBadgeProps {
type: PaletteIndex
@@ -31,12 +29,6 @@ export interface BuildAvatarProps {
size?: AvatarProps["size"]
}
const iconByTransition: Record<WorkspaceTransition, JSX.Element> = {
start: <PlayArrowOutlined />,
stop: <PauseOutlined />,
delete: <DeleteOutlined />,
}
export const BuildAvatar: FC<BuildAvatarProps> = ({ build, size }) => {
const theme = useTheme<Theme>()
const displayBuildStatus = getDisplayWorkspaceBuildStatus(theme, build)
@@ -55,7 +47,7 @@ export const BuildAvatar: FC<BuildAvatarProps> = ({ build, size }) => {
badgeContent={<div></div>}
>
<Avatar size={size} colorScheme="darken">
{iconByTransition[build.transition]}
<BuildIcon transition={build.transition} />
</Avatar>
</StyledBadge>
)
+44
View File
@@ -0,0 +1,44 @@
import Box, { BoxProps } from "@mui/material/Box"
import { styled } from "@mui/styles"
import { colors } from "theme/colors"
export const Sidebar = styled((props: BoxProps) => (
<Box {...props} component="nav" />
))(({ theme }) => ({
width: theme.spacing(32),
flexShrink: 0,
borderRight: `1px solid ${theme.palette.divider}`,
height: "100%",
overflowY: "auto",
}))
export const SidebarItem = styled(
({ active, ...props }: BoxProps & { active?: boolean }) => (
<Box component="button" {...props} />
),
)(({ theme, active }) => ({
background: active ? colors.gray[13] : "none",
border: "none",
fontSize: 14,
width: "100%",
textAlign: "left",
padding: theme.spacing(0, 3),
cursor: "pointer",
pointerEvents: active ? "none" : "auto",
color: active ? theme.palette.text.primary : theme.palette.text.secondary,
"&:hover": {
background: theme.palette.action.hover,
color: theme.palette.text.primary,
},
paddingTop: theme.spacing(1.25),
paddingBottom: theme.spacing(1.25),
}))
export const SidebarCaption = styled(Box)(({ theme }) => ({
fontSize: 10,
textTransform: "uppercase",
fontWeight: 500,
color: theme.palette.text.secondary,
padding: theme.spacing(1.5, 3),
letterSpacing: "0.5px",
}))
@@ -1,37 +0,0 @@
import { ComponentMeta, Story } from "@storybook/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,
}
export const Autostart = Template.bind({})
Autostart.args = {
build: {
...MockWorkspaceBuild,
reason: "autostart",
},
}
export const Autostop = Template.bind({})
Autostop.args = {
build: {
...MockWorkspaceBuild,
transition: "stop",
reason: "autostop",
},
}
@@ -1,38 +0,0 @@
import { Stats, StatsItem } from "components/Stats/Stats"
import { FC } from "react"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { WorkspaceBuild } from "../../api/typesGenerated"
import { displayWorkspaceBuildDuration } from "../../utils/workspace"
export interface WorkspaceBuildStatsProps {
build: WorkspaceBuild
}
export const WorkspaceBuildStats: FC<WorkspaceBuildStatsProps> = ({
build,
}) => {
const { t } = useTranslation("buildPage")
return (
<Stats>
<StatsItem
label={t("stats.workspace")}
value={
<Link to={`/@${build.workspace_owner_name}/${build.workspace_name}`}>
{build.workspace_name}
</Link>
}
/>
<StatsItem
label={t("stats.duration")}
value={displayWorkspaceBuildDuration(build)}
/>
<StatsItem
label={t("stats.startedAt")}
value={new Date(build.created_at).toLocaleString()}
/>
<StatsItem label={t("stats.action")} value={build.transition} />
</Stats>
)
}
@@ -22,7 +22,7 @@ describe("WorkspaceBuildPage", () => {
expect(getWorkspaceBuildSpy).toBeCalledWith(
MockWorkspace.owner_name,
MockWorkspace.name,
`${MockWorkspaceBuild.build_number}`,
MockWorkspaceBuild.build_number,
),
)
})
@@ -1,10 +1,14 @@
import { useMachine } from "@xstate/react"
import { FC } from "react"
import { FC, useEffect } from "react"
import { Helmet } from "react-helmet-async"
import { useParams } from "react-router-dom"
import { pageTitle } from "../../utils/page"
import { workspaceBuildMachine } from "../../xServices/workspaceBuild/workspaceBuildXService"
import { WorkspaceBuildPageView } from "./WorkspaceBuildPageView"
import { useQuery } from "@tanstack/react-query"
import { getWorkspaceBuilds } from "api/api"
import dayjs from "dayjs"
import { usePermissions } from "hooks"
export const WorkspaceBuildPage: FC = () => {
const params = useParams() as {
@@ -13,12 +17,27 @@ export const WorkspaceBuildPage: FC = () => {
buildNumber: string
}
const workspaceName = params.workspace
const buildNumber = params.buildNumber
const buildNumber = Number(params.buildNumber)
const username = params.username.replace("@", "")
const [buildState] = useMachine(workspaceBuildMachine, {
const [buildState, send] = useMachine(workspaceBuildMachine, {
context: { username, workspaceName, buildNumber, timeCursor: new Date() },
})
const { logs, build } = buildState.context
const { data: builds } = useQuery({
queryKey: ["builds", username, build?.workspace_id],
queryFn: () => {
return getWorkspaceBuilds(
build?.workspace_id ?? "",
dayjs().add(-30, "day").toDate(),
)
},
enabled: Boolean(build),
})
const permissions = usePermissions()
useEffect(() => {
send("RESET", { buildNumber, timeCursor: new Date() })
}, [buildNumber, send])
return (
<>
@@ -32,7 +51,13 @@ export const WorkspaceBuildPage: FC = () => {
</title>
</Helmet>
<WorkspaceBuildPageView logs={logs} build={build} />
<WorkspaceBuildPageView
logs={logs}
build={build}
builds={builds}
activeBuildNumber={buildNumber}
hasDeploymentBanner={permissions.viewDeploymentStats}
/>
</>
)
}
@@ -1,31 +1,49 @@
import { ComponentMeta, Story } from "@storybook/react"
import { Meta, StoryObj } from "@storybook/react"
import {
MockFailedWorkspaceBuild,
MockWorkspaceBuild,
MockWorkspaceBuildLogs,
} from "../../testHelpers/entities"
import {
WorkspaceBuildPageView,
WorkspaceBuildPageViewProps,
} from "./WorkspaceBuildPageView"
import { WorkspaceBuildPageView } from "./WorkspaceBuildPageView"
export default {
const defaultBuilds = Array.from({ length: 15 }, (_, i) => ({
...MockWorkspaceBuild,
id: `${i}`,
build_number: i,
}))
const meta: Meta<typeof WorkspaceBuildPageView> = {
title: "pages/WorkspaceBuildPageView",
component: WorkspaceBuildPageView,
} as ComponentMeta<typeof WorkspaceBuildPageView>
const Template: Story<WorkspaceBuildPageViewProps> = (args) => (
<WorkspaceBuildPageView {...args} />
)
export const Example = Template.bind({})
Example.args = {
build: MockWorkspaceBuild,
logs: MockWorkspaceBuildLogs,
args: {
build: MockWorkspaceBuild,
logs: MockWorkspaceBuildLogs,
builds: defaultBuilds,
activeBuildNumber: defaultBuilds[0].build_number,
hasDeploymentBanner: false,
},
}
export const FailedDelete = Template.bind({})
FailedDelete.args = {
build: MockFailedWorkspaceBuild("delete"),
logs: MockWorkspaceBuildLogs,
export default meta
type Story = StoryObj<typeof WorkspaceBuildPageView>
export const Loaded: Story = {}
export const LoadingBuildLogs: Story = {
args: {
builds: undefined,
},
}
const failedBuild = {
...MockFailedWorkspaceBuild("delete"),
build_number: new Date().getDate(),
}
export const FailedDelete: Story = {
args: {
build: failedBuild,
builds: [failedBuild, ...defaultBuilds],
activeBuildNumber: failedBuild.build_number,
},
}
@@ -2,16 +2,30 @@ import { BuildAvatar } from "components/BuildsTable/BuildAvatar"
import { FC } from "react"
import { ProvisionerJobLog, WorkspaceBuild } from "../../api/typesGenerated"
import { Loader } from "../../components/Loader/Loader"
import { Margins } from "../../components/Margins/Margins"
import {
PageHeader,
PageHeaderSubtitle,
PageHeaderTitle,
} from "../../components/PageHeader/PageHeader"
import { Stack } from "../../components/Stack/Stack"
import { WorkspaceBuildLogs } from "../../components/WorkspaceBuildLogs/WorkspaceBuildLogs"
import { WorkspaceBuildStats } from "../../components/WorkspaceBuildStats/WorkspaceBuildStats"
import { WorkspaceBuildStateError } from "./WorkspaceBuildStateError"
import { makeStyles } from "@mui/styles"
import {
FullWidthPageHeader,
PageHeaderTitle,
PageHeaderSubtitle,
} from "components/PageHeader/FullWidthPageHeader"
import { Link } from "react-router-dom"
import { Stats, StatsItem } from "components/Stats/Stats"
import {
displayWorkspaceBuildDuration,
getDisplayWorkspaceBuildInitiatedBy,
getDisplayWorkspaceBuildStatus,
} from "utils/workspace"
import Box from "@mui/material/Box"
import {
Sidebar,
SidebarCaption,
SidebarItem,
} from "components/Sidebar/Sidebar"
import { BuildIcon } from "components/BuildIcon/BuildIcon"
import Skeleton from "@mui/material/Skeleton"
import { Alert } from "components/Alert/Alert"
const sortLogsByCreatedAt = (logs: ProvisionerJobLog[]) => {
return [...logs].sort(
@@ -23,38 +37,248 @@ const sortLogsByCreatedAt = (logs: ProvisionerJobLog[]) => {
export interface WorkspaceBuildPageViewProps {
logs: ProvisionerJobLog[] | undefined
build: WorkspaceBuild | undefined
builds: WorkspaceBuild[] | undefined
activeBuildNumber: number
hasDeploymentBanner: boolean
}
export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
logs,
build,
builds,
activeBuildNumber,
hasDeploymentBanner,
}) => {
return (
<Margins>
{build && (
<PageHeader>
<Stack direction="row" alignItems="center" spacing={3}>
<BuildAvatar build={build} size="xl" />
<div>
<PageHeaderTitle>Build #{build.build_number}</PageHeaderTitle>
<PageHeaderSubtitle condensed>
{build.initiator_name}
</PageHeaderSubtitle>
</div>
</Stack>
</PageHeader>
)}
const styles = useStyles()
const navbarHeight = 62
const deploymentBannerHeight = 48
const heightOffset = hasDeploymentBanner
? navbarHeight + deploymentBannerHeight
: navbarHeight
<Stack spacing={4}>
{build &&
build.transition === "delete" &&
build.job.status === "failed" && (
<WorkspaceBuildStateError build={build} />
if (!build) {
return <Loader />
}
return (
<Box
sx={{
// 62px is the navbar height
height: `calc(100vh - ${heightOffset}px)`,
overflow: "hidden",
// Remove padding added from dashboard layout (.siteContent)
marginBottom: "-48px",
display: "flex",
flexDirection: "column",
}}
>
<FullWidthPageHeader sticky={false}>
<Stack direction="row" alignItems="center" spacing={3}>
<BuildAvatar build={build} />
<div>
<PageHeaderTitle>Build #{build.build_number}</PageHeaderTitle>
<PageHeaderSubtitle>{build.initiator_name}</PageHeaderSubtitle>
</div>
</Stack>
<Stats aria-label="Build details" className={styles.stats}>
<StatsItem
className={styles.statsItem}
label="Workspace"
value={
<Link
to={`/@${build.workspace_owner_name}/${build.workspace_name}`}
>
{build.workspace_name}
</Link>
}
/>
<StatsItem
className={styles.statsItem}
label="Template version"
value={build.template_version_name}
/>
<StatsItem
className={styles.statsItem}
label="Duration"
value={displayWorkspaceBuildDuration(build)}
/>
<StatsItem
className={styles.statsItem}
label="Started at"
value={new Date(build.created_at).toLocaleString()}
/>
<StatsItem
className={styles.statsItem}
label="Action"
value={
<Box component="span" sx={{ textTransform: "capitalize" }}>
{build.transition}
</Box>
}
/>
</Stats>
</FullWidthPageHeader>
<Box
sx={{
display: "flex",
alignItems: "start",
overflow: "hidden",
flex: 1,
}}
>
<Sidebar>
<SidebarCaption>Builds</SidebarCaption>
{!builds &&
Array.from({ length: 15 }, (_, i) => (
<BuildSidebarItemSkeleton key={i} />
))}
{builds?.map((build) => (
<BuildSidebarItem
key={build.id}
build={build}
active={build.build_number === activeBuildNumber}
/>
))}
</Sidebar>
<Box sx={{ height: "100%", overflowY: "auto", width: "100%" }}>
{build.transition === "delete" && build.job.status === "failed" && (
<Alert
severity="error"
sx={{
borderRadius: 0,
border: 0,
background: (theme) => theme.palette.error.dark,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<Box>
The workspace may have failed to delete due to a Terraform state
mismatch. A template admin may run{" "}
<Box
component="code"
display="inline-block"
width="fit-content"
fontWeight={600}
>
`
{`coder rm ${
build.workspace_owner_name + "/" + build.workspace_name
} --orphan`}
`
</Box>{" "}
to delete the workspace skipping resource destruction.
</Box>
</Alert>
)}
{build && <WorkspaceBuildStats build={build} />}
{!logs && <Loader />}
{logs && <WorkspaceBuildLogs logs={sortLogsByCreatedAt(logs)} />}
</Stack>
</Margins>
{logs ? (
<WorkspaceBuildLogs
sx={{ border: 0 }}
logs={sortLogsByCreatedAt(logs)}
/>
) : (
<Loader />
)}
</Box>
</Box>
</Box>
)
}
const BuildSidebarItem = ({
build,
active,
}: {
build: WorkspaceBuild
active: boolean
}) => {
return (
<Link
key={build.id}
to={`/@${build.workspace_owner_name}/${build.workspace_name}/builds/${build.build_number}`}
>
<SidebarItem active={active}>
<Box sx={{ display: "flex", alignItems: "start", gap: 1 }}>
<BuildIcon
transition={build.transition}
sx={{
width: 16,
height: 16,
color: (theme) =>
theme.palette[getDisplayWorkspaceBuildStatus(theme, build).type]
.light,
}}
/>
<Box sx={{ overflow: "hidden" }}>
<Box
sx={{
textTransform: "capitalize",
color: (theme) => theme.palette.text.primary,
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
}}
>
{build.transition} by{" "}
<strong>{getDisplayWorkspaceBuildInitiatedBy(build)}</strong>
</Box>
<Box
sx={{
fontSize: 12,
color: (theme) => theme.palette.text.secondary,
mt: 0.25,
}}
>
{displayWorkspaceBuildDuration(build)}
</Box>
</Box>
</Box>
</SidebarItem>
</Link>
)
}
const BuildSidebarItemSkeleton = () => {
return (
<SidebarItem>
<Box sx={{ display: "flex", alignItems: "start", gap: 1 }}>
<Skeleton variant="circular" width={16} height={16} />
<Box>
<Skeleton variant="text" width={94} height={16} />
<Skeleton variant="text" width={60} height={14} sx={{ mt: 0.25 }} />
</Box>
</Box>
</SidebarItem>
)
}
const useStyles = makeStyles((theme) => ({
stats: {
padding: 0,
border: 0,
gap: theme.spacing(6),
rowGap: theme.spacing(3),
flex: 1,
[theme.breakpoints.down("md")]: {
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: theme.spacing(1),
},
},
statsItem: {
flexDirection: "column",
gap: 0,
padding: 0,
"& > span:first-of-type": {
fontSize: 12,
fontWeight: 500,
},
},
}))
@@ -1,36 +0,0 @@
import Box from "@mui/material/Box"
import { WorkspaceBuild } from "api/typesGenerated"
import { Alert } from "components/Alert/Alert"
const Language = {
stateMessage:
"The workspace may have failed to delete due to a Terraform state mismatch.",
}
export interface WorkspaceBuildStateErrorProps {
build: WorkspaceBuild
}
export const WorkspaceBuildStateError: React.FC<
WorkspaceBuildStateErrorProps
> = ({ build }) => {
const orphanCommand = `coder rm ${
build.workspace_owner_name + "/" + build.workspace_name
} --orphan`
return (
<Alert severity="error">
<Box>
{Language.stateMessage} A template admin may run{" "}
<Box
component="code"
display="inline-block"
width="fit-content"
fontWeight={600}
>
`{orphanCommand}`
</Box>{" "}
to delete the workspace skipping resource destruction.
</Box>
</Alert>
)
}
@@ -351,7 +351,7 @@ const WarningDialog: FC<
}
const useBuildLogs = (workspace: TypesGen.Workspace) => {
const buildNumber = workspace.latest_build.build_number.toString()
const buildNumber = workspace.latest_build.build_number
const [buildState, buildSend] = useMachine(workspaceBuildMachine, {
context: {
buildNumber,
+1 -1
View File
@@ -815,7 +815,7 @@ export const MockFailedWorkspaceBuild = (
deadline: "2022-05-17T23:39:00.00Z",
reason: "initiator",
resources: [],
status: "running",
status: "failed",
daily_cost: 20,
})
+1 -1
View File
@@ -37,7 +37,7 @@ export const getDisplayWorkspaceBuildStatus = (
case "succeeded":
return {
type: "success",
color: theme.palette.success.main,
color: theme.palette.success.light,
status: DisplayWorkspaceBuildStatusLanguage.succeeded,
} as const
case "pending":
@@ -6,7 +6,7 @@ type LogsContext = {
// Build
username: string
workspaceName: string
buildNumber: string
buildNumber: number
buildId: string
// Used to reference logs before + after.
timeCursor: Date
@@ -26,7 +26,7 @@ type LogsEvent =
}
| {
type: "RESET"
buildNumber: string
buildNumber: number
timeCursor: Date
}