diff --git a/site/src/components/Section/Section.tsx b/site/src/components/Section/Section.tsx index 9e2b993ed38d7..40e68161b75b6 100644 --- a/site/src/components/Section/Section.tsx +++ b/site/src/components/Section/Section.tsx @@ -30,7 +30,7 @@ export const Section: SectionFC = ({ }) => { const styles = useStyles({ layout }) return ( -
+
{(title || description) && (
@@ -49,7 +49,7 @@ export const Section: SectionFC = ({ {alert &&
{alert}
} {children}
-
+
) } @@ -63,6 +63,7 @@ const useStyles = makeStyles((theme) => ({ marginBottom: theme.spacing(1), padding: theme.spacing(6), borderRadius: theme.shape.borderRadius, + border: `1px solid ${theme.palette.divider}`, [theme.breakpoints.down("sm")]: { padding: theme.spacing(4, 3, 4, 3), diff --git a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.stories.tsx b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.stories.tsx index cb24e1316dc5e..7d1957bcdff8d 100644 --- a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.stories.tsx +++ b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.stories.tsx @@ -3,12 +3,10 @@ import dayjs from "dayjs" import advancedFormat from "dayjs/plugin/advancedFormat" import timezone from "dayjs/plugin/timezone" import utc from "dayjs/plugin/utc" +import { defaultSchedule, emptySchedule } from "pages/WorkspaceSchedulePage/schedule" +import { defaultTTL, emptyTTL } from "pages/WorkspaceSchedulePage/ttl" import { makeMockApiError } from "testHelpers/entities" -import { - defaultWorkspaceSchedule, - WorkspaceScheduleForm, - WorkspaceScheduleFormProps, -} from "./WorkspaceScheduleForm" +import { WorkspaceScheduleForm, WorkspaceScheduleFormProps } from "./WorkspaceScheduleForm" dayjs.extend(advancedFormat) dayjs.extend(utc) @@ -29,51 +27,60 @@ export default { const Template: Story = (args) => -export const WorkspaceWillNotShutDown = Template.bind({}) -WorkspaceWillNotShutDown.args = { +const defaultInitialValues = { + autoStartEnabled: true, + ...defaultSchedule(), + autoStopEnabled: true, + ttl: defaultTTL, +} + +export const AllDisabled = Template.bind({}) +AllDisabled.args = { initialValues: { - ...defaultWorkspaceSchedule(5), - ttl: 0, + autoStartEnabled: false, + ...emptySchedule, + autoStopEnabled: false, + ttl: emptyTTL, }, } -export const WorkspaceWillShutdownInAnHour = Template.bind({}) -WorkspaceWillShutdownInAnHour.args = { +export const AutoStart = Template.bind({}) +AutoStart.args = { initialValues: { - ...defaultWorkspaceSchedule(5), - ttl: 1, + autoStartEnabled: true, + ...defaultSchedule(), + autoStopEnabled: false, + ttl: emptyTTL, }, } export const WorkspaceWillShutdownInTwoHours = Template.bind({}) WorkspaceWillShutdownInTwoHours.args = { - initialValues: { - ...defaultWorkspaceSchedule(2), - ttl: 2, - }, + initialValues: { ...defaultInitialValues, ttl: 2 }, } export const WorkspaceWillShutdownInADay = Template.bind({}) WorkspaceWillShutdownInADay.args = { - initialValues: { - ...defaultWorkspaceSchedule(2), - ttl: 24, - }, + initialValues: { ...defaultInitialValues, ttl: 24 }, } export const WorkspaceWillShutdownInTwoDays = Template.bind({}) WorkspaceWillShutdownInTwoDays.args = { - initialValues: { - ...defaultWorkspaceSchedule(2), - ttl: 48, - }, + initialValues: { ...defaultInitialValues, ttl: 48 }, } export const WithError = Template.bind({}) WithError.args = { + initialValues: { ...defaultInitialValues, ttl: 100 }, initialTouched: { ttl: true }, submitScheduleError: makeMockApiError({ message: "Something went wrong.", validations: [{ field: "ttl_ms", detail: "Invalid time until shutdown." }], }), } + +export const Loading = Template.bind({}) +Loading.args = { + initialValues: defaultInitialValues, + isLoading: true, +} diff --git a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.test.ts b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.test.ts index 0b08446f0fcc8..101635a13cd00 100644 --- a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.test.ts +++ b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.test.ts @@ -7,6 +7,7 @@ import { import { zones } from "./zones" const valid: WorkspaceScheduleFormValues = { + autoStartEnabled: true, sunday: false, monday: true, tuesday: true, @@ -14,15 +15,17 @@ const valid: WorkspaceScheduleFormValues = { thursday: true, friday: true, saturday: false, - startTime: "09:30", timezone: "Canada/Eastern", + + autoStopEnabled: true, ttl: 120, } describe("validationSchema", () => { - it("allows everything to be falsy", () => { + it("allows everything to be falsy when switches are off", () => { const values: WorkspaceScheduleFormValues = { + autoStartEnabled: false, sunday: false, monday: false, tuesday: false, @@ -30,9 +33,10 @@ describe("validationSchema", () => { thursday: false, friday: false, saturday: false, - startTime: "", timezone: "", + + autoStopEnabled: false, ttl: 0, } const validate = () => validationSchema.validateSync(values) @@ -48,7 +52,7 @@ describe("validationSchema", () => { expect(validate).toThrow() }) - it("disallows all days-of-week to be false when startTime is set", () => { + it("disallows all days-of-week to be false when auto-start is enabled", () => { const values: WorkspaceScheduleFormValues = { ...valid, sunday: false, @@ -63,7 +67,7 @@ describe("validationSchema", () => { expect(validate).toThrowError(Language.errorNoDayOfWeek) }) - it("disallows empty startTime when at least one day is set", () => { + it("disallows empty startTime when auto-start is enabled", () => { const values: WorkspaceScheduleFormValues = { ...valid, sunday: false, diff --git a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx index 6eb500550ff38..94c378c7f7e95 100644 --- a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx +++ b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx @@ -6,8 +6,10 @@ import FormHelperText from "@material-ui/core/FormHelperText" import FormLabel from "@material-ui/core/FormLabel" import MenuItem from "@material-ui/core/MenuItem" import makeStyles from "@material-ui/core/styles/makeStyles" +import Switch from "@material-ui/core/Switch" import TextField from "@material-ui/core/TextField" import { ErrorSummary } from "components/ErrorSummary/ErrorSummary" +import { Section } from "components/Section/Section" import dayjs from "dayjs" import advancedFormat from "dayjs/plugin/advancedFormat" import duration from "dayjs/plugin/duration" @@ -15,7 +17,9 @@ import relativeTime from "dayjs/plugin/relativeTime" import timezone from "dayjs/plugin/timezone" import utc from "dayjs/plugin/utc" import { FormikTouched, useFormik } from "formik" -import { FC } from "react" +import { defaultSchedule } from "pages/WorkspaceSchedulePage/schedule" +import { defaultTTL } from "pages/WorkspaceSchedulePage/ttl" +import { ChangeEvent, FC } from "react" import * as Yup from "yup" import { getFormHelpersWithError } from "../../util/formUtils" import { FormFooter } from "../FormFooter/FormFooter" @@ -32,10 +36,11 @@ dayjs.extend(relativeTime) dayjs.extend(timezone) export const Language = { - errorNoDayOfWeek: "Must set at least one day of week if start time is set", - errorNoTime: "Start time is required when days of the week are selected", + errorNoDayOfWeek: "Must set at least one day of week if auto-start is enabled", + errorNoTime: "Start time is required when auto-start is enabled", errorTime: "Time must be in HH:mm format (24 hours)", errorTimezone: "Invalid timezone", + errorNoStop: "Time until shutdown must be greater than zero when auto-stop is enabled", daysOfWeekLabel: "Days of Week", daySundayLabel: "Sunday", dayMondayLabel: "Monday", @@ -51,11 +56,16 @@ export const Language = { ttlCausesShutdownHelperText: "Your workspace will shut down", ttlCausesShutdownAfterStart: "after start", ttlCausesNoShutdownHelperText: "Your workspace will not automatically shut down.", + formTitle: "Workspace schedule", + startSection: "Start", + startSwitch: "Auto-start", + stopSection: "Stop", + stopSwitch: "Auto-stop", } export interface WorkspaceScheduleFormProps { submitScheduleError?: Error | unknown - initialValues?: WorkspaceScheduleFormValues + initialValues: WorkspaceScheduleFormValues isLoading: boolean onCancel: () => void onSubmit: (values: WorkspaceScheduleFormValues) => void @@ -64,6 +74,7 @@ export interface WorkspaceScheduleFormProps { } export interface WorkspaceScheduleFormValues { + autoStartEnabled: boolean sunday: boolean monday: boolean tuesday: boolean @@ -71,18 +82,20 @@ export interface WorkspaceScheduleFormValues { thursday: boolean friday: boolean saturday: boolean - startTime: string timezone: string + + autoStopEnabled: boolean ttl: number } +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export const validationSchema = Yup.object({ sunday: Yup.boolean(), monday: Yup.boolean().test("at-least-one-day", Language.errorNoDayOfWeek, function (value) { const parent = this.parent as WorkspaceScheduleFormValues - if (!parent.startTime) { + if (!parent.autoStartEnabled) { return true } else { return ![ @@ -104,20 +117,9 @@ export const validationSchema = Yup.object({ startTime: Yup.string() .ensure() - .test("required-if-day-selected", Language.errorNoTime, function (value) { + .test("required-if-auto-start", Language.errorNoTime, function (value) { const parent = this.parent as WorkspaceScheduleFormValues - - const isDaySelected = [ - parent.sunday, - parent.monday, - parent.tuesday, - parent.wednesday, - parent.thursday, - parent.friday, - parent.saturday, - ].some((day) => day) - - if (isDaySelected) { + if (parent.autoStartEnabled) { return value !== "" } else { return true @@ -157,31 +159,20 @@ export const validationSchema = Yup.object({ ttl: Yup.number() .integer() .min(0) - .max(24 * 7 /* 7 days */), -}) - -export const defaultWorkspaceScheduleTTL = 8 - -export const defaultWorkspaceSchedule = ( - ttl = defaultWorkspaceScheduleTTL, - timezone = dayjs.tz.guess(), -): WorkspaceScheduleFormValues => ({ - sunday: false, - monday: true, - tuesday: true, - wednesday: true, - thursday: true, - friday: true, - saturday: false, - - startTime: "09:30", - timezone, - ttl, + .max(24 * 7 /* 7 days */) + .test("positive-if-auto-stop", Language.errorNoStop, function (value) { + const parent = this.parent as WorkspaceScheduleFormValues + if (parent.autoStopEnabled) { + return !!value + } else { + return true + } + }), }) export const WorkspaceScheduleForm: FC = ({ submitScheduleError, - initialValues = defaultWorkspaceSchedule(), + initialValues, isLoading, onCancel, onSubmit, @@ -210,72 +201,115 @@ export const WorkspaceScheduleForm: FC = ({ { value: form.values.saturday, name: "saturday", label: Language.daySaturdayLabel }, ] + const handleToggleAutoStart = async (e: ChangeEvent) => { + form.handleChange(e) + // if enabling from empty values, fill with defaults + if (!form.values.autoStartEnabled && !form.values.startTime) { + await form.setValues({ ...form.values, autoStartEnabled: true, ...defaultSchedule() }) + } + } + + const handleToggleAutoStop = async (e: ChangeEvent) => { + form.handleChange(e) + // if enabling from empty values, fill with defaults + if (!form.values.autoStopEnabled && !form.values.ttl) { + await form.setFieldValue("ttl", defaultTTL) + } + } + return ( - +
{submitScheduleError && } - +
+ + } + label={Language.startSwitch} + /> + - - {zones.map((zone) => ( - - {zone} - - ))} - + + {zones.map((zone) => ( + + {zone} + + ))} + - - - {Language.daysOfWeekLabel} - + + + {Language.daysOfWeekLabel} + - - {checkboxes.map((checkbox) => ( - - } - key={checkbox.name} - label={checkbox.label} - /> - ))} - + + {checkboxes.map((checkbox) => ( + + } + key={checkbox.name} + label={checkbox.label} + /> + ))} + - {form.errors.monday && {Language.errorNoDayOfWeek}} - + {form.errors.monday && {Language.errorNoDayOfWeek}} + +
- +
+ + } + label={Language.stopSwitch} + /> + +
diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx index 1f440e18eea5c..db4abe2481dd9 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx @@ -1,13 +1,14 @@ -import * as TypesGen from "../../api/typesGenerated" -import { WorkspaceScheduleFormValues } from "../../components/WorkspaceScheduleForm/WorkspaceScheduleForm" -import * as Mocks from "../../testHelpers/entities" import { formValuesToAutoStartRequest, formValuesToTTLRequest, - workspaceToInitialValues, -} from "./WorkspaceSchedulePage" +} from "pages/WorkspaceSchedulePage/formToRequest" +import { AutoStart, scheduleToAutoStart } from "pages/WorkspaceSchedulePage/schedule" +import { AutoStop, ttlMsToAutoStop } from "pages/WorkspaceSchedulePage/ttl" +import * as TypesGen from "../../api/typesGenerated" +import { WorkspaceScheduleFormValues } from "../../components/WorkspaceScheduleForm/WorkspaceScheduleForm" const validValues: WorkspaceScheduleFormValues = { + autoStartEnabled: true, sunday: false, monday: true, tuesday: true, @@ -17,6 +18,7 @@ const validValues: WorkspaceScheduleFormValues = { saturday: false, startTime: "09:30", timezone: "Canada/Eastern", + autoStopEnabled: true, ttl: 120, } @@ -26,6 +28,7 @@ describe("WorkspaceSchedulePage", () => { [ // Empty case { + autoStartEnabled: false, sunday: false, monday: false, tuesday: false, @@ -35,6 +38,7 @@ describe("WorkspaceSchedulePage", () => { saturday: false, startTime: "", timezone: "", + autoStopEnabled: false, ttl: 0, }, { @@ -44,6 +48,7 @@ describe("WorkspaceSchedulePage", () => { [ // Single day { + autoStartEnabled: true, sunday: true, monday: false, tuesday: false, @@ -53,6 +58,7 @@ describe("WorkspaceSchedulePage", () => { saturday: false, startTime: "16:20", timezone: "Canada/Eastern", + autoStopEnabled: true, ttl: 120, }, { @@ -62,6 +68,7 @@ describe("WorkspaceSchedulePage", () => { [ // Standard 1-5 case { + autoStartEnabled: true, sunday: false, monday: true, tuesday: true, @@ -71,6 +78,7 @@ describe("WorkspaceSchedulePage", () => { saturday: false, startTime: "09:30", timezone: "America/Central", + autoStopEnabled: true, ttl: 120, }, { @@ -80,6 +88,7 @@ describe("WorkspaceSchedulePage", () => { [ // Everyday { + autoStartEnabled: true, sunday: true, monday: true, tuesday: true, @@ -89,6 +98,7 @@ describe("WorkspaceSchedulePage", () => { saturday: true, startTime: "09:00", timezone: "", + autoStopEnabled: true, ttl: 60 * 8, }, { @@ -98,6 +108,7 @@ describe("WorkspaceSchedulePage", () => { [ // Mon, Wed, Fri Evenings { + autoStartEnabled: true, sunday: false, monday: true, tuesday: false, @@ -107,6 +118,7 @@ describe("WorkspaceSchedulePage", () => { saturday: false, startTime: "16:20", timezone: "", + autoStopEnabled: true, ttl: 60 * 3, }, { @@ -155,61 +167,30 @@ describe("WorkspaceSchedulePage", () => { }) }) - describe("workspaceToInitialValues", () => { - it.each<[TypesGen.Workspace, WorkspaceScheduleFormValues]>([ + describe("scheduleToAutoStart", () => { + it.each<[string | undefined, AutoStart]>([ // Empty case [ + undefined, { - ...Mocks.MockWorkspace, - autostart_schedule: undefined, - ttl_ms: undefined, - }, - { - sunday: false, - monday: true, - tuesday: true, - wednesday: true, - thursday: true, - friday: true, - saturday: false, - startTime: "09:30", - timezone: "", - ttl: 8, - }, - ], - - // ttl-only case (2 hours) - [ - { - ...Mocks.MockWorkspace, - autostart_schedule: "", - ttl_ms: 7_200_000, - }, - { + autoStartEnabled: false, sunday: false, - monday: true, - tuesday: true, - wednesday: true, - thursday: true, - friday: true, + monday: false, + tuesday: false, + wednesday: false, + thursday: false, + friday: false, saturday: false, - startTime: "09:30", + 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! + // Basic case: 9:30 1-5 UTC [ + "CRON_TZ=UTC 30 9 * * 1-5", { - ...Mocks.MockWorkspace, - autostart_schedule: "CRON_TZ=UTC 30 9 * * 1-5", - ttl_ms: 7_200_000, - }, - { + autoStartEnabled: true, sunday: false, monday: true, tuesday: true, @@ -219,18 +200,14 @@ describe("WorkspaceSchedulePage", () => { saturday: false, startTime: "09:30", timezone: "UTC", - ttl: 2, }, ], - // Complex case: 4:20 1 3-4 6 Canada/Eastern for 8 hours + // Complex case: 4:20 1 3-4 6 Canada/Eastern [ + "CRON_TZ=Canada/Eastern 20 16 * * 1,3-4,6", { - ...Mocks.MockWorkspace, - autostart_schedule: "CRON_TZ=Canada/Eastern 20 16 * * 1,3-4,6", - ttl_ms: 28_800_000, - }, - { + autoStartEnabled: true, sunday: false, monday: true, tuesday: false, @@ -240,11 +217,23 @@ describe("WorkspaceSchedulePage", () => { saturday: true, startTime: "16:20", timezone: "Canada/Eastern", - ttl: 8, }, ], - ])(`workspaceToInitialValues(%p) returns %p`, (workspace, formValues) => { - expect(workspaceToInitialValues(workspace)).toEqual(formValues) + ])(`scheduleToAutoStart(%p) returns %p`, (schedule, autoStart) => { + expect(scheduleToAutoStart(schedule)).toEqual(autoStart) + }) + }) + + describe("ttlMsToAutoStop", () => { + it.each<[number | undefined, AutoStop]>([ + // empty case + [undefined, { autoStopEnabled: false, ttl: 0 }], + // zero + [0, { autoStopEnabled: false, ttl: 0 }], + // basic case + [28_800_000, { autoStopEnabled: true, ttl: 8 }], + ])(`ttlMsToAutoStop(%p) returns %p`, (ttlMs, autoStop) => { + expect(ttlMsToAutoStop(ttlMs)).toEqual(autoStop) }) }) }) diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index 3e74c1e17a6ad..b20be6b5ded83 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -1,30 +1,17 @@ import { useMachine, useSelector } from "@xstate/react" -import * as cronParser from "cron-parser" -import dayjs from "dayjs" -import timezone from "dayjs/plugin/timezone" -import utc from "dayjs/plugin/utc" -import React, { useContext, useEffect } from "react" -import { useNavigate, useParams } from "react-router-dom" +import { scheduleToAutoStart } from "pages/WorkspaceSchedulePage/schedule" +import { ttlMsToAutoStop } from "pages/WorkspaceSchedulePage/ttl" +import React, { useContext, useEffect, useState } from "react" +import { Navigate, 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 { - defaultWorkspaceSchedule, - defaultWorkspaceScheduleTTL, - WorkspaceScheduleForm, - WorkspaceScheduleFormValues, -} from "../../components/WorkspaceScheduleForm/WorkspaceScheduleForm" +import { WorkspaceScheduleForm } from "../../components/WorkspaceScheduleForm/WorkspaceScheduleForm" import { firstOrItem } from "../../util/array" -import { extractTimezone, stripTimezone } from "../../util/schedule" import { selectUser } from "../../xServices/auth/authSelectors" import { XServiceContext } from "../../xServices/StateContext" 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) +import { formValuesToAutoStartRequest, formValuesToTTLRequest } from "./formToRequest" const Language = { forbiddenError: "You don't have permissions to update the schedule for this workspace.", @@ -32,118 +19,6 @@ const Language = { checkPermissionsError: "Failed to fetch permissions.", } -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_ms: values.ttl ? values.ttl * 60 * 60 * 1000 : undefined, - } -} - -export const workspaceToInitialValues = ( - workspace: TypesGen.Workspace, - defaultTimeZone = "", -): WorkspaceScheduleFormValues => { - const schedule = workspace.autostart_schedule - const ttlHours = workspace.ttl_ms - ? Math.round(workspace.ttl_ms / (1000 * 60 * 60)) - : defaultWorkspaceScheduleTTL - - if (!schedule) { - return defaultWorkspaceSchedule(ttlHours, defaultTimeZone) - } - - const timezone = extractTimezone(schedule, defaultTimeZone) - - const expression = cronParser.parseExpression(stripTimezone(schedule)) - - const HH = expression.fields.hour.join("").padStart(2, "0") - const mm = expression.fields.minute.join("").padStart(2, "0") - - const weeklyFlags = [false, false, false, false, false, false, false] - - for (const day of expression.fields.dayOfWeek) { - weeklyFlags[day % 7] = true - } - - return { - sunday: weeklyFlags[0], - monday: weeklyFlags[1], - tuesday: weeklyFlags[2], - wednesday: weeklyFlags[3], - thursday: weeklyFlags[4], - friday: weeklyFlags[5], - saturday: weeklyFlags[6], - startTime: `${HH}:${mm}`, - timezone, - ttl: ttlHours, - } -} - export const WorkspaceSchedulePage: React.FC = () => { const { username: usernameQueryParam, workspace: workspaceQueryParam } = useParams() const navigate = useNavigate() @@ -167,9 +42,20 @@ export const WorkspaceSchedulePage: React.FC = () => { username && workspaceName && scheduleSend({ type: "GET_WORKSPACE", username, workspaceName }) }, [username, workspaceName, scheduleSend]) + const getAutoStart = (workspace?: TypesGen.Workspace) => + scheduleToAutoStart(workspace?.autostart_schedule) + const getAutoStop = (workspace?: TypesGen.Workspace) => ttlMsToAutoStop(workspace?.ttl_ms) + + const [autoStart, setAutoStart] = useState(getAutoStart(workspace)) + const [autoStop, setAutoStop] = useState(getAutoStop(workspace)) + + useEffect(() => { + setAutoStart(getAutoStart(workspace)) + setAutoStop(getAutoStop(workspace)) + }, [workspace]) + if (!username || !workspaceName) { - navigate("/workspaces") - return null + return } if ( @@ -201,7 +87,7 @@ export const WorkspaceSchedulePage: React.FC = () => { return ( { navigate(`/@${username}/${workspaceName}`) @@ -218,12 +104,10 @@ export const WorkspaceSchedulePage: React.FC = () => { } if (scheduleState.matches("submitSuccess")) { - navigate(`/@${username}/${workspaceName}`) - return + return } // Theoretically impossible - log and bail console.error("WorkspaceSchedulePage: unknown state :: ", scheduleState) - navigate("/") - return null + return } diff --git a/site/src/pages/WorkspaceSchedulePage/formToRequest.ts b/site/src/pages/WorkspaceSchedulePage/formToRequest.ts new file mode 100644 index 0000000000000..8802139c769d6 --- /dev/null +++ b/site/src/pages/WorkspaceSchedulePage/formToRequest.ts @@ -0,0 +1,74 @@ +import * as TypesGen from "api/typesGenerated" +import { WorkspaceScheduleFormValues } from "components/WorkspaceScheduleForm/WorkspaceScheduleForm" + +export const formValuesToAutoStartRequest = ( + values: WorkspaceScheduleFormValues, +): TypesGen.UpdateWorkspaceAutostartRequest => { + if (!values.autoStartEnabled || !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_ms: values.autoStopEnabled && values.ttl ? values.ttl * 60 * 60 * 1000 : undefined, + } +} diff --git a/site/src/pages/WorkspaceSchedulePage/schedule.ts b/site/src/pages/WorkspaceSchedulePage/schedule.ts new file mode 100644 index 0000000000000..ef08da81e9193 --- /dev/null +++ b/site/src/pages/WorkspaceSchedulePage/schedule.ts @@ -0,0 +1,91 @@ +import * as cronParser from "cron-parser" +import dayjs from "dayjs" +import timezone from "dayjs/plugin/timezone" +import utc from "dayjs/plugin/utc" +import { extractTimezone, stripTimezone } from "../../util/schedule" + +// REMARK: timezone plugin depends on UTC +// +// SEE: https://day.js.org/docs/en/timezone/timezone +dayjs.extend(utc) +dayjs.extend(timezone) + +export interface AutoStartSchedule { + sunday: boolean + monday: boolean + tuesday: boolean + wednesday: boolean + thursday: boolean + friday: boolean + saturday: boolean + startTime: string + timezone: string +} + +export type AutoStart = { + autoStartEnabled: boolean +} & AutoStartSchedule + +export const emptySchedule = { + sunday: false, + monday: false, + tuesday: false, + wednesday: false, + thursday: false, + friday: false, + saturday: false, + + startTime: "", + timezone: "", +} + +export const defaultSchedule = (): AutoStartSchedule => ({ + sunday: false, + monday: true, + tuesday: true, + wednesday: true, + thursday: true, + friday: true, + saturday: false, + + startTime: "09:30", + timezone: dayjs.tz.guess(), +}) + +const transformSchedule = (schedule: string) => { + const timezone = extractTimezone(schedule, dayjs.tz.guess()) + + const expression = cronParser.parseExpression(stripTimezone(schedule)) + + const HH = expression.fields.hour.join("").padStart(2, "0") + const mm = expression.fields.minute.join("").padStart(2, "0") + + const weeklyFlags = [false, false, false, false, false, false, false] + + for (const day of expression.fields.dayOfWeek) { + weeklyFlags[day % 7] = true + } + + return { + sunday: weeklyFlags[0], + monday: weeklyFlags[1], + tuesday: weeklyFlags[2], + wednesday: weeklyFlags[3], + thursday: weeklyFlags[4], + friday: weeklyFlags[5], + saturday: weeklyFlags[6], + startTime: `${HH}:${mm}`, + timezone, + } +} + +export const scheduleToAutoStart = (schedule?: string): AutoStart => { + if (schedule) { + return { + autoStartEnabled: true, + ...transformSchedule(schedule), + } + } else { + return { autoStartEnabled: false, ...emptySchedule } + } +} diff --git a/site/src/pages/WorkspaceSchedulePage/ttl.ts b/site/src/pages/WorkspaceSchedulePage/ttl.ts new file mode 100644 index 0000000000000..0d82563b64ff8 --- /dev/null +++ b/site/src/pages/WorkspaceSchedulePage/ttl.ts @@ -0,0 +1,13 @@ +export interface AutoStop { + autoStopEnabled: boolean + ttl: number +} + +export const emptyTTL = 0 + +export const defaultTTL = 8 + +const msToHours = (ms: number) => Math.round(ms / (1000 * 60 * 60)) + +export const ttlMsToAutoStop = (ttl_ms?: number): AutoStop => + ttl_ms ? { autoStopEnabled: true, ttl: msToHours(ttl_ms) } : { autoStopEnabled: false, ttl: 0 }