mirror of
https://github.com/coder/coder.git
synced 2026-10-28 02:42:54 +08:00
feat: Redesign the workspace page (#1620)
This commit is contained in:
Vendored
+1
@@ -7,6 +7,7 @@
|
|||||||
"coderd",
|
"coderd",
|
||||||
"coderdtest",
|
"coderdtest",
|
||||||
"codersdk",
|
"codersdk",
|
||||||
|
"cronstrue",
|
||||||
"devel",
|
"devel",
|
||||||
"drpc",
|
"drpc",
|
||||||
"drpcconn",
|
"drpcconn",
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
import { makeStyles } from "@material-ui/core/styles"
|
import { makeStyles } from "@material-ui/core/styles"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
|
||||||
export interface StackProps {
|
type Direction = "column" | "row"
|
||||||
spacing?: number
|
|
||||||
|
interface StyleProps {
|
||||||
|
spacing: number
|
||||||
|
direction: Direction
|
||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
const useStyles = makeStyles((theme) => ({
|
||||||
stack: {
|
stack: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: ({ direction }: StyleProps) => direction,
|
||||||
gap: ({ spacing }: { spacing: number }) => theme.spacing(spacing),
|
gap: ({ spacing }: StyleProps) => theme.spacing(spacing),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export const Stack: React.FC<StackProps> = ({ children, spacing = 2 }) => {
|
export interface StackProps {
|
||||||
const styles = useStyles({ spacing })
|
spacing?: number
|
||||||
|
direction?: Direction
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Stack: React.FC<StackProps> = ({ children, spacing = 2, direction = "column" }) => {
|
||||||
|
const styles = useStyles({ spacing, direction })
|
||||||
return <div className={styles.stack}>{children}</div>
|
return <div className={styles.stack}>{children}</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { makeStyles } from "@material-ui/core/styles"
|
|||||||
import Typography from "@material-ui/core/Typography"
|
import Typography from "@material-ui/core/Typography"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import * as TypesGen from "../../api/typesGenerated"
|
import * as TypesGen from "../../api/typesGenerated"
|
||||||
|
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
|
||||||
import { WorkspaceStatus } from "../../util/workspace"
|
import { WorkspaceStatus } from "../../util/workspace"
|
||||||
import { BuildsTable } from "../BuildsTable/BuildsTable"
|
import { BuildsTable } from "../BuildsTable/BuildsTable"
|
||||||
import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule"
|
import { Stack } from "../Stack/Stack"
|
||||||
|
import { WorkspaceActions } from "../WorkspaceActions/WorkspaceActions"
|
||||||
import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection"
|
import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection"
|
||||||
import { WorkspaceStatusBar } from "../WorkspaceStatusBar/WorkspaceStatusBar"
|
import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats"
|
||||||
|
|
||||||
export interface WorkspaceProps {
|
export interface WorkspaceProps {
|
||||||
handleStart: () => void
|
handleStart: () => void
|
||||||
@@ -34,76 +36,62 @@ export const Workspace: React.FC<WorkspaceProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<div className={styles.vertical}>
|
<div className={styles.header}>
|
||||||
<WorkspaceStatusBar
|
<div>
|
||||||
workspace={workspace}
|
<Typography variant="h4" className={styles.title}>
|
||||||
handleStart={handleStart}
|
{workspace.name}
|
||||||
handleStop={handleStop}
|
</Typography>
|
||||||
handleRetry={handleRetry}
|
|
||||||
handleUpdate={handleUpdate}
|
|
||||||
workspaceStatus={workspaceStatus}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={styles.horizontal}>
|
<Typography color="textSecondary" className={styles.subtitle}>
|
||||||
<div className={styles.sidebarContainer}>
|
{workspace.owner_name}
|
||||||
<WorkspaceSection title="Applications">
|
</Typography>
|
||||||
<Placeholder />
|
</div>
|
||||||
</WorkspaceSection>
|
|
||||||
<WorkspaceSchedule workspace={workspace} />
|
|
||||||
|
|
||||||
<WorkspaceSection title="Dev URLs">
|
<div className={styles.headerActions}>
|
||||||
<Placeholder />
|
<WorkspaceActions
|
||||||
</WorkspaceSection>
|
workspace={workspace}
|
||||||
|
handleStart={handleStart}
|
||||||
<WorkspaceSection title="Resources">
|
handleStop={handleStop}
|
||||||
<Placeholder />
|
handleRetry={handleRetry}
|
||||||
</WorkspaceSection>
|
handleUpdate={handleUpdate}
|
||||||
</div>
|
workspaceStatus={workspaceStatus}
|
||||||
|
/>
|
||||||
<div className={styles.timelineContainer}>
|
|
||||||
<WorkspaceSection title="Timeline" contentsProps={{ className: styles.timelineContents }}>
|
|
||||||
<BuildsTable builds={builds} className={styles.timelineTable} />
|
|
||||||
</WorkspaceSection>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<WorkspaceStats workspace={workspace} />
|
||||||
|
<WorkspaceSection title="Timeline" contentsProps={{ className: styles.timelineContents }}>
|
||||||
|
<BuildsTable builds={builds} className={styles.timelineTable} />
|
||||||
|
</WorkspaceSection>
|
||||||
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const useStyles = makeStyles((theme) => {
|
||||||
* Temporary placeholder component until we have the sections implemented
|
|
||||||
* Can be removed once the Workspace page has all the necessary sections
|
|
||||||
*/
|
|
||||||
const Placeholder: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<div style={{ textAlign: "center", opacity: "0.5" }}>
|
|
||||||
<Typography variant="caption">Not yet implemented</Typography>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useStyles = makeStyles(() => {
|
|
||||||
return {
|
return {
|
||||||
root: {
|
root: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
},
|
},
|
||||||
horizontal: {
|
header: {
|
||||||
|
paddingTop: theme.spacing(5),
|
||||||
|
paddingBottom: theme.spacing(5),
|
||||||
|
fontFamily: MONOSPACE_FONT_FAMILY,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "row",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
vertical: {
|
headerActions: {
|
||||||
display: "flex",
|
marginLeft: "auto",
|
||||||
flexDirection: "column",
|
|
||||||
},
|
},
|
||||||
sidebarContainer: {
|
title: {
|
||||||
display: "flex",
|
fontWeight: 600,
|
||||||
flexDirection: "column",
|
fontFamily: "inherit",
|
||||||
flex: "0 0 350px",
|
|
||||||
},
|
},
|
||||||
timelineContainer: {
|
subtitle: {
|
||||||
flex: 1,
|
fontFamily: "inherit",
|
||||||
|
marginTop: theme.spacing(0.5),
|
||||||
},
|
},
|
||||||
timelineContents: {
|
timelineContents: {
|
||||||
margin: 0,
|
margin: 0,
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import PlayArrowRoundedIcon from "@material-ui/icons/PlayArrowRounded"
|
||||||
|
import { ComponentMeta, Story } from "@storybook/react"
|
||||||
|
import React from "react"
|
||||||
|
import { WorkspaceActionButton, WorkspaceActionButtonProps } from "./WorkspaceActionButton"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "components/WorkspaceActionButton",
|
||||||
|
component: WorkspaceActionButton,
|
||||||
|
} as ComponentMeta<typeof WorkspaceActionButton>
|
||||||
|
|
||||||
|
const Template: Story<WorkspaceActionButtonProps> = (args) => <WorkspaceActionButton {...args} />
|
||||||
|
|
||||||
|
export const Example = Template.bind({})
|
||||||
|
Example.args = {
|
||||||
|
icon: <PlayArrowRoundedIcon />,
|
||||||
|
label: "Start workspace",
|
||||||
|
loadingLabel: "Starting workspace",
|
||||||
|
isLoading: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Loading = Template.bind({})
|
||||||
|
Loading.args = {
|
||||||
|
icon: <PlayArrowRoundedIcon />,
|
||||||
|
label: "Start workspace",
|
||||||
|
loadingLabel: "Starting workspace",
|
||||||
|
isLoading: true,
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import Button from "@material-ui/core/Button"
|
||||||
|
import CircularProgress from "@material-ui/core/CircularProgress"
|
||||||
|
import { makeStyles } from "@material-ui/core/styles"
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
|
export interface WorkspaceActionButtonProps {
|
||||||
|
label: string
|
||||||
|
loadingLabel: string
|
||||||
|
isLoading: boolean
|
||||||
|
icon: JSX.Element
|
||||||
|
onClick: () => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspaceActionButton: React.FC<WorkspaceActionButtonProps> = ({
|
||||||
|
label,
|
||||||
|
loadingLabel,
|
||||||
|
isLoading,
|
||||||
|
icon,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const styles = useStyles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={className}
|
||||||
|
startIcon={isLoading ? <CircularProgress size={12} className={styles.spinner} /> : icon}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? loadingLabel : label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
spinner: {
|
||||||
|
color: theme.palette.text.disabled,
|
||||||
|
marginRight: theme.spacing(1),
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import Button from "@material-ui/core/Button"
|
||||||
|
import Link from "@material-ui/core/Link"
|
||||||
|
import { makeStyles } from "@material-ui/core/styles"
|
||||||
|
import CloudDownloadIcon from "@material-ui/icons/CloudDownload"
|
||||||
|
import PlayArrowRoundedIcon from "@material-ui/icons/PlayArrowRounded"
|
||||||
|
import ReplayIcon from "@material-ui/icons/Replay"
|
||||||
|
import StopIcon from "@material-ui/icons/Stop"
|
||||||
|
import React from "react"
|
||||||
|
import { Link as RouterLink } from "react-router-dom"
|
||||||
|
import { Workspace } from "../../api/typesGenerated"
|
||||||
|
import { WorkspaceStatus } from "../../util/workspace"
|
||||||
|
import { Stack } from "../Stack/Stack"
|
||||||
|
import { WorkspaceActionButton } from "../WorkspaceActionButton/WorkspaceActionButton"
|
||||||
|
|
||||||
|
export const Language = {
|
||||||
|
stop: "Stop workspace",
|
||||||
|
stopping: "Stopping workspace",
|
||||||
|
start: "Start workspace",
|
||||||
|
starting: "Starting workspace",
|
||||||
|
retry: "Retry",
|
||||||
|
update: "Update workspace",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jobs submitted while another job is in progress will be discarded,
|
||||||
|
* so check whether workspace job status has reached completion (whether successful or not).
|
||||||
|
*/
|
||||||
|
const canAcceptJobs = (workspaceStatus: WorkspaceStatus) =>
|
||||||
|
["started", "stopped", "deleted", "error", "canceled"].includes(workspaceStatus)
|
||||||
|
|
||||||
|
export interface WorkspaceActionsProps {
|
||||||
|
workspace: Workspace
|
||||||
|
workspaceStatus: WorkspaceStatus
|
||||||
|
handleStart: () => void
|
||||||
|
handleStop: () => void
|
||||||
|
handleRetry: () => void
|
||||||
|
handleUpdate: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspaceActions: React.FC<WorkspaceActionsProps> = ({
|
||||||
|
workspace,
|
||||||
|
workspaceStatus,
|
||||||
|
handleStart,
|
||||||
|
handleStop,
|
||||||
|
handleRetry,
|
||||||
|
handleUpdate,
|
||||||
|
}) => {
|
||||||
|
const styles = useStyles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Link underline="none" component={RouterLink} to="edit">
|
||||||
|
<Button variant="outlined">Settings</Button>
|
||||||
|
</Link>
|
||||||
|
{(workspaceStatus === "started" || workspaceStatus === "stopping") && (
|
||||||
|
<WorkspaceActionButton
|
||||||
|
className={styles.actionButton}
|
||||||
|
icon={<StopIcon />}
|
||||||
|
onClick={handleStop}
|
||||||
|
label={Language.stop}
|
||||||
|
loadingLabel={Language.stopping}
|
||||||
|
isLoading={workspaceStatus === "stopping"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(workspaceStatus === "stopped" || workspaceStatus === "starting") && (
|
||||||
|
<WorkspaceActionButton
|
||||||
|
className={styles.actionButton}
|
||||||
|
icon={<PlayArrowRoundedIcon />}
|
||||||
|
onClick={handleStart}
|
||||||
|
label={Language.start}
|
||||||
|
loadingLabel={Language.starting}
|
||||||
|
isLoading={workspaceStatus === "starting"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{workspaceStatus === "error" && (
|
||||||
|
<Button className={styles.actionButton} startIcon={<ReplayIcon />} onClick={handleRetry}>
|
||||||
|
{Language.retry}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{workspace.outdated && canAcceptJobs(workspaceStatus) && (
|
||||||
|
<Button className={styles.actionButton} startIcon={<CloudDownloadIcon />} onClick={handleUpdate}>
|
||||||
|
{Language.update}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
actionButton: {
|
||||||
|
// Set fixed width for the action buttons so they will not change the size
|
||||||
|
// during the transitions
|
||||||
|
width: theme.spacing(30),
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -3,7 +3,7 @@ import { makeStyles, useTheme } from "@material-ui/core/styles"
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import { Link as RouterLink } from "react-router-dom"
|
import { Link as RouterLink } from "react-router-dom"
|
||||||
import { WorkspaceBuild } from "../../api/typesGenerated"
|
import { WorkspaceBuild } from "../../api/typesGenerated"
|
||||||
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
|
import { CardRadius, MONOSPACE_FONT_FAMILY } from "../../theme/constants"
|
||||||
import { combineClasses } from "../../util/combineClasses"
|
import { combineClasses } from "../../util/combineClasses"
|
||||||
import { displayWorkspaceBuildDuration, getDisplayStatus } from "../../util/workspace"
|
import { displayWorkspaceBuildDuration, getDisplayStatus } from "../../util/workspace"
|
||||||
|
|
||||||
@@ -57,17 +57,21 @@ export const WorkspaceBuildStats: React.FC<WorkspaceBuildStatsProps> = ({ build
|
|||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
const useStyles = makeStyles((theme) => ({
|
||||||
stats: {
|
stats: {
|
||||||
paddingTop: theme.spacing(3),
|
paddingLeft: theme.spacing(2),
|
||||||
paddingBottom: theme.spacing(3),
|
paddingRight: theme.spacing(2),
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
borderRadius: CardRadius,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
fontFamily: MONOSPACE_FONT_FAMILY,
|
fontFamily: MONOSPACE_FONT_FAMILY,
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
statItem: {
|
statItem: {
|
||||||
minWidth: theme.spacing(20),
|
minWidth: theme.spacing(20),
|
||||||
paddingRight: theme.spacing(3),
|
padding: theme.spacing(2),
|
||||||
|
paddingTop: theme.spacing(1.75),
|
||||||
},
|
},
|
||||||
|
|
||||||
statsLabel: {
|
statsLabel: {
|
||||||
@@ -80,14 +84,14 @@ const useStyles = makeStyles((theme) => ({
|
|||||||
statsValue: {
|
statsValue: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
marginTop: theme.spacing(0.25),
|
marginTop: theme.spacing(0.25),
|
||||||
display: "block",
|
display: "inline-block",
|
||||||
},
|
},
|
||||||
|
|
||||||
statsDivider: {
|
statsDivider: {
|
||||||
width: 1,
|
width: 1,
|
||||||
height: theme.spacing(5),
|
height: theme.spacing(5),
|
||||||
backgroundColor: theme.palette.divider,
|
backgroundColor: theme.palette.divider,
|
||||||
marginRight: theme.spacing(3),
|
marginRight: theme.spacing(2),
|
||||||
},
|
},
|
||||||
|
|
||||||
capitalize: {
|
capitalize: {
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import Box from "@material-ui/core/Box"
|
|
||||||
import Typography from "@material-ui/core/Typography"
|
|
||||||
import cronstrue from "cronstrue"
|
|
||||||
import dayjs from "dayjs"
|
|
||||||
import duration from "dayjs/plugin/duration"
|
|
||||||
import relativeTime from "dayjs/plugin/relativeTime"
|
|
||||||
import React from "react"
|
|
||||||
import * as TypesGen from "../../api/typesGenerated"
|
|
||||||
import { extractTimezone, stripTimezone } from "../../util/schedule"
|
|
||||||
import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection"
|
|
||||||
|
|
||||||
dayjs.extend(duration)
|
|
||||||
dayjs.extend(relativeTime)
|
|
||||||
|
|
||||||
const Language = {
|
|
||||||
autoStartLabel: (schedule: string): string => {
|
|
||||||
const prefix = "Start"
|
|
||||||
|
|
||||||
if (schedule) {
|
|
||||||
return `${prefix} (${extractTimezone(schedule)})`
|
|
||||||
} else {
|
|
||||||
return prefix
|
|
||||||
}
|
|
||||||
},
|
|
||||||
autoStartDisplay: (schedule: string): string => {
|
|
||||||
if (schedule) {
|
|
||||||
return cronstrue.toString(stripTimezone(schedule), { throwExceptionOnParseError: false })
|
|
||||||
}
|
|
||||||
return "Manual"
|
|
||||||
},
|
|
||||||
autoStopLabel: "Shutdown",
|
|
||||||
autoStopDisplay: (workspace: TypesGen.Workspace): string => {
|
|
||||||
const latest = workspace.latest_build
|
|
||||||
|
|
||||||
if (!workspace.ttl || workspace.ttl < 1) {
|
|
||||||
return "Manual"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (latest.transition === "start") {
|
|
||||||
const now = dayjs()
|
|
||||||
const updatedAt = dayjs(latest.updated_at)
|
|
||||||
const deadline = updatedAt.add(workspace.ttl / 1_000_000, "ms")
|
|
||||||
if (now.isAfter(deadline)) {
|
|
||||||
return "workspace is shutting down now"
|
|
||||||
}
|
|
||||||
return now.to(deadline)
|
|
||||||
}
|
|
||||||
|
|
||||||
const duration = dayjs.duration(workspace.ttl / 1_000_000, "milliseconds")
|
|
||||||
return `${duration.humanize()} after start`
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkspaceScheduleProps {
|
|
||||||
workspace: TypesGen.Workspace
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WorkspaceSchedule displays a workspace schedule in a human-readable format
|
|
||||||
*
|
|
||||||
* @remarks Visual Component
|
|
||||||
*/
|
|
||||||
export const WorkspaceSchedule: React.FC<WorkspaceScheduleProps> = ({ workspace }) => {
|
|
||||||
return (
|
|
||||||
<WorkspaceSection title="Workspace schedule">
|
|
||||||
<Box mt={2}>
|
|
||||||
<Typography variant="h6">{Language.autoStartLabel(workspace.autostart_schedule)}</Typography>
|
|
||||||
<Typography>{Language.autoStartDisplay(workspace.autostart_schedule)}</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box mt={2}>
|
|
||||||
<Typography variant="h6">{Language.autoStopLabel}</Typography>
|
|
||||||
<Typography data-chromatic="ignore">{Language.autoStopDisplay(workspace)}</Typography>
|
|
||||||
</Box>
|
|
||||||
</WorkspaceSection>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -39,7 +39,6 @@ const useStyles = makeStyles((theme) => ({
|
|||||||
root: {
|
root: {
|
||||||
border: `1px solid ${theme.palette.divider}`,
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
borderRadius: CardRadius,
|
borderRadius: CardRadius,
|
||||||
margin: theme.spacing(1),
|
|
||||||
},
|
},
|
||||||
headerContainer: {
|
headerContainer: {
|
||||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||||
|
|||||||
+4
-4
@@ -2,14 +2,14 @@ import { Story } from "@storybook/react"
|
|||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import * as Mocks from "../../testHelpers/renderHelpers"
|
import * as Mocks from "../../testHelpers/renderHelpers"
|
||||||
import { WorkspaceSchedule, WorkspaceScheduleProps } from "./WorkspaceSchedule"
|
import { WorkspaceStats, WorkspaceStatsProps } from "../WorkspaceStats/WorkspaceStats"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "components/WorkspaceSchedule",
|
title: "components/WorkspaceStats",
|
||||||
component: WorkspaceSchedule,
|
component: WorkspaceStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
const Template: Story<WorkspaceScheduleProps> = (args) => <WorkspaceSchedule {...args} />
|
const Template: Story<WorkspaceStatsProps> = (args) => <WorkspaceStats {...args} />
|
||||||
|
|
||||||
export const NoTTL = Template.bind({})
|
export const NoTTL = Template.bind({})
|
||||||
NoTTL.args = {
|
NoTTL.args = {
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import Link from "@material-ui/core/Link"
|
||||||
|
import { makeStyles, useTheme } from "@material-ui/core/styles"
|
||||||
|
import cronstrue from "cronstrue"
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
import duration from "dayjs/plugin/duration"
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime"
|
||||||
|
import React from "react"
|
||||||
|
import { Link as RouterLink } from "react-router-dom"
|
||||||
|
import { Workspace } from "../../api/typesGenerated"
|
||||||
|
import { CardRadius, MONOSPACE_FONT_FAMILY } from "../../theme/constants"
|
||||||
|
import { combineClasses } from "../../util/combineClasses"
|
||||||
|
import { extractTimezone, stripTimezone } from "../../util/schedule"
|
||||||
|
import { getDisplayStatus } from "../../util/workspace"
|
||||||
|
|
||||||
|
dayjs.extend(duration)
|
||||||
|
dayjs.extend(relativeTime)
|
||||||
|
|
||||||
|
const autoStartLabel = (schedule: string): string => {
|
||||||
|
const prefix = "Start"
|
||||||
|
|
||||||
|
if (schedule) {
|
||||||
|
return `${prefix} (${extractTimezone(schedule)})`
|
||||||
|
} else {
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoStartDisplay = (schedule: string): string => {
|
||||||
|
if (schedule) {
|
||||||
|
return cronstrue.toString(stripTimezone(schedule), { throwExceptionOnParseError: false })
|
||||||
|
}
|
||||||
|
return "Manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoStopDisplay = (workspace: Workspace): string => {
|
||||||
|
const latest = workspace.latest_build
|
||||||
|
|
||||||
|
if (!workspace.ttl || workspace.ttl < 1) {
|
||||||
|
return "Manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latest.transition === "start") {
|
||||||
|
const now = dayjs()
|
||||||
|
const updatedAt = dayjs(latest.updated_at)
|
||||||
|
const deadline = updatedAt.add(workspace.ttl / 1_000_000, "ms")
|
||||||
|
if (now.isAfter(deadline)) {
|
||||||
|
return "workspace is shutting down now"
|
||||||
|
}
|
||||||
|
return now.to(deadline)
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = dayjs.duration(workspace.ttl / 1_000_000, "milliseconds")
|
||||||
|
return `${duration.humanize()} after start`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceStatsProps {
|
||||||
|
workspace: Workspace
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspaceStats: React.FC<WorkspaceStatsProps> = ({ workspace }) => {
|
||||||
|
const styles = useStyles()
|
||||||
|
const theme = useTheme()
|
||||||
|
const status = getDisplayStatus(theme, workspace.latest_build)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.stats}>
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<span className={styles.statsLabel}>Workspace</span>
|
||||||
|
<Link
|
||||||
|
component={RouterLink}
|
||||||
|
to={`/templates/${workspace.template_name}`}
|
||||||
|
className={combineClasses([styles.statsValue, styles.link])}
|
||||||
|
>
|
||||||
|
{workspace.template_name}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsDivider} />
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<span className={styles.statsLabel}>Status</span>
|
||||||
|
<span className={styles.statsValue}>
|
||||||
|
<span style={{ color: status.color }} role="status">
|
||||||
|
{status.status}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsDivider} />
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<span className={styles.statsLabel}>Version</span>
|
||||||
|
<span className={styles.statsValue}>
|
||||||
|
{workspace.outdated ? (
|
||||||
|
<span style={{ color: theme.palette.error.main }}>outdated</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: theme.palette.text.secondary }}>up to date</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsDivider} />
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<span className={styles.statsLabel}>Last Built</span>
|
||||||
|
<span className={styles.statsValue}>{dayjs().to(dayjs(workspace.latest_build.created_at))}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsDivider} />
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<span className={styles.statsLabel}>{autoStartLabel(workspace.autostart_schedule)}</span>
|
||||||
|
<span className={styles.statsValue}>{autoStartDisplay(workspace.autostart_schedule)}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsDivider} />
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<span className={styles.statsLabel}>Shutdown</span>
|
||||||
|
<span className={styles.statsValue}>{autoStopDisplay(workspace)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
stats: {
|
||||||
|
paddingLeft: theme.spacing(2),
|
||||||
|
paddingRight: theme.spacing(2),
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
borderRadius: CardRadius,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
fontFamily: MONOSPACE_FONT_FAMILY,
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
statItem: {
|
||||||
|
minWidth: theme.spacing(20),
|
||||||
|
padding: theme.spacing(2),
|
||||||
|
paddingTop: theme.spacing(1.75),
|
||||||
|
},
|
||||||
|
|
||||||
|
statsLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
display: "block",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
|
||||||
|
statsValue: {
|
||||||
|
fontSize: 16,
|
||||||
|
marginTop: theme.spacing(0.25),
|
||||||
|
display: "inline-block",
|
||||||
|
},
|
||||||
|
|
||||||
|
statsDivider: {
|
||||||
|
width: 1,
|
||||||
|
height: theme.spacing(5),
|
||||||
|
backgroundColor: theme.palette.divider,
|
||||||
|
marginRight: theme.spacing(2),
|
||||||
|
},
|
||||||
|
|
||||||
|
capitalize: {
|
||||||
|
textTransform: "capitalize",
|
||||||
|
},
|
||||||
|
|
||||||
|
link: {
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import Box from "@material-ui/core/Box"
|
|
||||||
import Button from "@material-ui/core/Button"
|
|
||||||
import { makeStyles } from "@material-ui/core/styles"
|
|
||||||
import Typography from "@material-ui/core/Typography"
|
|
||||||
import React from "react"
|
|
||||||
import { Link } from "react-router-dom"
|
|
||||||
import * as TypesGen from "../../api/typesGenerated"
|
|
||||||
import { TitleIconSize } from "../../theme/constants"
|
|
||||||
import { combineClasses } from "../../util/combineClasses"
|
|
||||||
import { WorkspaceStatus } from "../../util/workspace"
|
|
||||||
import { Stack } from "../Stack/Stack"
|
|
||||||
import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection"
|
|
||||||
|
|
||||||
export const Language = {
|
|
||||||
stop: "Stop",
|
|
||||||
start: "Start",
|
|
||||||
retry: "Retry",
|
|
||||||
update: "Update",
|
|
||||||
settings: "Settings",
|
|
||||||
started: "Running",
|
|
||||||
stopped: "Stopped",
|
|
||||||
starting: "Building",
|
|
||||||
stopping: "Stopping",
|
|
||||||
canceled: "Canceled",
|
|
||||||
queued: "Queued",
|
|
||||||
error: "Build Failed",
|
|
||||||
loading: "Loading Status",
|
|
||||||
deleting: "Deleting",
|
|
||||||
deleted: "Deleted",
|
|
||||||
// "Canceling" would be misleading because it refers to a build, not the workspace.
|
|
||||||
// So just stall. When it is canceled it will appear as the error workspaceStatus.
|
|
||||||
canceling: "Loading Status",
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkspaceStatusBarProps {
|
|
||||||
organization?: TypesGen.Organization
|
|
||||||
workspace: TypesGen.Workspace
|
|
||||||
template?: TypesGen.Template
|
|
||||||
handleStart: () => void
|
|
||||||
handleStop: () => void
|
|
||||||
handleRetry: () => void
|
|
||||||
handleUpdate: () => void
|
|
||||||
workspaceStatus: WorkspaceStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Jobs submitted while another job is in progress will be discarded,
|
|
||||||
* so check whether workspace job status has reached completion (whether successful or not).
|
|
||||||
*/
|
|
||||||
const canAcceptJobs = (workspaceStatus: WorkspaceStatus) =>
|
|
||||||
["started", "stopped", "deleted", "error", "canceled"].includes(workspaceStatus)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component for the header at the top of the workspace page
|
|
||||||
*/
|
|
||||||
export const WorkspaceStatusBar: React.FC<WorkspaceStatusBarProps> = ({
|
|
||||||
workspace,
|
|
||||||
handleStart,
|
|
||||||
handleStop,
|
|
||||||
handleRetry,
|
|
||||||
handleUpdate,
|
|
||||||
workspaceStatus,
|
|
||||||
}) => {
|
|
||||||
const styles = useStyles()
|
|
||||||
|
|
||||||
const settingsLink = "edit"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WorkspaceSection>
|
|
||||||
<Stack spacing={1}>
|
|
||||||
<div className={combineClasses([styles.horizontal, styles.reverse])}>
|
|
||||||
<div className={styles.horizontal}>
|
|
||||||
<Link className={styles.link} to={settingsLink}>
|
|
||||||
{Language.settings}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.horizontal}>
|
|
||||||
<div className={styles.horizontal}>
|
|
||||||
<Typography variant="h4">{workspace.name}</Typography>
|
|
||||||
<Box className={styles.statusChip} role="status">
|
|
||||||
{Language[workspaceStatus]}
|
|
||||||
</Box>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.horizontal}>
|
|
||||||
{workspaceStatus === "started" && (
|
|
||||||
<Button onClick={handleStop} color="primary">
|
|
||||||
{Language.stop}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{workspaceStatus === "stopped" && (
|
|
||||||
<Button onClick={handleStart} color="primary">
|
|
||||||
{Language.start}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{workspaceStatus === "error" && (
|
|
||||||
<Button onClick={handleRetry} color="primary">
|
|
||||||
{Language.retry}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{workspace.outdated && canAcceptJobs(workspaceStatus) && (
|
|
||||||
<Button onClick={handleUpdate} color="primary">
|
|
||||||
{Language.update}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
</WorkspaceSection>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
|
||||||
return {
|
|
||||||
link: {
|
|
||||||
textDecoration: "none",
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
},
|
|
||||||
icon: {
|
|
||||||
width: TitleIconSize,
|
|
||||||
height: TitleIconSize,
|
|
||||||
},
|
|
||||||
horizontal: {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing(2),
|
|
||||||
},
|
|
||||||
reverse: {
|
|
||||||
flexDirection: "row-reverse",
|
|
||||||
},
|
|
||||||
statusChip: {
|
|
||||||
border: `solid 1px ${theme.palette.text.hint}`,
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
padding: theme.spacing(1),
|
|
||||||
},
|
|
||||||
vertical: {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -3,7 +3,7 @@ import { rest } from "msw"
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import * as api from "../../api/api"
|
import * as api from "../../api/api"
|
||||||
import { Workspace } from "../../api/typesGenerated"
|
import { Workspace } from "../../api/typesGenerated"
|
||||||
import { Language } from "../../components/WorkspaceStatusBar/WorkspaceStatusBar"
|
import { Language } from "../../components/WorkspaceActions/WorkspaceActions"
|
||||||
import {
|
import {
|
||||||
MockBuilds,
|
MockBuilds,
|
||||||
MockCancelingWorkspace,
|
MockCancelingWorkspace,
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
renderWithAuth,
|
renderWithAuth,
|
||||||
} from "../../testHelpers/renderHelpers"
|
} from "../../testHelpers/renderHelpers"
|
||||||
import { server } from "../../testHelpers/server"
|
import { server } from "../../testHelpers/server"
|
||||||
|
import { DisplayStatusLanguage } from "../../util/workspace"
|
||||||
import { WorkspacePage } from "./WorkspacePage"
|
import { WorkspacePage } from "./WorkspacePage"
|
||||||
|
|
||||||
// It renders the workspace page and waits for it be loaded
|
// It renders the workspace page and waits for it be loaded
|
||||||
@@ -133,28 +134,28 @@ describe("Workspace Page", () => {
|
|||||||
await testButton(Language.update, getTemplateMock)
|
await testButton(Language.update, getTemplateMock)
|
||||||
})
|
})
|
||||||
it("shows the Stopping status when the workspace is stopping", async () => {
|
it("shows the Stopping status when the workspace is stopping", async () => {
|
||||||
await testStatus(MockStoppingWorkspace, Language.stopping)
|
await testStatus(MockStoppingWorkspace, DisplayStatusLanguage.stopping)
|
||||||
})
|
})
|
||||||
it("shows the Stopped status when the workspace is stopped", async () => {
|
it("shows the Stopped status when the workspace is stopped", async () => {
|
||||||
await testStatus(MockStoppedWorkspace, Language.stopped)
|
await testStatus(MockStoppedWorkspace, DisplayStatusLanguage.stopped)
|
||||||
})
|
})
|
||||||
it("shows the Building status when the workspace is starting", async () => {
|
it("shows the Building status when the workspace is starting", async () => {
|
||||||
await testStatus(MockStartingWorkspace, Language.starting)
|
await testStatus(MockStartingWorkspace, DisplayStatusLanguage.starting)
|
||||||
})
|
})
|
||||||
it("shows the Running status when the workspace is started", async () => {
|
it("shows the Running status when the workspace is started", async () => {
|
||||||
await testStatus(MockWorkspace, Language.started)
|
await testStatus(MockWorkspace, DisplayStatusLanguage.started)
|
||||||
})
|
})
|
||||||
it("shows the Error status when the workspace is failed or canceled", async () => {
|
it("shows the Failed status when the workspace is failed or canceled", async () => {
|
||||||
await testStatus(MockFailedWorkspace, Language.error)
|
await testStatus(MockFailedWorkspace, DisplayStatusLanguage.failed)
|
||||||
})
|
})
|
||||||
it("shows the Loading status when the workspace is canceling", async () => {
|
it("shows the Canceling status when the workspace is canceling", async () => {
|
||||||
await testStatus(MockCancelingWorkspace, Language.canceling)
|
await testStatus(MockCancelingWorkspace, DisplayStatusLanguage.canceling)
|
||||||
})
|
})
|
||||||
it("shows the Deleting status when the workspace is deleting", async () => {
|
it("shows the Deleting status when the workspace is deleting", async () => {
|
||||||
await testStatus(MockDeletingWorkspace, Language.deleting)
|
await testStatus(MockDeletingWorkspace, DisplayStatusLanguage.deleting)
|
||||||
})
|
})
|
||||||
it("shows the Deleted status when the workspace is deleted", async () => {
|
it("shows the Deleted status when the workspace is deleted", async () => {
|
||||||
await testStatus(MockDeletedWorkspace, Language.deleted)
|
await testStatus(MockDeletedWorkspace, DisplayStatusLanguage.deleted)
|
||||||
})
|
})
|
||||||
it("shows the timeline build", async () => {
|
it("shows the timeline build", async () => {
|
||||||
await renderWorkspacePage()
|
await renderWorkspacePage()
|
||||||
|
|||||||
@@ -10,5 +10,5 @@ export const navHeight = 42
|
|||||||
export const maxWidth = 1380
|
export const maxWidth = 1380
|
||||||
export const sidePadding = "50px"
|
export const sidePadding = "50px"
|
||||||
export const TitleIconSize = 48
|
export const TitleIconSize = 48
|
||||||
export const CardRadius = 8
|
export const CardRadius = 2
|
||||||
export const CardPadding = 20
|
export const CardPadding = 20
|
||||||
|
|||||||
+25
-11
@@ -50,6 +50,20 @@ export const getWorkspaceStatus = (workspaceBuild?: WorkspaceBuild): WorkspaceSt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DisplayStatusLanguage = {
|
||||||
|
loading: "Loading...",
|
||||||
|
started: "Running",
|
||||||
|
starting: "Starting",
|
||||||
|
stopping: "Stopping",
|
||||||
|
stopped: "Stopped",
|
||||||
|
deleting: "Deleting",
|
||||||
|
deleted: "Deleted",
|
||||||
|
canceling: "Canceling",
|
||||||
|
canceled: "Canceled",
|
||||||
|
failed: "Failed",
|
||||||
|
queued: "Queued",
|
||||||
|
}
|
||||||
|
|
||||||
export const getDisplayStatus = (
|
export const getDisplayStatus = (
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
build: WorkspaceBuild,
|
build: WorkspaceBuild,
|
||||||
@@ -62,57 +76,57 @@ export const getDisplayStatus = (
|
|||||||
case undefined:
|
case undefined:
|
||||||
return {
|
return {
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
status: "Loading...",
|
status: DisplayStatusLanguage.loading,
|
||||||
}
|
}
|
||||||
case "started":
|
case "started":
|
||||||
return {
|
return {
|
||||||
color: theme.palette.success.main,
|
color: theme.palette.success.main,
|
||||||
status: "⦿ Running",
|
status: `⦿ ${DisplayStatusLanguage.started}`,
|
||||||
}
|
}
|
||||||
case "starting":
|
case "starting":
|
||||||
return {
|
return {
|
||||||
color: theme.palette.success.main,
|
color: theme.palette.success.main,
|
||||||
status: "⦿ Starting",
|
status: `⦿ ${DisplayStatusLanguage.starting}`,
|
||||||
}
|
}
|
||||||
case "stopping":
|
case "stopping":
|
||||||
return {
|
return {
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
status: "◍ Stopping",
|
status: `◍ ${DisplayStatusLanguage.stopping}`,
|
||||||
}
|
}
|
||||||
case "stopped":
|
case "stopped":
|
||||||
return {
|
return {
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
status: "◍ Stopped",
|
status: `◍ ${DisplayStatusLanguage.stopped}`,
|
||||||
}
|
}
|
||||||
case "deleting":
|
case "deleting":
|
||||||
return {
|
return {
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
status: "⦸ Deleting",
|
status: `⦸ ${DisplayStatusLanguage.deleting}`,
|
||||||
}
|
}
|
||||||
case "deleted":
|
case "deleted":
|
||||||
return {
|
return {
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
status: "⦸ Deleted",
|
status: `⦸ ${DisplayStatusLanguage.deleted}`,
|
||||||
}
|
}
|
||||||
case "canceling":
|
case "canceling":
|
||||||
return {
|
return {
|
||||||
color: theme.palette.warning.light,
|
color: theme.palette.warning.light,
|
||||||
status: "◍ Canceling",
|
status: `◍ ${DisplayStatusLanguage.canceling}`,
|
||||||
}
|
}
|
||||||
case "canceled":
|
case "canceled":
|
||||||
return {
|
return {
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
status: "◍ Canceled",
|
status: `◍ ${DisplayStatusLanguage.canceled}`,
|
||||||
}
|
}
|
||||||
case "error":
|
case "error":
|
||||||
return {
|
return {
|
||||||
color: theme.palette.error.main,
|
color: theme.palette.error.main,
|
||||||
status: "ⓧ Failed",
|
status: `ⓧ ${DisplayStatusLanguage.failed}`,
|
||||||
}
|
}
|
||||||
case "queued":
|
case "queued":
|
||||||
return {
|
return {
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
status: "◍ Queued",
|
status: `◍ ${DisplayStatusLanguage.queued}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error("unknown status " + status)
|
throw new Error("unknown status " + status)
|
||||||
|
|||||||
Reference in New Issue
Block a user