mirror of
https://github.com/coder/coder.git
synced 2026-09-24 06:47:27 +08:00
feat: Redesign the workspace page (#1620)
This commit is contained in:
Vendored
+1
@@ -7,6 +7,7 @@
|
||||
"coderd",
|
||||
"coderdtest",
|
||||
"codersdk",
|
||||
"cronstrue",
|
||||
"devel",
|
||||
"drpc",
|
||||
"drpcconn",
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import React from "react"
|
||||
|
||||
export interface StackProps {
|
||||
spacing?: number
|
||||
type Direction = "column" | "row"
|
||||
|
||||
interface StyleProps {
|
||||
spacing: number
|
||||
direction: Direction
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
stack: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: ({ spacing }: { spacing: number }) => theme.spacing(spacing),
|
||||
flexDirection: ({ direction }: StyleProps) => direction,
|
||||
gap: ({ spacing }: StyleProps) => theme.spacing(spacing),
|
||||
},
|
||||
}))
|
||||
|
||||
export const Stack: React.FC<StackProps> = ({ children, spacing = 2 }) => {
|
||||
const styles = useStyles({ spacing })
|
||||
export interface StackProps {
|
||||
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>
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ import { makeStyles } from "@material-ui/core/styles"
|
||||
import Typography from "@material-ui/core/Typography"
|
||||
import React from "react"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
|
||||
import { WorkspaceStatus } from "../../util/workspace"
|
||||
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 { WorkspaceStatusBar } from "../WorkspaceStatusBar/WorkspaceStatusBar"
|
||||
import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats"
|
||||
|
||||
export interface WorkspaceProps {
|
||||
handleStart: () => void
|
||||
@@ -34,76 +36,62 @@ export const Workspace: React.FC<WorkspaceProps> = ({
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.vertical}>
|
||||
<WorkspaceStatusBar
|
||||
workspace={workspace}
|
||||
handleStart={handleStart}
|
||||
handleStop={handleStop}
|
||||
handleRetry={handleRetry}
|
||||
handleUpdate={handleUpdate}
|
||||
workspaceStatus={workspaceStatus}
|
||||
/>
|
||||
<div className={styles.header}>
|
||||
<div>
|
||||
<Typography variant="h4" className={styles.title}>
|
||||
{workspace.name}
|
||||
</Typography>
|
||||
|
||||
<div className={styles.horizontal}>
|
||||
<div className={styles.sidebarContainer}>
|
||||
<WorkspaceSection title="Applications">
|
||||
<Placeholder />
|
||||
</WorkspaceSection>
|
||||
<WorkspaceSchedule workspace={workspace} />
|
||||
<Typography color="textSecondary" className={styles.subtitle}>
|
||||
{workspace.owner_name}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<WorkspaceSection title="Dev URLs">
|
||||
<Placeholder />
|
||||
</WorkspaceSection>
|
||||
|
||||
<WorkspaceSection title="Resources">
|
||||
<Placeholder />
|
||||
</WorkspaceSection>
|
||||
</div>
|
||||
|
||||
<div className={styles.timelineContainer}>
|
||||
<WorkspaceSection title="Timeline" contentsProps={{ className: styles.timelineContents }}>
|
||||
<BuildsTable builds={builds} className={styles.timelineTable} />
|
||||
</WorkspaceSection>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<WorkspaceActions
|
||||
workspace={workspace}
|
||||
handleStart={handleStart}
|
||||
handleStop={handleStop}
|
||||
handleRetry={handleRetry}
|
||||
handleUpdate={handleUpdate}
|
||||
workspaceStatus={workspaceStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<WorkspaceStats workspace={workspace} />
|
||||
<WorkspaceSection title="Timeline" contentsProps={{ className: styles.timelineContents }}>
|
||||
<BuildsTable builds={builds} className={styles.timelineTable} />
|
||||
</WorkspaceSection>
|
||||
</Stack>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(() => {
|
||||
export const useStyles = makeStyles((theme) => {
|
||||
return {
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
horizontal: {
|
||||
header: {
|
||||
paddingTop: theme.spacing(5),
|
||||
paddingBottom: theme.spacing(5),
|
||||
fontFamily: MONOSPACE_FONT_FAMILY,
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
vertical: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
headerActions: {
|
||||
marginLeft: "auto",
|
||||
},
|
||||
sidebarContainer: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: "0 0 350px",
|
||||
title: {
|
||||
fontWeight: 600,
|
||||
fontFamily: "inherit",
|
||||
},
|
||||
timelineContainer: {
|
||||
flex: 1,
|
||||
subtitle: {
|
||||
fontFamily: "inherit",
|
||||
marginTop: theme.spacing(0.5),
|
||||
},
|
||||
timelineContents: {
|
||||
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 { Link as RouterLink } from "react-router-dom"
|
||||
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 { displayWorkspaceBuildDuration, getDisplayStatus } from "../../util/workspace"
|
||||
|
||||
@@ -57,17 +57,21 @@ export const WorkspaceBuildStats: React.FC<WorkspaceBuildStatsProps> = ({ build
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
stats: {
|
||||
paddingTop: theme.spacing(3),
|
||||
paddingBottom: theme.spacing(3),
|
||||
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),
|
||||
paddingRight: theme.spacing(3),
|
||||
padding: theme.spacing(2),
|
||||
paddingTop: theme.spacing(1.75),
|
||||
},
|
||||
|
||||
statsLabel: {
|
||||
@@ -80,14 +84,14 @@ const useStyles = makeStyles((theme) => ({
|
||||
statsValue: {
|
||||
fontSize: 16,
|
||||
marginTop: theme.spacing(0.25),
|
||||
display: "block",
|
||||
display: "inline-block",
|
||||
},
|
||||
|
||||
statsDivider: {
|
||||
width: 1,
|
||||
height: theme.spacing(5),
|
||||
backgroundColor: theme.palette.divider,
|
||||
marginRight: theme.spacing(3),
|
||||
marginRight: theme.spacing(2),
|
||||
},
|
||||
|
||||
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: {
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: CardRadius,
|
||||
margin: theme.spacing(1),
|
||||
},
|
||||
headerContainer: {
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
|
||||
+4
-4
@@ -2,14 +2,14 @@ import { Story } from "@storybook/react"
|
||||
import dayjs from "dayjs"
|
||||
import React from "react"
|
||||
import * as Mocks from "../../testHelpers/renderHelpers"
|
||||
import { WorkspaceSchedule, WorkspaceScheduleProps } from "./WorkspaceSchedule"
|
||||
import { WorkspaceStats, WorkspaceStatsProps } from "../WorkspaceStats/WorkspaceStats"
|
||||
|
||||
export default {
|
||||
title: "components/WorkspaceSchedule",
|
||||
component: WorkspaceSchedule,
|
||||
title: "components/WorkspaceStats",
|
||||
component: WorkspaceStats,
|
||||
}
|
||||
|
||||
const Template: Story<WorkspaceScheduleProps> = (args) => <WorkspaceSchedule {...args} />
|
||||
const Template: Story<WorkspaceStatsProps> = (args) => <WorkspaceStats {...args} />
|
||||
|
||||
export const NoTTL = Template.bind({})
|
||||
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 * as api from "../../api/api"
|
||||
import { Workspace } from "../../api/typesGenerated"
|
||||
import { Language } from "../../components/WorkspaceStatusBar/WorkspaceStatusBar"
|
||||
import { Language } from "../../components/WorkspaceActions/WorkspaceActions"
|
||||
import {
|
||||
MockBuilds,
|
||||
MockCancelingWorkspace,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
renderWithAuth,
|
||||
} from "../../testHelpers/renderHelpers"
|
||||
import { server } from "../../testHelpers/server"
|
||||
import { DisplayStatusLanguage } from "../../util/workspace"
|
||||
import { WorkspacePage } from "./WorkspacePage"
|
||||
|
||||
// It renders the workspace page and waits for it be loaded
|
||||
@@ -133,28 +134,28 @@ describe("Workspace Page", () => {
|
||||
await testButton(Language.update, getTemplateMock)
|
||||
})
|
||||
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 () => {
|
||||
await testStatus(MockStoppedWorkspace, Language.stopped)
|
||||
await testStatus(MockStoppedWorkspace, DisplayStatusLanguage.stopped)
|
||||
})
|
||||
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 () => {
|
||||
await testStatus(MockWorkspace, Language.started)
|
||||
await testStatus(MockWorkspace, DisplayStatusLanguage.started)
|
||||
})
|
||||
it("shows the Error status when the workspace is failed or canceled", async () => {
|
||||
await testStatus(MockFailedWorkspace, Language.error)
|
||||
it("shows the Failed status when the workspace is failed or canceled", async () => {
|
||||
await testStatus(MockFailedWorkspace, DisplayStatusLanguage.failed)
|
||||
})
|
||||
it("shows the Loading status when the workspace is canceling", async () => {
|
||||
await testStatus(MockCancelingWorkspace, Language.canceling)
|
||||
it("shows the Canceling status when the workspace is canceling", async () => {
|
||||
await testStatus(MockCancelingWorkspace, DisplayStatusLanguage.canceling)
|
||||
})
|
||||
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 () => {
|
||||
await testStatus(MockDeletedWorkspace, Language.deleted)
|
||||
await testStatus(MockDeletedWorkspace, DisplayStatusLanguage.deleted)
|
||||
})
|
||||
it("shows the timeline build", async () => {
|
||||
await renderWorkspacePage()
|
||||
|
||||
@@ -10,5 +10,5 @@ export const navHeight = 42
|
||||
export const maxWidth = 1380
|
||||
export const sidePadding = "50px"
|
||||
export const TitleIconSize = 48
|
||||
export const CardRadius = 8
|
||||
export const CardRadius = 2
|
||||
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 = (
|
||||
theme: Theme,
|
||||
build: WorkspaceBuild,
|
||||
@@ -62,57 +76,57 @@ export const getDisplayStatus = (
|
||||
case undefined:
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: "Loading...",
|
||||
status: DisplayStatusLanguage.loading,
|
||||
}
|
||||
case "started":
|
||||
return {
|
||||
color: theme.palette.success.main,
|
||||
status: "⦿ Running",
|
||||
status: `⦿ ${DisplayStatusLanguage.started}`,
|
||||
}
|
||||
case "starting":
|
||||
return {
|
||||
color: theme.palette.success.main,
|
||||
status: "⦿ Starting",
|
||||
status: `⦿ ${DisplayStatusLanguage.starting}`,
|
||||
}
|
||||
case "stopping":
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: "◍ Stopping",
|
||||
status: `◍ ${DisplayStatusLanguage.stopping}`,
|
||||
}
|
||||
case "stopped":
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: "◍ Stopped",
|
||||
status: `◍ ${DisplayStatusLanguage.stopped}`,
|
||||
}
|
||||
case "deleting":
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: "⦸ Deleting",
|
||||
status: `⦸ ${DisplayStatusLanguage.deleting}`,
|
||||
}
|
||||
case "deleted":
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: "⦸ Deleted",
|
||||
status: `⦸ ${DisplayStatusLanguage.deleted}`,
|
||||
}
|
||||
case "canceling":
|
||||
return {
|
||||
color: theme.palette.warning.light,
|
||||
status: "◍ Canceling",
|
||||
status: `◍ ${DisplayStatusLanguage.canceling}`,
|
||||
}
|
||||
case "canceled":
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: "◍ Canceled",
|
||||
status: `◍ ${DisplayStatusLanguage.canceled}`,
|
||||
}
|
||||
case "error":
|
||||
return {
|
||||
color: theme.palette.error.main,
|
||||
status: "ⓧ Failed",
|
||||
status: `ⓧ ${DisplayStatusLanguage.failed}`,
|
||||
}
|
||||
case "queued":
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: "◍ Queued",
|
||||
status: `◍ ${DisplayStatusLanguage.queued}`,
|
||||
}
|
||||
}
|
||||
throw new Error("unknown status " + status)
|
||||
|
||||
Reference in New Issue
Block a user