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 } 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