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 { 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 TableContainer from "@material-ui/core/TableContainer"
import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow"
import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight"
import useTheme from "@material-ui/styles/useTheme"
import { FC } from "react"
import { useNavigate, useParams } from "react-router-dom"
import { FC, Fragment } from "react"
import * as TypesGen from "../../api/typesGenerated"
import {
displayWorkspaceBuildDuration,
getDisplayWorkspaceBuildStatus,
} from "../../util/workspace"
import { EmptyState } from "../EmptyState/EmptyState"
import { TableCellLink } from "../TableCellLink/TableCellLink"
import { TableLoader } from "../TableLoader/TableLoader"
import { BuildDateRow } from "./BuildDateRow"
import { BuildRow } from "./BuildRow"
export const Language = {
emptyMessage: "No builds found",
@ -33,75 +25,51 @@ export interface BuildsTableProps {
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>> = ({
builds,
className,
}) => {
const { username, workspace: workspaceName } = useParams()
const isLoading = !builds
const theme: Theme = useTheme()
const navigate = useNavigate()
const styles = useStyles()
const buildsByDate = groupBuildsByDate(builds)
return (
<TableContainer className={className}>
<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>
{isLoading && <TableLoader />}
{builds &&
builds.map((build) => {
const status = getDisplayWorkspaceBuildStatus(theme, build)
const buildPageLink = `/@${username}/${workspaceName}/builds/${build.build_number}`
{buildsByDate &&
Object.keys(buildsByDate).map((dateStr) => {
const builds = buildsByDate[dateStr]
return (
<TableRow
hover
key={build.id}
data-testid={`build-${build.id}`}
tabIndex={0}
onKeyDown={(event) => {
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>
<Fragment key={dateStr}>
<BuildDateRow date={new Date(dateStr)} />
{builds.map((build) => (
<BuildRow key={build.id} build={build} />
))}
</Fragment>
)
})}
@ -119,30 +87,3 @@ export const BuildsTable: FC<React.PropsWithChildren<BuildsTableProps>> = ({
</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: {
display: "flex",
alignItems: "center",
justifyContent: "center",
marginTop: theme.spacing(2),
},
showMoreButton: {

View File

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

View File

@ -38,5 +38,16 @@
"connected": "Connected",
"connecting": "Connecting...",
"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", () => {
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 () => {
const stopWorkspaceMock = jest
.spyOn(api, "stopWorkspace")
@ -288,7 +278,8 @@ describe("WorkspacePage", () => {
// Wait for the results to be loaded
await waitFor(async () => {
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,
reason: "autostart",
},
"system/autostart",
"Coder",
],
[
{
...Mocks.MockWorkspaceBuild,
reason: "autostop",
},
"system/autostop",
"Coder",
],
])(
`getDisplayWorkspaceBuildInitiatedBy(%p) returns %p`,

View File

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