diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 4ec0f463cae26..f4a055d12862d 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -17,6 +17,7 @@ import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage" import { UsersPage } from "./pages/UsersPage/UsersPage" import { WorkspaceBuildPage } from "./pages/WorkspaceBuildPage/WorkspaceBuildPage" import { WorkspacePage } from "./pages/WorkspacePage/WorkspacePage" +import { WorkspaceSchedulePage } from "./pages/WorkspaceSchedulePage/WorkspaceSchedulePage" import { WorkspaceSettingsPage } from "./pages/WorkspaceSettingsPage/WorkspaceSettingsPage" const TerminalPage = React.lazy(() => import("./pages/TerminalPage/TerminalPage")) @@ -83,6 +84,14 @@ export const AppRouter: React.FC = () => ( } /> + + + + } + /> diff --git a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx index f15a731f89089..b39ed857c8c9f 100644 --- a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx +++ b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx @@ -7,6 +7,7 @@ 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 { MONOSPACE_FONT_FAMILY } from "../../theme/constants" import { extractTimezone, stripTimezone } from "../../util/schedule" @@ -78,7 +79,9 @@ export const WorkspaceSchedule: React.FC = ({ workspace {Language.autoStopDisplay(workspace)}
- {Language.editScheduleLink} + + {Language.editScheduleLink} +
diff --git a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.stories.tsx b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.stories.tsx index 74fc7b921ce75..9b7f43e0850f2 100644 --- a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.stories.tsx +++ b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.stories.tsx @@ -13,8 +13,5 @@ const Template: Story = (args) => action("onCancel"), - onSubmit: () => { - action("onSubmit") - return Promise.resolve() - }, + onSubmit: () => action("onSubmit"), } diff --git a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.test.ts b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.test.ts index cdd133dfc7932..b1ffe7795b800 100644 --- a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.test.ts +++ b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.test.ts @@ -10,6 +10,7 @@ const valid: WorkspaceScheduleFormValues = { saturday: false, startTime: "09:30", + timezone: "Canada/Eastern", ttl: 120, } @@ -25,6 +26,7 @@ describe("validationSchema", () => { saturday: false, startTime: "", + timezone: "", ttl: 0, } const validate = () => validationSchema.validateSync(values) @@ -32,7 +34,7 @@ describe("validationSchema", () => { }) it("disallows ttl to be negative", () => { - const values = { + const values: WorkspaceScheduleFormValues = { ...valid, ttl: -1, } @@ -41,7 +43,7 @@ describe("validationSchema", () => { }) it("disallows all days-of-week to be false when startTime is set", () => { - const values = { + const values: WorkspaceScheduleFormValues = { ...valid, sunday: false, monday: false, @@ -54,4 +56,58 @@ describe("validationSchema", () => { const validate = () => validationSchema.validateSync(values) expect(validate).toThrowError(Language.errorNoDayOfWeek) }) + + it("allows startTime 16:20", () => { + const values: WorkspaceScheduleFormValues = { + ...valid, + startTime: "16:20", + } + const validate = () => validationSchema.validateSync(values) + expect(validate).not.toThrow() + }) + + it("disallows startTime to be H:mm", () => { + const values: WorkspaceScheduleFormValues = { + ...valid, + startTime: "9:30", + } + const validate = () => validationSchema.validateSync(values) + expect(validate).toThrowError(Language.errorTime) + }) + + it("disallows startTime to be HH:m", () => { + const values: WorkspaceScheduleFormValues = { + ...valid, + startTime: "09:5", + } + const validate = () => validationSchema.validateSync(values) + expect(validate).toThrowError(Language.errorTime) + }) + + it("disallows an invalid startTime 24:01", () => { + const values: WorkspaceScheduleFormValues = { + ...valid, + startTime: "24:01", + } + const validate = () => validationSchema.validateSync(values) + expect(validate).toThrowError(Language.errorTime) + }) + + it("disallows an invalid startTime 09:60", () => { + const values: WorkspaceScheduleFormValues = { + ...valid, + startTime: "09:60", + } + const validate = () => validationSchema.validateSync(values) + expect(validate).toThrowError(Language.errorTime) + }) + + it("disallows an invalid timezone Canada/North", () => { + const values: WorkspaceScheduleFormValues = { + ...valid, + timezone: "Canada/North", + } + const validate = () => validationSchema.validateSync(values) + expect(validate).toThrowError(Language.errorTimezone) + }) }) diff --git a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx index 1603e28e7e8e1..da4fec20269d0 100644 --- a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx +++ b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx @@ -4,18 +4,31 @@ import FormControlLabel from "@material-ui/core/FormControlLabel" import FormGroup from "@material-ui/core/FormGroup" import FormHelperText from "@material-ui/core/FormHelperText" import FormLabel from "@material-ui/core/FormLabel" +import Link from "@material-ui/core/Link" import makeStyles from "@material-ui/core/styles/makeStyles" import TextField from "@material-ui/core/TextField" +import dayjs from "dayjs" +import timezone from "dayjs/plugin/timezone" +import utc from "dayjs/plugin/utc" import { useFormik } from "formik" import React from "react" import * as Yup from "yup" +import { FieldErrors } from "../../api/errors" import { getFormHelpers } from "../../util/formUtils" import { FormFooter } from "../FormFooter/FormFooter" import { FullPageForm } from "../FullPageForm/FullPageForm" import { Stack } from "../Stack/Stack" +// REMARK: timezone plugin depends on UTC +// +// SEE: https://day.js.org/docs/en/timezone/timezone +dayjs.extend(utc) +dayjs.extend(timezone) + export const Language = { errorNoDayOfWeek: "Must set at least one day of week", + errorTime: "Time must be in HH:mm format (24 hours)", + errorTimezone: "Invalid timezone", daysOfWeekLabel: "Days of Week", daySundayLabel: "Sunday", dayMondayLabel: "Monday", @@ -26,13 +39,17 @@ export const Language = { daySaturdayLabel: "Saturday", startTimeLabel: "Start time", startTimeHelperText: "Your workspace will automatically start at this time.", - ttlLabel: "Runtime (minutes)", - ttlHelperText: "Your workspace will automatically shutdown after the runtime.", + timezoneLabel: "Timezone", + ttlLabel: "TTL (hours)", + ttlHelperText: "Your workspace will automatically shutdown after the TTL.", } export interface WorkspaceScheduleFormProps { + fieldErrors?: FieldErrors + initialValues?: WorkspaceScheduleFormValues + isLoading: boolean onCancel: () => void - onSubmit: (values: WorkspaceScheduleFormValues) => Promise + onSubmit: (values: WorkspaceScheduleFormValues) => void } export interface WorkspaceScheduleFormValues { @@ -45,6 +62,7 @@ export interface WorkspaceScheduleFormValues { saturday: boolean startTime: string + timezone: string ttl: number } @@ -73,30 +91,79 @@ export const validationSchema = Yup.object({ friday: Yup.boolean(), saturday: Yup.boolean(), - startTime: Yup.string(), + startTime: Yup.string() + .ensure() + .test("is-time-string", Language.errorTime, (value) => { + if (value === "") { + return true + } else if (!/^[0-9][0-9]:[0-9][0-9]$/.test(value)) { + return false + } else { + const parts = value.split(":") + const HH = Number(parts[0]) + const mm = Number(parts[1]) + return HH >= 0 && HH <= 23 && mm >= 0 && mm <= 59 + } + }), + timezone: Yup.string() + .ensure() + .test("is-timezone", Language.errorTimezone, function (value) { + const parent = this.parent as WorkspaceScheduleFormValues + + if (!parent.startTime) { + return true + } else { + // Unfortunately, there's not a good API on dayjs at this time for + // evaluating a timezone. Attempt to parse today in the supplied timezone + // and return as valid if the function doesn't throw. + try { + dayjs.tz(dayjs(), value) + return true + } catch (e) { + return false + } + } + }), ttl: Yup.number().min(0).integer(), }) -export const WorkspaceScheduleForm: React.FC = ({ onCancel, onSubmit }) => { +export const WorkspaceScheduleForm: React.FC = ({ + fieldErrors, + initialValues = { + sunday: false, + monday: true, + tuesday: true, + wednesday: true, + thursday: true, + friday: true, + saturday: false, + + startTime: "09:30", + timezone: "", + ttl: 5, + }, + isLoading, + onCancel, + onSubmit, +}) => { const styles = useStyles() const form = useFormik({ - initialValues: { - sunday: false, - monday: true, - tuesday: true, - wednesday: true, - thursday: true, - friday: true, - saturday: false, - - startTime: "09:30", - ttl: 120, - }, + initialValues, onSubmit, validationSchema, }) - const formHelpers = getFormHelpers(form) + const formHelpers = getFormHelpers(form, fieldErrors) + + const checkboxes: Array<{ value: boolean; name: string; label: string }> = [ + { value: form.values.sunday, name: "sunday", label: Language.daySundayLabel }, + { value: form.values.monday, name: "monday", label: Language.dayMondayLabel }, + { value: form.values.tuesday, name: "tuesday", label: Language.dayTuesdayLabel }, + { value: form.values.wednesday, name: "wednesday", label: Language.dayWednesdayLabel }, + { value: form.values.thursday, name: "thursday", label: Language.dayThursdayLabel }, + { value: form.values.friday, name: "friday", label: Language.dayFridayLabel }, + { value: form.values.saturday, name: "saturday", label: Language.daySaturdayLabel }, + ] return ( @@ -104,6 +171,7 @@ export const WorkspaceScheduleForm: React.FC = ({ on = ({ on variant="standard" /> + + Timezone must be a valid{" "} + + tz database name + + , + )} + disabled={form.isSubmitting || isLoading || !form.values.startTime} + InputLabelProps={{ + shrink: true, + }} + label={Language.timezoneLabel} + variant="standard" + /> + {Language.daysOfWeekLabel} - - } - label={Language.daySundayLabel} - /> - - } - label={Language.dayMondayLabel} - /> - - } - label={Language.dayTuesdayLabel} - /> - - } - label={Language.dayWednesdayLabel} - /> - - } - label={Language.dayThursdayLabel} - /> - - } - label={Language.dayFridayLabel} - /> - - } - label={Language.daySaturdayLabel} - /> + {checkboxes.map((checkbox) => ( + + } + key={checkbox.name} + label={checkbox.label} + /> + ))} + {form.errors.monday && {Language.errorNoDayOfWeek}} - + diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx new file mode 100644 index 0000000000000..798f6a7b62532 --- /dev/null +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx @@ -0,0 +1,246 @@ +import * as TypesGen from "../../api/typesGenerated" +import { WorkspaceScheduleFormValues } from "../../components/WorkspaceStats/WorkspaceScheduleForm" +import * as Mocks from "../../testHelpers/entities" +import { formValuesToAutoStartRequest, formValuesToTTLRequest, workspaceToInitialValues } from "./WorkspaceSchedulePage" + +const validValues: WorkspaceScheduleFormValues = { + sunday: false, + monday: true, + tuesday: true, + wednesday: true, + thursday: true, + friday: true, + saturday: false, + startTime: "09:30", + timezone: "Canada/Eastern", + ttl: 120, +} + +describe("WorkspaceSchedulePage", () => { + describe("formValuesToAutoStartRequest", () => { + it.each<[WorkspaceScheduleFormValues, TypesGen.UpdateWorkspaceAutostartRequest]>([ + [ + // Empty case + { + sunday: false, + monday: false, + tuesday: false, + wednesday: false, + thursday: false, + friday: false, + saturday: false, + startTime: "", + timezone: "", + ttl: 0, + }, + { + schedule: "", + }, + ], + [ + // Single day + { + sunday: true, + monday: false, + tuesday: false, + wednesday: false, + thursday: false, + friday: false, + saturday: false, + startTime: "16:20", + timezone: "Canada/Eastern", + ttl: 120, + }, + { + schedule: "CRON_TZ=Canada/Eastern 20 16 * * 0", + }, + ], + [ + // Standard 1-5 case + { + sunday: false, + monday: true, + tuesday: true, + wednesday: true, + thursday: true, + friday: true, + saturday: false, + startTime: "09:30", + timezone: "America/Central", + ttl: 120, + }, + { + schedule: "CRON_TZ=America/Central 30 09 * * 1-5", + }, + ], + [ + // Everyday + { + sunday: true, + monday: true, + tuesday: true, + wednesday: true, + thursday: true, + friday: true, + saturday: true, + startTime: "09:00", + timezone: "", + ttl: 60 * 8, + }, + { + schedule: "00 09 * * *", + }, + ], + [ + // Mon, Wed, Fri Evenings + { + sunday: false, + monday: true, + tuesday: false, + wednesday: true, + thursday: false, + friday: true, + saturday: false, + startTime: "16:20", + timezone: "", + ttl: 60 * 3, + }, + { + schedule: "20 16 * * 1,3,5", + }, + ], + ])(`formValuesToAutoStartRequest(%p) return %p`, (values, request) => { + expect(formValuesToAutoStartRequest(values)).toEqual(request) + }) + }) + + describe("formValuesToTTLRequest", () => { + it.each<[WorkspaceScheduleFormValues, TypesGen.UpdateWorkspaceTTLRequest]>([ + [ + // 0 case + { + ...validValues, + ttl: 0, + }, + { + ttl: undefined, + }, + ], + [ + // 2 Hours = 7.2e+12 case + { + ...validValues, + ttl: 2, + }, + { + ttl: 7_200_000_000_000, + }, + ], + [ + // 8 hours = 2.88e+13 case + { + ...validValues, + ttl: 8, + }, + { + ttl: 28_800_000_000_000, + }, + ], + ])(`formValuesToTTLRequest(%p) returns %p`, (values, request) => { + expect(formValuesToTTLRequest(values)).toEqual(request) + }) + }) + + describe("workspaceToInitialValues", () => { + it.each<[TypesGen.Workspace, WorkspaceScheduleFormValues]>([ + // Empty case + [ + { + ...Mocks.MockWorkspace, + autostart_schedule: "", + ttl: undefined, + }, + { + sunday: false, + monday: false, + tuesday: false, + wednesday: false, + thursday: false, + friday: false, + saturday: false, + startTime: "", + timezone: "", + ttl: 0, + }, + ], + + // ttl-only case (2 hours) + [ + { + ...Mocks.MockWorkspace, + autostart_schedule: "", + ttl: 7_200_000_000_000, + }, + { + sunday: false, + monday: false, + tuesday: false, + wednesday: false, + thursday: false, + friday: false, + saturday: false, + startTime: "", + timezone: "", + ttl: 2, + }, + ], + + // Basic case: 9:30 1-5 UTC running for 2 hours + // + // NOTE: We have to set CRON_TZ here because otherwise this test will + // flake based off of where it runs! + [ + { + ...Mocks.MockWorkspace, + autostart_schedule: "CRON_TZ=UTC 30 9 * * 1-5", + ttl: 7_200_000_000_000, + }, + { + sunday: false, + monday: true, + tuesday: true, + wednesday: true, + thursday: true, + friday: true, + saturday: false, + startTime: "09:30", + timezone: "UTC", + ttl: 2, + }, + ], + + // Complex case: 4:20 1 3-4 6 Canada/Eastern for 8 hours + [ + { + ...Mocks.MockWorkspace, + autostart_schedule: "CRON_TZ=Canada/Eastern 20 16 * * 1,3-4,6", + ttl: 28_800_000_000_000, + }, + { + sunday: false, + monday: true, + tuesday: false, + wednesday: true, + thursday: true, + friday: false, + saturday: true, + startTime: "16:20", + timezone: "Canada/Eastern", + ttl: 8, + }, + ], + ])(`workspaceToInitialValues(%p) returns %p`, (workspace, formValues) => { + expect(workspaceToInitialValues(workspace)).toEqual(formValues) + }) + }) +}) diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx new file mode 100644 index 0000000000000..a686daf4daf00 --- /dev/null +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -0,0 +1,187 @@ +import { useMachine } from "@xstate/react" +import dayjs from "dayjs" +import timezone from "dayjs/plugin/timezone" +import utc from "dayjs/plugin/utc" +import React, { useEffect } from "react" +import { useNavigate, useParams } from "react-router-dom" +import * as TypesGen from "../../api/typesGenerated" +import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary" +import { FullScreenLoader } from "../../components/Loader/FullScreenLoader" +import { + WorkspaceScheduleForm, + WorkspaceScheduleFormValues, +} from "../../components/WorkspaceStats/WorkspaceScheduleForm" +import { firstOrItem } from "../../util/array" +import { dowToWeeklyFlag, extractTimezone, stripTimezone } from "../../util/schedule" +import { workspaceSchedule } from "../../xServices/workspaceSchedule/workspaceScheduleXService" + +// REMARK: timezone plugin depends on UTC +// +// SEE: https://day.js.org/docs/en/timezone/timezone +dayjs.extend(utc) +dayjs.extend(timezone) + +export const formValuesToAutoStartRequest = ( + values: WorkspaceScheduleFormValues, +): TypesGen.UpdateWorkspaceAutostartRequest => { + if (!values.startTime) { + return { + schedule: "", + } + } + + const [HH, mm] = values.startTime.split(":") + + // Note: Space after CRON_TZ if timezone is defined + const preparedTZ = values.timezone ? `CRON_TZ=${values.timezone} ` : "" + + const makeCronString = (dow: string) => `${preparedTZ}${mm} ${HH} * * ${dow}` + + const days = [ + values.sunday, + values.monday, + values.tuesday, + values.wednesday, + values.thursday, + values.friday, + values.saturday, + ] + + const isEveryDay = days.every((day) => day) + + const isMonThroughFri = + !values.sunday && + values.monday && + values.tuesday && + values.wednesday && + values.thursday && + values.friday && + !values.saturday && + !values.sunday + + // Handle special cases, falling through to comma-separation + if (isEveryDay) { + return { + schedule: makeCronString("*"), + } + } else if (isMonThroughFri) { + return { + schedule: makeCronString("1-5"), + } + } else { + const dow = days.reduce((previous, current, idx) => { + if (!current) { + return previous + } else { + const prefix = previous ? "," : "" + return previous + prefix + idx + } + }, "") + + return { + schedule: makeCronString(dow), + } + } +} + +export const formValuesToTTLRequest = (values: WorkspaceScheduleFormValues): TypesGen.UpdateWorkspaceTTLRequest => { + return { + // minutes to nanoseconds + ttl: values.ttl ? values.ttl * 60 * 60 * 1000 * 1_000_000 : undefined, + } +} + +export const workspaceToInitialValues = (workspace: TypesGen.Workspace): WorkspaceScheduleFormValues => { + const schedule = workspace.autostart_schedule + const ttl = workspace.ttl ? workspace.ttl / (1_000_000 * 1000 * 60 * 60) : 0 + + if (!schedule) { + return { + sunday: false, + monday: false, + tuesday: false, + wednesday: false, + thursday: false, + friday: false, + saturday: false, + startTime: "", + timezone: "", + ttl, + } + } + + const timezone = extractTimezone(schedule, dayjs.tz.guess()) + const cronString = stripTimezone(schedule) + + // parts has the following format: "mm HH * * dow" + const parts = cronString.split(" ") + + // -> we skip month and day-of-month + const mm = parts[0] + const HH = parts[1] + const dow = parts[4] + + const weeklyFlags = dowToWeeklyFlag(dow) + + return { + sunday: weeklyFlags[0], + monday: weeklyFlags[1], + tuesday: weeklyFlags[2], + wednesday: weeklyFlags[3], + thursday: weeklyFlags[4], + friday: weeklyFlags[5], + saturday: weeklyFlags[6], + startTime: `${HH.padStart(2, "0")}:${mm.padStart(2, "0")}`, + timezone, + ttl, + } +} + +export const WorkspaceSchedulePage: React.FC = () => { + const navigate = useNavigate() + const { workspace: workspaceQueryParam } = useParams() + const workspaceId = firstOrItem(workspaceQueryParam, null) + const [scheduleState, scheduleSend] = useMachine(workspaceSchedule) + const { formErrors, getWorkspaceError, workspace } = scheduleState.context + + // Get workspace on mount and whenever workspaceId changes. + // scheduleSend should not change. + useEffect(() => { + workspaceId && scheduleSend({ type: "GET_WORKSPACE", workspaceId }) + }, [workspaceId, scheduleSend]) + + if (!workspaceId) { + navigate("/workspaces") + return null + } else if (scheduleState.matches("idle") || scheduleState.matches("gettingWorkspace") || !workspace) { + return + } else if (scheduleState.matches("error")) { + return scheduleSend({ type: "GET_WORKSPACE", workspaceId })} /> + } else if (scheduleState.matches("presentForm") || scheduleState.matches("submittingSchedule")) { + return ( + { + navigate(`/workspaces/${workspaceId}`) + }} + onSubmit={(values) => { + scheduleSend({ + type: "SUBMIT_SCHEDULE", + autoStart: formValuesToAutoStartRequest(values), + ttl: formValuesToTTLRequest(values), + }) + }} + /> + ) + } else if (scheduleState.matches("submitSuccess")) { + navigate(`/workspaces/${workspaceId}`) + return + } else { + // Theoretically impossible - log and bail + console.error("WorkspaceSchedulePage: unknown state :: ", scheduleState) + navigate("/") + return null + } +} diff --git a/site/src/util/formUtils.ts b/site/src/util/formUtils.ts index eec487f70ca15..108ffdbedf261 100644 --- a/site/src/util/formUtils.ts +++ b/site/src/util/formUtils.ts @@ -1,5 +1,5 @@ import { FormikContextType, FormikErrors, getIn } from "formik" -import { ChangeEvent, ChangeEventHandler, FocusEventHandler } from "react" +import { ChangeEvent, ChangeEventHandler, FocusEventHandler, ReactNode } from "react" interface FormHelpers { name: string @@ -8,12 +8,12 @@ interface FormHelpers { id: string value?: string | number error: boolean - helperText?: string + helperText?: ReactNode } export const getFormHelpers = (form: FormikContextType, formErrors?: FormikErrors) => - (name: keyof T, helperText = ""): FormHelpers => { + (name: keyof T, HelperText: ReactNode = ""): FormHelpers => { if (typeof name !== "string") { throw new Error(`name must be type of string, instead received '${typeof name}'`) } @@ -28,7 +28,7 @@ export const getFormHelpers = ...form.getFieldProps(name), id: name, error: touched && Boolean(error), - helperText: touched ? error || helperText : helperText, + helperText: touched ? error || HelperText : HelperText, } } diff --git a/site/src/util/schedule.test.ts b/site/src/util/schedule.test.ts index d7ed65299cd67..95bc27433e667 100644 --- a/site/src/util/schedule.test.ts +++ b/site/src/util/schedule.test.ts @@ -1,4 +1,4 @@ -import { extractTimezone, stripTimezone } from "./schedule" +import { dowToWeeklyFlag, extractTimezone, stripTimezone, WeeklyFlag } from "./schedule" describe("util/schedule", () => { describe("stripTimezone", () => { @@ -20,4 +20,26 @@ describe("util/schedule", () => { expect(extractTimezone(input)).toBe(expected) }) }) + + describe("dowToWeeklyFlag", () => { + it.each<[string, WeeklyFlag]>([ + // All days + ["*", [true, true, true, true, true, true, true]], + ["0-6", [true, true, true, true, true, true, true]], + ["1-7", [true, true, true, true, true, true, true]], + + // Single number modulo 7 + ["3", [false, false, false, true, false, false, false]], + ["0", [true, false, false, false, false, false, false]], + ["7", [true, false, false, false, false, false, false]], + ["8", [false, true, false, false, false, false, false]], + + // Comma-separated Numbers, Ranges and Mixes + ["1,3,5", [false, true, false, true, false, true, false]], + ["1-2,4-5", [false, true, true, false, true, true, false]], + ["1,3-4,6", [false, true, false, true, true, false, true]], + ])(`dowToWeeklyFlag(%p) returns %p`, (dow, weeklyFlag) => { + expect(dowToWeeklyFlag(dow)).toEqual(weeklyFlag) + }) + }) }) diff --git a/site/src/util/schedule.ts b/site/src/util/schedule.ts index 55c26aadfea14..81dd10694f4bb 100644 --- a/site/src/util/schedule.ts +++ b/site/src/util/schedule.ts @@ -19,14 +19,83 @@ export const stripTimezone = (raw: string): string => { /** * extractTimezone returns a leading timezone from a schedule string if one is - * specified; otherwise DEFAULT_TIMEZONE + * specified; otherwise the specified defaultTZ */ -export const extractTimezone = (raw: string): string => { +export const extractTimezone = (raw: string, defaultTZ = DEFAULT_TIMEZONE): string => { const matches = raw.match(/CRON_TZ=\S*\s/g) if (matches && matches.length) { return matches[0].replace(/CRON_TZ=/, "").trim() } else { - return DEFAULT_TIMEZONE + return defaultTZ } } + +/** + * WeeklyFlag is an array representing which days of the week are set or flagged + * + * @remarks + * + * A WeeklyFlag has an array size of 7 and should never have its size modified. + * The 0th index is Sunday + * The 6th index is Saturday + */ +export type WeeklyFlag = [boolean, boolean, boolean, boolean, boolean, boolean, boolean] + +/** + * dowToWeeklyFlag converts a dow cron string to a WeeklyFlag array. + * + * @example + * + * dowToWeeklyFlag("1") // [false, true, false, false, false, false, false] + * dowToWeeklyFlag("1-5") // [false, true, true, true, true, true, false] + * dowToWeeklyFlag("1,3-4,6") // [false, true, false, true, true, false, true] + */ +export const dowToWeeklyFlag = (dow: string): WeeklyFlag => { + if (dow === "*") { + return [true, true, true, true, true, true, true] + } + + const results: WeeklyFlag = [false, false, false, false, false, false, false] + + const commaSeparatedRangeOrNum = dow.split(",") + + for (const rangeOrNum of commaSeparatedRangeOrNum) { + const flags = processRangeOrNum(rangeOrNum) + + flags.forEach((value, idx) => { + if (value) { + results[idx] = true + } + }) + } + + return results +} + +/** + * processRangeOrNum is a helper for dowToWeeklyFlag. It processes a range or + * number (modulo 7) into a Weeklyflag boolean array. + * + * @example + * + * processRangeOrNum("1") // [false, true, false, false, false, false, false] + * processRangeOrNum("1-5") // [false, true, true, true, true, true, false] + */ +const processRangeOrNum = (rangeOrNum: string): WeeklyFlag => { + const result: WeeklyFlag = [false, false, false, false, false, false, false] + + const isRange = /^[0-9]-[0-9]$/.test(rangeOrNum) + + if (isRange) { + const [first, last] = rangeOrNum.split("-") + + for (let i = Number(first); i <= Number(last); i++) { + result[i % 7] = true + } + } else { + result[Number(rangeOrNum) % 7] = true + } + + return result +} diff --git a/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts b/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts new file mode 100644 index 0000000000000..a1ddb58254366 --- /dev/null +++ b/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts @@ -0,0 +1,151 @@ +/** + * @fileoverview workspaceSchedule is an xstate machine backing a form to CRUD + * an individual workspace's schedule. + */ +import { assign, createMachine } from "xstate" +import * as API from "../../api/api" +import { ApiError, FieldErrors, mapApiErrorToFieldErrors } from "../../api/errors" +import * as TypesGen from "../../api/typesGenerated" +import { displayError, displaySuccess } from "../../components/GlobalSnackbar/utils" + +export const Language = { + errorSubmissionFailed: "Failed to update schedule", + errorWorkspaceFetch: "Failed to fetch workspace", + successMessage: "Successfully updated workspace schedule.", +} + +export interface WorkspaceScheduleContext { + formErrors?: FieldErrors + getWorkspaceError?: Error | unknown + /** + * Each workspace has their own schedule (start and ttl). For this reason, we + * re-fetch the workspace to ensure we're up-to-date. As a result, this + * machine is partially influenced by workspaceXService. + */ + workspace?: TypesGen.Workspace +} + +export type WorkspaceScheduleEvent = + | { type: "GET_WORKSPACE"; workspaceId: string } + | { + type: "SUBMIT_SCHEDULE" + autoStart: TypesGen.UpdateWorkspaceAutostartRequest + ttl: TypesGen.UpdateWorkspaceTTLRequest + } + +export const workspaceSchedule = createMachine( + { + tsTypes: {} as import("./workspaceScheduleXService.typegen").Typegen0, + schema: { + context: {} as WorkspaceScheduleContext, + events: {} as WorkspaceScheduleEvent, + services: {} as { + getWorkspace: { + data: TypesGen.Workspace + } + }, + }, + id: "workspaceScheduleState", + initial: "idle", + on: { + GET_WORKSPACE: "gettingWorkspace", + }, + states: { + idle: { + tags: "loading", + }, + gettingWorkspace: { + entry: ["clearGetWorkspaceError", "clearContext"], + invoke: { + src: "getWorkspace", + id: "getWorkspace", + onDone: { + target: "presentForm", + actions: ["assignWorkspace"], + }, + onError: { + target: "error", + actions: ["assignGetWorkspaceError", "displayWorkspaceError"], + }, + }, + tags: "loading", + }, + presentForm: { + on: { + SUBMIT_SCHEDULE: "submittingSchedule", + }, + }, + submittingSchedule: { + invoke: { + src: "submitSchedule", + id: "submitSchedule", + onDone: { + target: "submitSuccess", + actions: "displaySuccess", + }, + onError: { + target: "presentForm", + actions: ["assignSubmissionError", "displaySubmissionError"], + }, + }, + tags: "loading", + }, + submitSuccess: { + on: { + SUBMIT_SCHEDULE: "submittingSchedule", + }, + }, + error: { + on: { + GET_WORKSPACE: "gettingWorkspace", + }, + }, + }, + }, + { + actions: { + assignSubmissionError: assign({ + formErrors: (_, event) => mapApiErrorToFieldErrors((event.data as ApiError).response.data), + }), + assignWorkspace: assign({ + workspace: (_, event) => event.data, + }), + assignGetWorkspaceError: assign({ + getWorkspaceError: (_, event) => event.data, + }), + clearContext: () => { + assign({ workspace: undefined }) + }, + clearGetWorkspaceError: (context) => { + assign({ ...context, getWorkspaceError: undefined }) + }, + displayWorkspaceError: () => { + displayError(Language.errorWorkspaceFetch) + }, + displaySubmissionError: () => { + displayError(Language.errorSubmissionFailed) + }, + displaySuccess: () => { + displaySuccess(Language.successMessage) + }, + }, + + services: { + getWorkspace: async (_, event) => { + return await API.getWorkspace(event.workspaceId) + }, + submitSchedule: async (context, event) => { + if (!context.workspace?.id) { + // This state is theoretically impossible, but helps TS + throw new Error("failed to load workspace") + } + + // REMARK: These calls are purposefully synchronous because if one + // value contradicts the other, we don't want a race condition + // on re-submission. + await API.putWorkspaceAutostart(context.workspace.id, event.autoStart) + await API.putWorkspaceAutostop(context.workspace.id, event.ttl) + }, + }, + }, +)