refactor: Move schedule to the header (#2775)

This commit is contained in:
Bruno Quaresma
2022-07-01 18:26:27 +00:00
committed by GitHub
parent 22febc749a
commit 2d0ea00ffd
8 changed files with 444 additions and 157 deletions
+17 -23
View File
@@ -1,5 +1,5 @@
import { makeStyles } from "@material-ui/core/styles"
import { FC } from "react"
import React, { FC } from "react"
import { useNavigate } from "react-router-dom"
import * as TypesGen from "../../api/typesGenerated"
import { BuildsTable } from "../BuildsTable/BuildsTable"
@@ -9,8 +9,8 @@ import { Resources } from "../Resources/Resources"
import { Stack } from "../Stack/Stack"
import { WorkspaceActions } from "../WorkspaceActions/WorkspaceActions"
import { WorkspaceDeletedBanner } from "../WorkspaceDeletedBanner/WorkspaceDeletedBanner"
import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule"
import { WorkspaceScheduleBanner } from "../WorkspaceScheduleBanner/WorkspaceScheduleBanner"
import { WorkspaceScheduleButton } from "../WorkspaceScheduleButton/WorkspaceScheduleButton"
import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection"
import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats"
@@ -58,16 +58,22 @@ export const Workspace: FC<WorkspaceProps> = ({
return (
<Margins>
<PageHeader
className={styles.header}
actions={
<WorkspaceActions
workspace={workspace}
handleStart={handleStart}
handleStop={handleStop}
handleDelete={handleDelete}
handleUpdate={handleUpdate}
handleCancel={handleCancel}
/>
<Stack direction="row" spacing={1}>
<WorkspaceScheduleButton
workspace={workspace}
onDeadlineMinus={scheduleProps.onDeadlineMinus}
onDeadlinePlus={scheduleProps.onDeadlinePlus}
/>
<WorkspaceActions
workspace={workspace}
handleStart={handleStart}
handleStop={handleStop}
handleDelete={handleDelete}
handleUpdate={handleUpdate}
handleCancel={handleCancel}
/>
</Stack>
}
>
<PageHeaderTitle>{workspace.name}</PageHeaderTitle>
@@ -102,14 +108,6 @@ export const Workspace: FC<WorkspaceProps> = ({
<BuildsTable builds={builds} className={styles.timelineTable} />
</WorkspaceSection>
</Stack>
<Stack direction="column" className={styles.secondColumnSpacer} spacing={3}>
<WorkspaceSchedule
workspace={workspace}
onDeadlineMinus={scheduleProps.onDeadlineMinus}
onDeadlinePlus={scheduleProps.onDeadlinePlus}
/>
</Stack>
</Stack>
</Margins>
)
@@ -125,10 +123,6 @@ export const useStyles = makeStyles((theme) => {
secondColumnSpacer: {
flex: `0 0 ${spacerWidth}px`,
},
header: {
// 100% - (the size of sidebar + the space between both )
maxWidth: `calc(100% - (${spacerWidth}px + ${theme.spacing(3)}px))`,
},
layout: {
alignItems: "flex-start",
},
@@ -1,12 +1,5 @@
import IconButton from "@material-ui/core/IconButton"
import Link from "@material-ui/core/Link"
import { makeStyles } from "@material-ui/core/styles"
import Tooltip from "@material-ui/core/Tooltip"
import Typography from "@material-ui/core/Typography"
import AddBoxIcon from "@material-ui/icons/AddBox"
import IndeterminateCheckBoxIcon from "@material-ui/icons/IndeterminateCheckBox"
import ScheduleIcon from "@material-ui/icons/Schedule"
import cronstrue from "cronstrue"
import dayjs from "dayjs"
import advancedFormat from "dayjs/plugin/advancedFormat"
import duration from "dayjs/plugin/duration"
@@ -17,8 +10,12 @@ import { FC } from "react"
import { Link as RouterLink } from "react-router-dom"
import { Workspace } from "../../api/typesGenerated"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
import { extractTimezone, stripTimezone } from "../../util/schedule"
import { isWorkspaceOn } from "../../util/workspace"
import {
autoStartDisplay,
autoStopDisplay,
extractTimezone,
Language as ScheduleLanguage,
} from "../../util/schedule"
import { Stack } from "../Stack/Stack"
// REMARK: some plugins depend on utc, so it's listed first. Otherwise they're
@@ -30,133 +27,39 @@ dayjs.extend(relativeTime)
dayjs.extend(timezone)
export const Language = {
autoStartDisplay: (schedule: string | undefined): string => {
if (schedule) {
return cronstrue.toString(stripTimezone(schedule), { throwExceptionOnParseError: false })
} else {
return "Manual"
}
},
autoStartLabel: "START",
autoStopLabel: "SHUTDOWN",
autoStopDisplay: (workspace: Workspace): string => {
const deadline = dayjs(workspace.latest_build.deadline).utc()
// a manual shutdown has a deadline of '"0001-01-01T00:00:00Z"'
// SEE: #1834
const hasDeadline = deadline.year() > 1
const ttl = workspace.ttl_ms
if (isWorkspaceOn(workspace) && hasDeadline) {
// Workspace is on --> derive from latest_build.deadline. Note that the
// user may modify their workspace object (ttl) while the workspace is
// running and depending on system semantics, the deadline may still
// represent the previously defined ttl. Thus, we always derive from the
// deadline as the source of truth.
const now = dayjs().utc()
if (now.isAfter(deadline)) {
return "Workspace is shutting down"
} else {
return deadline.tz(dayjs.tz.guess()).format("MMM D, YYYY h:mm A")
}
} else if (!ttl || ttl < 1) {
// If the workspace is not on, and the ttl is 0 or undefined, then the
// workspace is set to manually shutdown.
return "Manual"
} else {
// The workspace has a ttl set, but is either in an unknown state or is
// not running. Therefore, we derive from workspace.ttl.
const duration = dayjs.duration(ttl, "milliseconds")
return `${duration.humanize()} after start`
}
},
editScheduleLink: "Edit schedule",
editDeadlineMinus: "Subtract one hour",
editDeadlinePlus: "Add one hour",
scheduleHeader: (workspace: Workspace): string => {
const tz = workspace.autostart_schedule
? extractTimezone(workspace.autostart_schedule)
: dayjs.tz.guess()
return `Schedule (${tz})`
},
timezoneLabel: "Timezone",
}
export interface WorkspaceScheduleProps {
now?: dayjs.Dayjs
workspace: Workspace
onDeadlinePlus: () => void
onDeadlineMinus: () => void
}
export const shouldDisplayPlusMinus = (workspace: Workspace): boolean => {
if (!isWorkspaceOn(workspace)) {
return false
}
const deadline = dayjs(workspace.latest_build.deadline).utc()
return deadline.year() > 1
}
export const deadlineMinusDisabled = (workspace: Workspace, now: dayjs.Dayjs): boolean => {
const delta = dayjs(workspace.latest_build.deadline).diff(now)
return delta <= 30 * 60 * 1000 // 30 minutes
}
export const deadlinePlusDisabled = (workspace: Workspace, now: dayjs.Dayjs): boolean => {
const delta = dayjs(workspace.latest_build.deadline).diff(now)
return delta >= 24 * 60 * 60 * 1000 // 24 hours
}
export const WorkspaceSchedule: FC<WorkspaceScheduleProps> = ({
now,
workspace,
onDeadlineMinus,
onDeadlinePlus,
}) => {
export const WorkspaceSchedule: FC<WorkspaceScheduleProps> = ({ workspace }) => {
const styles = useStyles()
const editDeadlineButtons = shouldDisplayPlusMinus(workspace) ? (
<Stack direction="row" spacing={0}>
<IconButton
size="small"
disabled={deadlineMinusDisabled(workspace, now ?? dayjs())}
className={styles.editDeadline}
onClick={onDeadlineMinus}
>
<Tooltip title={Language.editDeadlineMinus}>
<IndeterminateCheckBoxIcon />
</Tooltip>
</IconButton>
<IconButton
size="small"
disabled={deadlinePlusDisabled(workspace, now ?? dayjs())}
className={styles.editDeadline}
onClick={onDeadlinePlus}
>
<Tooltip title={Language.editDeadlinePlus}>
<AddBoxIcon />
</Tooltip>
</IconButton>
</Stack>
) : null
const timezone = workspace.autostart_schedule
? extractTimezone(workspace.autostart_schedule)
: dayjs.tz.guess()
return (
<div className={styles.schedule}>
<Stack spacing={2}>
<Typography variant="body1" className={styles.title}>
<ScheduleIcon className={styles.scheduleIcon} />
{Language.scheduleHeader(workspace)}
</Typography>
<div>
<span className={styles.scheduleLabel}>{Language.autoStartLabel}</span>
<span className={styles.scheduleLabel}>{Language.timezoneLabel}</span>
<span className={styles.scheduleValue}>{timezone}</span>
</div>
<div>
<span className={styles.scheduleLabel}>{ScheduleLanguage.autoStartLabel}</span>
<span className={[styles.scheduleValue, "chromatic-ignore"].join(" ")}>
{Language.autoStartDisplay(workspace.autostart_schedule)}
{autoStartDisplay(workspace.autostart_schedule)}
</span>
</div>
<div>
<span className={styles.scheduleLabel}>{Language.autoStopLabel}</span>
<span className={styles.scheduleLabel}>{ScheduleLanguage.autoStopLabel}</span>
<Stack direction="row">
<span className={[styles.scheduleValue, "chromatic-ignore"].join(" ")}>
{Language.autoStopDisplay(workspace)}
{autoStopDisplay(workspace)}
</span>
{editDeadlineButtons}
</Stack>
</div>
<div>
@@ -177,18 +80,6 @@ const useStyles = makeStyles((theme) => ({
schedule: {
fontFamily: MONOSPACE_FONT_FAMILY,
},
title: {
fontWeight: 600,
fontFamily: "inherit",
display: "flex",
alignItems: "center",
},
scheduleIcon: {
width: 16,
height: 16,
marginRight: theme.spacing(1),
},
scheduleLabel: {
fontSize: 12,
textTransform: "uppercase",
@@ -198,14 +89,11 @@ const useStyles = makeStyles((theme) => ({
},
scheduleValue: {
fontSize: 14,
marginTop: theme.spacing(0.75),
marginTop: theme.spacing(0.5),
display: "inline-block",
color: theme.palette.text.secondary,
},
scheduleAction: {
cursor: "pointer",
},
editDeadline: {
color: theme.palette.text.secondary,
},
}))
@@ -0,0 +1,117 @@
import { Story } from "@storybook/react"
import dayjs from "dayjs"
import utc from "dayjs/plugin/utc"
import * as Mocks from "../../testHelpers/entities"
import { WorkspaceScheduleButton, WorkspaceScheduleButtonProps } from "./WorkspaceScheduleButton"
dayjs.extend(utc)
// REMARK: There's a known problem with storybook and using date libraries that
// call string.toLowerCase
// SEE: https:github.com/storybookjs/storybook/issues/12208#issuecomment-697044557
const ONE = 1
const SEVEN = 7
const THIRTY = 30
export default {
title: "components/WorkspaceScheduleButton",
component: WorkspaceScheduleButton,
}
const Template: Story<WorkspaceScheduleButtonProps> = (args) => (
<WorkspaceScheduleButton {...args} />
)
export const NoScheduleNoTTL = Template.bind({})
NoScheduleNoTTL.args = {
workspace: {
...Mocks.MockWorkspace,
latest_build: {
...Mocks.MockWorkspaceBuild,
transition: "stop",
},
autostart_schedule: undefined,
ttl_ms: undefined,
},
}
export const NoTTL = Template.bind({})
NoTTL.args = {
workspace: {
...Mocks.MockWorkspace,
latest_build: {
...Mocks.MockWorkspaceBuild,
// a mannual shutdown has a deadline of '"0001-01-01T00:00:00Z"'
// SEE: #1834
deadline: "0001-01-01T00:00:00Z",
},
ttl_ms: undefined,
},
}
export const ShutdownRealSoon = Template.bind({})
ShutdownRealSoon.args = {
workspace: {
...Mocks.MockWorkspace,
latest_build: {
...Mocks.MockWorkspaceBuild,
deadline: dayjs().add(THIRTY, "minute").utc().format(),
transition: "start",
},
ttl_ms: 2 * 60 * 60 * 1000, // 2 hours
},
}
export const ShutdownSoon = Template.bind({})
ShutdownSoon.args = {
workspace: {
...Mocks.MockWorkspace,
latest_build: {
...Mocks.MockWorkspaceBuild,
deadline: dayjs().add(ONE, "hour").utc().format(),
transition: "start",
},
ttl_ms: 2 * 60 * 60 * 1000, // 2 hours
},
}
export const ShutdownLong = Template.bind({})
ShutdownLong.args = {
workspace: {
...Mocks.MockWorkspace,
latest_build: {
...Mocks.MockWorkspaceBuild,
deadline: dayjs().add(SEVEN, "days").utc().format(),
transition: "start",
},
ttl_ms: 7 * 24 * 60 * 60 * 1000, // 7 days
},
}
export const WorkspaceOffShort = Template.bind({})
WorkspaceOffShort.args = {
workspace: {
...Mocks.MockWorkspace,
latest_build: {
...Mocks.MockWorkspaceBuild,
transition: "stop",
},
ttl_ms: 2 * 60 * 60 * 1000, // 2 hours
},
}
export const WorkspaceOffLong = Template.bind({})
WorkspaceOffLong.args = {
workspace: {
...Mocks.MockWorkspace,
latest_build: {
...Mocks.MockWorkspaceBuild,
transition: "stop",
},
ttl_ms: 2 * 365 * 24 * 60 * 60 * 1000, // 2 years
},
}
@@ -6,12 +6,12 @@ import {
deadlineMinusDisabled,
deadlinePlusDisabled,
shouldDisplayPlusMinus,
} from "./WorkspaceSchedule"
} from "./WorkspaceScheduleButton"
dayjs.extend(utc)
const now = dayjs()
describe("WorkspaceSchedule", () => {
describe("WorkspaceScheduleButton", () => {
describe("shouldDisplayPlusMinus", () => {
it("should not display if the workspace is not running", () => {
// Given: a stopped workspace
@@ -0,0 +1,159 @@
import Button from "@material-ui/core/Button"
import IconButton from "@material-ui/core/IconButton"
import Popover from "@material-ui/core/Popover"
import { makeStyles } from "@material-ui/core/styles"
import Tooltip from "@material-ui/core/Tooltip"
import AddIcon from "@material-ui/icons/Add"
import RemoveIcon from "@material-ui/icons/Remove"
import ScheduleIcon from "@material-ui/icons/Schedule"
import dayjs from "dayjs"
import advancedFormat from "dayjs/plugin/advancedFormat"
import duration from "dayjs/plugin/duration"
import relativeTime from "dayjs/plugin/relativeTime"
import timezone from "dayjs/plugin/timezone"
import utc from "dayjs/plugin/utc"
import { useRef, useState } from "react"
import { Workspace } from "../../api/typesGenerated"
import { isWorkspaceOn } from "../../util/workspace"
import { Stack } from "../Stack/Stack"
import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule"
import { WorkspaceScheduleLabel } from "./WorkspaceScheduleLabel"
// REMARK: some plugins depend on utc, so it's listed first. Otherwise they're
// sorted alphabetically.
dayjs.extend(utc)
dayjs.extend(advancedFormat)
dayjs.extend(duration)
dayjs.extend(relativeTime)
dayjs.extend(timezone)
export const Language = {
editDeadlineMinus: "Subtract one hour",
editDeadlinePlus: "Add one hour",
}
export const shouldDisplayPlusMinus = (workspace: Workspace): boolean => {
if (!isWorkspaceOn(workspace)) {
return false
}
const deadline = dayjs(workspace.latest_build.deadline).utc()
return deadline.year() > 1
}
export const deadlineMinusDisabled = (workspace: Workspace, now: dayjs.Dayjs): boolean => {
const delta = dayjs(workspace.latest_build.deadline).diff(now)
return delta <= 30 * 60 * 1000 // 30 minutes
}
export const deadlinePlusDisabled = (workspace: Workspace, now: dayjs.Dayjs): boolean => {
const delta = dayjs(workspace.latest_build.deadline).diff(now)
return delta >= 24 * 60 * 60 * 1000 // 24 hours
}
export interface WorkspaceScheduleButtonProps {
workspace: Workspace
onDeadlinePlus: () => void
onDeadlineMinus: () => void
}
export const WorkspaceScheduleButton: React.FC<WorkspaceScheduleButtonProps> = ({
workspace,
onDeadlinePlus,
onDeadlineMinus,
}) => {
const anchorRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const id = isOpen ? "schedule-popover" : undefined
const styles = useStyles()
const onClose = () => {
setIsOpen(false)
}
return (
<div className={styles.wrapper}>
<div className={styles.label}>
<WorkspaceScheduleLabel workspace={workspace} />
{shouldDisplayPlusMinus(workspace) && (
<Stack direction="row" spacing={0}>
<IconButton
className={styles.iconButton}
size="small"
disabled={deadlineMinusDisabled(workspace, dayjs())}
onClick={onDeadlineMinus}
>
<Tooltip title={Language.editDeadlineMinus}>
<RemoveIcon />
</Tooltip>
</IconButton>
<IconButton
className={styles.iconButton}
size="small"
disabled={deadlinePlusDisabled(workspace, dayjs())}
onClick={onDeadlinePlus}
>
<Tooltip title={Language.editDeadlinePlus}>
<AddIcon />
</Tooltip>
</IconButton>
</Stack>
)}
</div>
<div>
<Button
ref={anchorRef}
startIcon={<ScheduleIcon />}
onClick={() => {
setIsOpen(true)
}}
>
Schedule
</Button>
<Popover
classes={{ paper: styles.popoverPaper }}
id={id}
open={isOpen}
anchorEl={anchorRef.current}
onClose={onClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<WorkspaceSchedule workspace={workspace} />
</Popover>
</div>
</div>
)
}
const useStyles = makeStyles((theme) => ({
wrapper: {
display: "flex",
alignItems: "center",
},
label: {
border: `1px solid ${theme.palette.divider}`,
borderRight: 0,
height: "100%",
display: "flex",
alignItems: "center",
padding: "0 8px 0 16px",
color: theme.palette.text.secondary,
// It is from the button props
minHeight: 42,
},
iconButton: {
borderRadius: 2,
},
popoverPaper: {
padding: `${theme.spacing(2)}px ${theme.spacing(3)}px ${theme.spacing(3)}px`,
},
}))
@@ -0,0 +1,41 @@
import { makeStyles } from "@material-ui/core/styles"
import { Workspace } from "../../api/typesGenerated"
import { combineClasses } from "../../util/combineClasses"
import { autoStartDisplay, autoStopDisplay, isShuttingDown, Language } from "../../util/schedule"
import { isWorkspaceOn } from "../../util/workspace"
export const WorkspaceScheduleLabel: React.FC<{ workspace: Workspace }> = ({ workspace }) => {
const styles = useStyles()
if (isWorkspaceOn(workspace)) {
const stopLabel = autoStopDisplay(workspace)
const shouldDisplayStrongLabel = !isShuttingDown(workspace)
// If it is shutting down, we don't need to display the auto stop label
return (
<span className={combineClasses([styles.labelText, "chromatic-ignore"])}>
{shouldDisplayStrongLabel && (
<strong className={styles.labelStrong}>{Language.autoStopLabel}</strong>
)}
{stopLabel}
</span>
)
}
return (
<span className={combineClasses([styles.labelText, "chromatic-ignore"])}>
<strong className={styles.labelStrong}>{Language.autoStartLabel}</strong>
{autoStartDisplay(workspace.autostart_schedule)}
</span>
)
}
const useStyles = makeStyles((theme) => ({
labelText: {
marginRight: theme.spacing(2),
},
labelStrong: {
marginRight: theme.spacing(0.5),
},
}))
+8
View File
@@ -44,6 +44,14 @@ export const getOverrides = (palette: PaletteOptions) => {
marginRight: 12,
},
},
MuiIconButton: {
sizeSmall: {
"& .MuiSvgIcon-root": {
width: 20,
height: 20,
},
},
},
MuiTableHead: {
root: {
fontFamily: MONOSPACE_FONT_FAMILY,
+80
View File
@@ -1,3 +1,21 @@
import cronstrue from "cronstrue"
import dayjs, { Dayjs } from "dayjs"
import advancedFormat from "dayjs/plugin/advancedFormat"
import duration from "dayjs/plugin/duration"
import relativeTime from "dayjs/plugin/relativeTime"
import timezone from "dayjs/plugin/timezone"
import utc from "dayjs/plugin/utc"
import { Workspace } from "../api/typesGenerated"
import { isWorkspaceOn } from "./workspace"
// REMARK: some plugins depend on utc, so it's listed first. Otherwise they're
// sorted alphabetically.
dayjs.extend(utc)
dayjs.extend(advancedFormat)
dayjs.extend(duration)
dayjs.extend(relativeTime)
dayjs.extend(timezone)
/**
* @fileoverview Client-side counterpart of the coderd/autostart/schedule Go
* package. This package is a variation on crontab that uses minute, hour and
@@ -30,3 +48,65 @@ export const extractTimezone = (raw: string, defaultTZ = DEFAULT_TIMEZONE): stri
return defaultTZ
}
}
/** Language used in the schedule components */
export const Language = {
manual: "Manual",
workspaceShuttingDownLabel: "Workspace is shutting down",
afterStart: "after start",
autoStartLabel: "Starts at",
autoStopLabel: "Stops at",
}
export const autoStartDisplay = (schedule: string | undefined): string => {
if (schedule) {
return (
cronstrue
.toString(stripTimezone(schedule), { throwExceptionOnParseError: false })
// We don't want to keep the At because it is on the label
.replace("At", "")
)
} else {
return Language.manual
}
}
export const isShuttingDown = (workspace: Workspace, deadline?: Dayjs): boolean => {
if (!deadline) {
deadline = dayjs(workspace.latest_build.deadline).utc()
}
const hasDeadline = deadline.year() > 1
const now = dayjs().utc()
return isWorkspaceOn(workspace) && hasDeadline && now.isAfter(deadline)
}
export const autoStopDisplay = (workspace: Workspace): string => {
const deadline = dayjs(workspace.latest_build.deadline).utc()
// a manual shutdown has a deadline of '"0001-01-01T00:00:00Z"'
// SEE: #1834
const hasDeadline = deadline.year() > 1
const ttl = workspace.ttl_ms
if (isWorkspaceOn(workspace) && hasDeadline) {
// Workspace is on --> derive from latest_build.deadline. Note that the
// user may modify their workspace object (ttl) while the workspace is
// running and depending on system semantics, the deadline may still
// represent the previously defined ttl. Thus, we always derive from the
// deadline as the source of truth.
if (isShuttingDown(workspace, deadline)) {
return Language.workspaceShuttingDownLabel
} else {
return deadline.tz(dayjs.tz.guess()).format("MMM D, YYYY h:mm A")
}
} else if (!ttl || ttl < 1) {
// If the workspace is not on, and the ttl is 0 or undefined, then the
// workspace is set to manually shutdown.
return Language.manual
} else {
// The workspace has a ttl set, but is either in an unknown state or is
// not running. Therefore, we derive from workspace.ttl.
const duration = dayjs.duration(ttl, "milliseconds")
return `${duration.humanize()} ${Language.afterStart}`
}
}