feat: Redesign build logs (#4734)

This commit is contained in:
Bruno Quaresma
2022-10-25 00:44:13 -03:00
committed by GitHub
parent 6449443c1f
commit 3e08bb4842
10 changed files with 329 additions and 123 deletions

View File

@ -0,0 +1,71 @@
import Avatar from "@material-ui/core/Avatar"
import Badge from "@material-ui/core/Badge"
import { Theme, useTheme, withStyles } from "@material-ui/core/styles"
import { FC } from "react"
import PlayArrowOutlined from "@material-ui/icons/PlayArrowOutlined"
import PauseOutlined from "@material-ui/icons/PauseOutlined"
import DeleteOutlined from "@material-ui/icons/DeleteOutlined"
import { WorkspaceBuild, WorkspaceTransition } from "api/typesGenerated"
import { getDisplayWorkspaceBuildStatus } from "util/workspace"
import { PaletteIndex } from "theme/palettes"
interface StylesBadgeProps {
type: PaletteIndex
}
const StyledBadge = withStyles((theme) => ({
badge: {
backgroundColor: ({ type }: StylesBadgeProps) => theme.palette[type].light,
borderRadius: "100%",
width: 8,
minWidth: 8,
height: 8,
display: "block",
padding: 0,
},
}))(Badge)
const StyledAvatar = withStyles((theme) => ({
root: {
background: theme.palette.background.paperLight,
color: theme.palette.text.primary,
border: `2px solid ${theme.palette.divider}`,
"& svg": {
width: 24,
height: 24,
},
},
}))(Avatar)
export type BuildAvatarProps = {
build: WorkspaceBuild
}
const iconByTransition: Record<WorkspaceTransition, JSX.Element> = {
start: <PlayArrowOutlined />,
stop: <PauseOutlined />,
delete: <DeleteOutlined />,
}
export const BuildAvatar: FC<BuildAvatarProps> = ({ build }) => {
const theme = useTheme<Theme>()
const displayBuildStatus = getDisplayWorkspaceBuildStatus(theme, build)
return (
<StyledBadge
role="status"
type={displayBuildStatus.type}
arial-label={displayBuildStatus.status}
title={displayBuildStatus.status}
overlap="circular"
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
badgeContent={<div></div>}
>
<StyledAvatar>{iconByTransition[build.transition]}</StyledAvatar>
</StyledBadge>
)
}

View File

@ -0,0 +1,46 @@
import { makeStyles } from "@material-ui/core/styles"
import TableCell from "@material-ui/core/TableCell"
import TableRow from "@material-ui/core/TableRow"
import formatRelative from "date-fns/formatRelative"
import { FC } from "react"
export interface BuildDateRow {
date: Date
}
export const BuildDateRow: FC<BuildDateRow> = ({ date }) => {
const styles = useStyles()
// We only want the message related to the date since the time is displayed
// inside of the build row
const displayDate = formatRelative(date, new Date()).split("at")[0]
return (
<TableRow className={styles.buildDateRow}>
<TableCell
className={styles.buildDateCell}
title={date.toLocaleDateString()}
>
{displayDate}
</TableCell>
</TableRow>
)
}
const useStyles = makeStyles((theme) => ({
buildDateRow: {
background: theme.palette.background.paper,
"&:not(:first-child) td": {
borderTop: `1px solid ${theme.palette.divider}`,
},
},
buildDateCell: {
padding: `${theme.spacing(1, 4)} !important`,
background: `${theme.palette.background.paperLight} !important`,
fontSize: 12,
position: "relative",
color: theme.palette.text.secondary,
textTransform: "capitalize",
},
}))

View File

@ -0,0 +1,144 @@
import { makeStyles } from "@material-ui/core/styles"
import TableCell from "@material-ui/core/TableCell"
import TableRow from "@material-ui/core/TableRow"
import { WorkspaceBuild } from "api/typesGenerated"
import { Stack } from "components/Stack/Stack"
import { useClickable } from "hooks/useClickable"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { MONOSPACE_FONT_FAMILY } from "theme/constants"
import {
displayWorkspaceBuildDuration,
getDisplayWorkspaceBuildInitiatedBy,
} from "util/workspace"
import { BuildAvatar } from "./BuildAvatar"
export interface BuildRowProps {
build: WorkspaceBuild
}
export const BuildRow: React.FC<BuildRowProps> = ({ build }) => {
const styles = useStyles()
const { t } = useTranslation("workspacePage")
const initiatedBy = getDisplayWorkspaceBuildInitiatedBy(build)
const navigate = useNavigate()
const clickableProps = useClickable(() =>
navigate(`builds/${build.build_number}`),
)
return (
<TableRow
hover
data-testid={`build-${build.id}`}
className={styles.buildRow}
{...clickableProps}
>
<TableCell className={styles.buildCell}>
<Stack
direction="row"
alignItems="center"
className={styles.buildWrapper}
>
<Stack direction="row" alignItems="center">
<BuildAvatar build={build} />
<div>
<Stack
className={styles.buildResume}
direction="row"
alignItems="center"
spacing={1}
>
<span>
<strong>{initiatedBy}</strong>{" "}
{build.reason !== "initiator"
? t("buildMessage.automatically")
: ""}
<strong>{t(`buildMessage.${build.transition}`)}</strong>{" "}
{t("buildMessage.theWorkspace")}
</span>
<span className={styles.buildTime}>
{new Date(build.created_at).toLocaleTimeString()}
</span>
</Stack>
<Stack direction="row" spacing={1}>
<span className={styles.buildInfo}>
{t("buildData.reason")}: <strong>{build.reason}</strong>
</span>
<span className={styles.buildInfo}>
{t("buildData.duration")}:{" "}
<strong>{displayWorkspaceBuildDuration(build)}</strong>
</span>
</Stack>
</div>
</Stack>
</Stack>
</TableCell>
</TableRow>
)
}
const useStyles = makeStyles((theme) => ({
buildRow: {
cursor: "pointer",
"&:focus": {
outlineStyle: "solid",
outlineOffset: -1,
outlineWidth: 2,
outlineColor: theme.palette.secondary.dark,
},
"&:not(:last-child) td:before": {
position: "absolute",
top: 20,
left: 50,
display: "block",
content: "''",
height: "100%",
width: 2,
background: theme.palette.divider,
},
},
buildWrapper: {
padding: theme.spacing(2, 4),
},
buildCell: {
padding: "0 !important",
position: "relative",
borderBottom: 0,
},
buildResume: {
...theme.typography.body1,
fontFamily: "inherit",
},
buildInfo: {
...theme.typography.body2,
fontSize: 12,
fontFamily: "inherit",
color: theme.palette.text.secondary,
display: "block",
},
buildTime: {
color: theme.palette.text.secondary,
fontSize: 12,
},
buildRight: {
width: "auto",
},
buildExtraInfo: {
...theme.typography.body2,
fontFamily: MONOSPACE_FONT_FAMILY,
color: theme.palette.text.secondary,
whiteSpace: "nowrap",
},
}))

View File

@ -1,23 +1,15 @@
import Box from "@material-ui/core/Box" import Box from "@material-ui/core/Box"
import { makeStyles, Theme } from "@material-ui/core/styles"
import Table from "@material-ui/core/Table" import Table from "@material-ui/core/Table"
import TableBody from "@material-ui/core/TableBody" import TableBody from "@material-ui/core/TableBody"
import TableCell from "@material-ui/core/TableCell" import TableCell from "@material-ui/core/TableCell"
import TableContainer from "@material-ui/core/TableContainer" import TableContainer from "@material-ui/core/TableContainer"
import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow" import TableRow from "@material-ui/core/TableRow"
import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight" import { FC, Fragment } from "react"
import useTheme from "@material-ui/styles/useTheme"
import { FC } from "react"
import { useNavigate, useParams } from "react-router-dom"
import * as TypesGen from "../../api/typesGenerated" import * as TypesGen from "../../api/typesGenerated"
import {
displayWorkspaceBuildDuration,
getDisplayWorkspaceBuildStatus,
} from "../../util/workspace"
import { EmptyState } from "../EmptyState/EmptyState" import { EmptyState } from "../EmptyState/EmptyState"
import { TableCellLink } from "../TableCellLink/TableCellLink"
import { TableLoader } from "../TableLoader/TableLoader" import { TableLoader } from "../TableLoader/TableLoader"
import { BuildDateRow } from "./BuildDateRow"
import { BuildRow } from "./BuildRow"
export const Language = { export const Language = {
emptyMessage: "No builds found", emptyMessage: "No builds found",
@ -33,75 +25,51 @@ export interface BuildsTableProps {
className?: string className?: string
} }
const groupBuildsByDate = (builds?: TypesGen.WorkspaceBuild[]) => {
const buildsByDate: Record<string, TypesGen.WorkspaceBuild[]> = {}
if (!builds) {
return
}
builds.forEach((build) => {
const dateKey = new Date(build.created_at).toDateString()
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (buildsByDate[dateKey]) {
buildsByDate[dateKey].push(build)
} else {
buildsByDate[dateKey] = [build]
}
})
return buildsByDate
}
export const BuildsTable: FC<React.PropsWithChildren<BuildsTableProps>> = ({ export const BuildsTable: FC<React.PropsWithChildren<BuildsTableProps>> = ({
builds, builds,
className, className,
}) => { }) => {
const { username, workspace: workspaceName } = useParams()
const isLoading = !builds const isLoading = !builds
const theme: Theme = useTheme() const buildsByDate = groupBuildsByDate(builds)
const navigate = useNavigate()
const styles = useStyles()
return ( return (
<TableContainer className={className}> <TableContainer className={className}>
<Table data-testid="builds-table" aria-describedby="builds table"> <Table data-testid="builds-table" aria-describedby="builds table">
<TableHead>
<TableRow>
<TableCell width="20%">{Language.actionLabel}</TableCell>
<TableCell width="20%">{Language.durationLabel}</TableCell>
<TableCell width="40%">{Language.startedAtLabel}</TableCell>
<TableCell width="20%">{Language.statusLabel}</TableCell>
<TableCell></TableCell>
</TableRow>
</TableHead>
<TableBody> <TableBody>
{isLoading && <TableLoader />} {isLoading && <TableLoader />}
{builds &&
builds.map((build) => { {buildsByDate &&
const status = getDisplayWorkspaceBuildStatus(theme, build) Object.keys(buildsByDate).map((dateStr) => {
const buildPageLink = `/@${username}/${workspaceName}/builds/${build.build_number}` const builds = buildsByDate[dateStr]
return ( return (
<TableRow <Fragment key={dateStr}>
hover <BuildDateRow date={new Date(dateStr)} />
key={build.id} {builds.map((build) => (
data-testid={`build-${build.id}`} <BuildRow key={build.id} build={build} />
tabIndex={0} ))}
onKeyDown={(event) => { </Fragment>
if (event.key === "Enter") {
navigate(buildPageLink)
}
}}
className={styles.clickableTableRow}
>
<TableCellLink to={buildPageLink}>
{build.transition}
</TableCellLink>
<TableCellLink to={buildPageLink}>
<span style={{ color: theme.palette.text.secondary }}>
{displayWorkspaceBuildDuration(build)}
</span>
</TableCellLink>
<TableCellLink to={buildPageLink}>
<span style={{ color: theme.palette.text.secondary }}>
{new Date(build.created_at).toLocaleString()}
</span>
</TableCellLink>
<TableCellLink to={buildPageLink}>
<span
style={{ color: status.color }}
className={styles.status}
>
{status.status}
</span>
</TableCellLink>
<TableCellLink to={buildPageLink}>
<div className={styles.arrowCell}>
<KeyboardArrowRight className={styles.arrowRight} />
</div>
</TableCellLink>
</TableRow>
) )
})} })}
@ -119,30 +87,3 @@ export const BuildsTable: FC<React.PropsWithChildren<BuildsTableProps>> = ({
</TableContainer> </TableContainer>
) )
} }
const useStyles = makeStyles((theme) => ({
clickableTableRow: {
"&:hover td": {
backgroundColor: theme.palette.action.hover,
},
"&:focus": {
outline: `1px solid ${theme.palette.secondary.dark}`,
},
"& .MuiTableCell-root:last-child": {
paddingRight: theme.spacing(2),
},
},
arrowRight: {
color: theme.palette.text.secondary,
width: 20,
height: 20,
},
arrowCell: {
display: "flex",
},
status: {
whiteSpace: "nowrap",
},
}))

View File

@ -93,11 +93,12 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
) )
} }
const useStyles = makeStyles(() => ({ const useStyles = makeStyles((theme) => ({
buttonWrapper: { buttonWrapper: {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
marginTop: theme.spacing(2),
}, },
showMoreButton: { showMoreButton: {

View File

@ -179,7 +179,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
<Stack <Stack
direction="column" direction="column"
className={styles.firstColumnSpacer} className={styles.firstColumnSpacer}
spacing={2.5} spacing={6}
> >
{buildError} {buildError}
{cancellationError} {cancellationError}
@ -220,7 +220,6 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
)} )}
<WorkspaceSection <WorkspaceSection
title="Logs"
contentsProps={{ className: styles.timelineContents }} contentsProps={{ className: styles.timelineContents }}
> >
{workspaceErrors[WorkspaceErrors.GET_BUILDS_ERROR] ? ( {workspaceErrors[WorkspaceErrors.GET_BUILDS_ERROR] ? (

View File

@ -38,5 +38,16 @@
"connected": "Connected", "connected": "Connected",
"connecting": "Connecting...", "connecting": "Connecting...",
"disconnected": "Disconnected" "disconnected": "Disconnected"
},
"buildMessage": {
"start": "started",
"stop": "stopped",
"delete": "deleted",
"theWorkspace": "the workspace",
"automatically": "automatically "
},
"buildData": {
"reason": "Reason",
"duration": "Duration"
} }
} }

View File

@ -92,16 +92,6 @@ afterAll(() => {
}) })
describe("WorkspacePage", () => { describe("WorkspacePage", () => {
it("shows a workspace", async () => {
await renderWorkspacePage()
const workspaceName = await screen.findByText(MockWorkspace.name)
expect(workspaceName).toBeDefined()
const header = screen.getByTestId("header")
const status = await within(header).findByRole("status")
expect(status).toHaveTextContent("Running")
// wait for workspace page to finish loading
await screen.findByText("stop")
})
it("requests a stop job when the user presses Stop", async () => { it("requests a stop job when the user presses Stop", async () => {
const stopWorkspaceMock = jest const stopWorkspaceMock = jest
.spyOn(api, "stopWorkspace") .spyOn(api, "stopWorkspace")
@ -288,7 +278,8 @@ describe("WorkspacePage", () => {
// Wait for the results to be loaded // Wait for the results to be loaded
await waitFor(async () => { await waitFor(async () => {
const rows = table.querySelectorAll("tbody > tr") const rows = table.querySelectorAll("tbody > tr")
expect(rows).toHaveLength(MockBuilds.length) // Added +1 because of the date row
expect(rows).toHaveLength(MockBuilds.length + 1)
}) })
}) })
}) })

View File

@ -82,14 +82,14 @@ describe("util > workspace", () => {
...Mocks.MockWorkspaceBuild, ...Mocks.MockWorkspaceBuild,
reason: "autostart", reason: "autostart",
}, },
"system/autostart", "Coder",
], ],
[ [
{ {
...Mocks.MockWorkspaceBuild, ...Mocks.MockWorkspaceBuild,
reason: "autostop", reason: "autostop",
}, },
"system/autostop", "Coder",
], ],
])( ])(
`getDisplayWorkspaceBuildInitiatedBy(%p) returns %p`, `getDisplayWorkspaceBuildInitiatedBy(%p) returns %p`,

View File

@ -4,6 +4,7 @@ import duration from "dayjs/plugin/duration"
import minMax from "dayjs/plugin/minMax" import minMax from "dayjs/plugin/minMax"
import utc from "dayjs/plugin/utc" import utc from "dayjs/plugin/utc"
import semver from "semver" import semver from "semver"
import { PaletteIndex } from "theme/palettes"
import * as TypesGen from "../api/typesGenerated" import * as TypesGen from "../api/typesGenerated"
dayjs.extend(duration) dayjs.extend(duration)
@ -29,46 +30,48 @@ export const getDisplayWorkspaceBuildStatus = (
): { ): {
color: string color: string
status: string status: string
type: PaletteIndex
} => { } => {
switch (build.job.status) { switch (build.job.status) {
case "succeeded": case "succeeded":
return { return {
type: "success",
color: theme.palette.success.main, color: theme.palette.success.main,
status: `⦿ ${DisplayWorkspaceBuildStatusLanguage.succeeded}`, status: DisplayWorkspaceBuildStatusLanguage.succeeded,
} }
case "pending": case "pending":
return { return {
type: "secondary",
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
status: `⦿ ${DisplayWorkspaceBuildStatusLanguage.pending}`, status: DisplayWorkspaceBuildStatusLanguage.pending,
} }
case "running": case "running":
return { return {
type: "info",
color: theme.palette.primary.main, color: theme.palette.primary.main,
status: `⦿ ${DisplayWorkspaceBuildStatusLanguage.running}`, status: DisplayWorkspaceBuildStatusLanguage.running,
} }
case "failed": case "failed":
return { return {
type: "error",
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
status: `${DisplayWorkspaceBuildStatusLanguage.failed}`, status: DisplayWorkspaceBuildStatusLanguage.failed,
} }
case "canceling": case "canceling":
return { return {
type: "warning",
color: theme.palette.warning.light, color: theme.palette.warning.light,
status: `${DisplayWorkspaceBuildStatusLanguage.canceling}`, status: DisplayWorkspaceBuildStatusLanguage.canceling,
} }
case "canceled": case "canceled":
return { return {
type: "secondary",
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
status: `${DisplayWorkspaceBuildStatusLanguage.canceled}`, status: DisplayWorkspaceBuildStatusLanguage.canceled,
} }
} }
} }
export const DisplayWorkspaceBuildInitiatedByLanguage = {
autostart: "system/autostart",
autostop: "system/autostop",
}
export const getDisplayWorkspaceBuildInitiatedBy = ( export const getDisplayWorkspaceBuildInitiatedBy = (
build: TypesGen.WorkspaceBuild, build: TypesGen.WorkspaceBuild,
): string => { ): string => {
@ -76,9 +79,8 @@ export const getDisplayWorkspaceBuildInitiatedBy = (
case "initiator": case "initiator":
return build.initiator_name return build.initiator_name
case "autostart": case "autostart":
return DisplayWorkspaceBuildInitiatedByLanguage.autostart
case "autostop": case "autostop":
return DisplayWorkspaceBuildInitiatedByLanguage.autostop return "Coder"
} }
} }