mirror of
https://github.com/coder/coder.git
synced 2025-07-09 11:45:56 +00:00
feat: Redesign build logs (#4734)
This commit is contained in:
71
site/src/components/BuildsTable/BuildAvatar.tsx
Normal file
71
site/src/components/BuildsTable/BuildAvatar.tsx
Normal 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>
|
||||
)
|
||||
}
|
46
site/src/components/BuildsTable/BuildDateRow.tsx
Normal file
46
site/src/components/BuildsTable/BuildDateRow.tsx
Normal 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",
|
||||
},
|
||||
}))
|
144
site/src/components/BuildsTable/BuildRow.tsx
Normal file
144
site/src/components/BuildsTable/BuildRow.tsx
Normal 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",
|
||||
},
|
||||
}))
|
@ -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",
|
||||
},
|
||||
}))
|
||||
|
@ -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: {
|
||||
|
@ -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] ? (
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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`,
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user