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) + }, + }, + }, +) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy