diff --git a/coderd/autostart/schedule/schedule.go b/coderd/autostart/schedule/schedule.go index 11cd8a0030ec6..db9eeb9f3e4d7 100644 --- a/coderd/autostart/schedule/schedule.go +++ b/coderd/autostart/schedule/schedule.go @@ -26,6 +26,7 @@ var defaultParser = cron.NewParser(parserFormatWeekly) // local_sched, _ := schedule.Weekly("59 23 *") // fmt.Println(sched.Next(time.Now().Format(time.RFC3339))) // // Output: 2022-04-04T23:59:00Z +// // us_sched, _ := schedule.Weekly("CRON_TZ=US/Central 30 9 1-5") // fmt.Println(sched.Next(time.Now()).Format(time.RFC3339)) // // Output: 2022-04-04T14:30:00Z diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index b066b41285ca7..05f42cd06e0d2 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -92,7 +92,7 @@ func (c *Client) WorkspaceBuildByName(ctx context.Context, workspace uuid.UUID, // UpdateWorkspaceAutostartRequest is a request to update a workspace's autostart schedule. type UpdateWorkspaceAutostartRequest struct { - Schedule string + Schedule string `json:"schedule"` } // UpdateWorkspaceAutostart sets the autostart schedule for workspace by id. @@ -112,7 +112,7 @@ func (c *Client) UpdateWorkspaceAutostart(ctx context.Context, id uuid.UUID, req // UpdateWorkspaceAutostopRequest is a request to update a workspace's autostop schedule. type UpdateWorkspaceAutostopRequest struct { - Schedule string + Schedule string `json:"schedule"` } // UpdateWorkspaceAutostop sets the autostop schedule for workspace by id. diff --git a/site/package.json b/site/package.json index eb1cf82ef96d0..ea777820752ca 100644 --- a/site/package.json +++ b/site/package.json @@ -32,6 +32,7 @@ "@xstate/inspect": "0.6.5", "@xstate/react": "3.0.0", "axios": "0.26.1", + "cronstrue": "2.2.0", "formik": "2.2.9", "history": "5.3.0", "react": "17.0.2", diff --git a/site/src/api/index.ts b/site/src/api/index.ts index 764e32489f438..135ac91646339 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -73,3 +73,23 @@ export const getBuildInfo = async (): Promise => { const response = await axios.get("/api/v2/buildinfo") return response.data } + +export const putWorkspaceAutostart = async ( + workspaceID: string, + autostart: Types.WorkspaceAutostartRequest, +): Promise => { + const payload = JSON.stringify(autostart) + await axios.put(`/api/v2/workspaces/${workspaceID}/autostart`, payload, { + headers: { ...CONTENT_TYPE_JSON }, + }) +} + +export const putWorkspaceAutostop = async ( + workspaceID: string, + autostop: Types.WorkspaceAutostopRequest, +): Promise => { + const payload = JSON.stringify(autostop) + await axios.put(`/api/v2/workspaces/${workspaceID}/autostop`, payload, { + headers: { ...CONTENT_TYPE_JSON }, + }) +} diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 04f5192fd5279..e61faf9d99634 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -54,7 +54,9 @@ export interface CreateWorkspaceRequest { template_id: string } -// Must be kept in sync with backend Workspace struct +/** + * @remarks Keep in sync with codersdk/workspaces.go + */ export interface Workspace { id: string created_at: string @@ -62,6 +64,8 @@ export interface Workspace { owner_id: string template_id: string name: string + autostart_schedule: string + autostop_schedule: string } export interface APIKeyResponse { @@ -74,3 +78,11 @@ export interface UserAgent { readonly ip_address: string readonly os: string } + +export interface WorkspaceAutostartRequest { + schedule: string +} + +export interface WorkspaceAutostopRequest { + schedule: string +} diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 5f96162f42cfd..0d419f67295cc 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -7,6 +7,7 @@ import React from "react" import { Link } from "react-router-dom" import * as Types from "../../api/types" import * as Constants from "./constants" +import { WorkspaceSchedule } from "./WorkspaceSchedule" import { WorkspaceSection } from "./WorkspaceSection" export interface WorkspaceProps { @@ -30,6 +31,7 @@ export const Workspace: React.FC = ({ organization, template, wo + diff --git a/site/src/components/Workspace/WorkspaceSchedule.stories.tsx b/site/src/components/Workspace/WorkspaceSchedule.stories.tsx new file mode 100644 index 0000000000000..ea687c343637d --- /dev/null +++ b/site/src/components/Workspace/WorkspaceSchedule.stories.tsx @@ -0,0 +1,17 @@ +import { Story } from "@storybook/react" +import React from "react" +import { MockWorkspaceAutostartEnabled } from "../../test_helpers" +import { WorkspaceSchedule, WorkspaceScheduleProps } from "./WorkspaceSchedule" + +export default { + title: "Workspaces/WorkspaceSchedule", + component: WorkspaceSchedule, +} + +const Template: Story = (args) => + +export const Example = Template.bind({}) +Example.args = { + autostart: MockWorkspaceAutostartEnabled.schedule, + autostop: "", +} diff --git a/site/src/components/Workspace/WorkspaceSchedule.tsx b/site/src/components/Workspace/WorkspaceSchedule.tsx new file mode 100644 index 0000000000000..bda8581fe03af --- /dev/null +++ b/site/src/components/Workspace/WorkspaceSchedule.tsx @@ -0,0 +1,59 @@ +import Box from "@material-ui/core/Box" +import Typography from "@material-ui/core/Typography" +import cronstrue from "cronstrue" +import React from "react" +import { expandScheduleCronString, extractTimezone } from "../../util/schedule" +import { WorkspaceSection } from "./WorkspaceSection" + +const Language = { + autoStartLabel: (schedule: string): string => { + const prefix = "Workspace start" + + if (schedule) { + return `${prefix} (${extractTimezone(schedule)})` + } else { + return prefix + } + }, + autoStopLabel: (schedule: string): string => { + const prefix = "Workspace shutdown" + + if (schedule) { + return `${prefix} (${extractTimezone(schedule)})` + } else { + return prefix + } + }, + cronHumanDisplay: (schedule: string): string => { + if (schedule) { + return cronstrue.toString(expandScheduleCronString(schedule), { throwExceptionOnParseError: false }) + } + return "Manual" + }, +} + +export interface WorkspaceScheduleProps { + autostart: string + autostop: string +} + +/** + * WorkspaceSchedule displays a workspace schedule in a human-readable format + * + * @remarks Visual Component + */ +export const WorkspaceSchedule: React.FC = ({ autostart, autostop }) => { + return ( + + + {Language.autoStartLabel(autostart)} + {Language.cronHumanDisplay(autostart)} + + + + {Language.autoStopLabel(autostop)} + {Language.cronHumanDisplay(autostop)} + + + ) +} diff --git a/site/src/test_helpers/entities.ts b/site/src/test_helpers/entities.ts index b3f6614e63ba3..661608cef3077 100644 --- a/site/src/test_helpers/entities.ts +++ b/site/src/test_helpers/entities.ts @@ -6,6 +6,7 @@ import { UserAgent, UserResponse, Workspace, + WorkspaceAutostartRequest, } from "../api/types" export const MockSessionToken = { session_token: "my-session-token" } @@ -46,6 +47,25 @@ export const MockTemplate: Template = { active_version_id: "", } +export const MockWorkspaceAutostartDisabled: WorkspaceAutostartRequest = { + schedule: "", +} + +export const MockWorkspaceAutostartEnabled: WorkspaceAutostartRequest = { + // Runs at 9:30am Monday through Friday using Canada/Eastern + // (America/Toronto) time + schedule: "CRON_TZ=Canada/Eastern 30 9 1-5", +} + +export const MockWorkspaceAutostopDisabled: WorkspaceAutostartRequest = { + schedule: "", +} + +export const MockWorkspaceAutostopEnabled: WorkspaceAutostartRequest = { + // Runs at 9:30pm Monday through Friday using America/Toronto + schedule: "CRON_TZ=America/Toronto 30 21 1-5", +} + export const MockWorkspace: Workspace = { id: "test-workspace", name: "Test-Workspace", @@ -53,6 +73,8 @@ export const MockWorkspace: Workspace = { updated_at: "", template_id: MockTemplate.id, owner_id: MockUser.id, + autostart_schedule: MockWorkspaceAutostartEnabled.schedule, + autostop_schedule: MockWorkspaceAutostopEnabled.schedule, } export const MockUserAgent: UserAgent = { diff --git a/site/src/test_helpers/handlers.ts b/site/src/test_helpers/handlers.ts index a7de1446ba9d6..2875a9ed86320 100644 --- a/site/src/test_helpers/handlers.ts +++ b/site/src/test_helpers/handlers.ts @@ -44,4 +44,10 @@ export const handlers = [ rest.get("/api/v2/workspaces/:workspaceId", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockWorkspace)) }), + rest.put("/api/v2/workspaces/:workspaceId/autostart", async (req, res, ctx) => { + return res(ctx.status(200)) + }), + rest.put("/api/v2/workspaces/:workspaceId/autostop", async (req, res, ctx) => { + return res(ctx.status(200)) + }), ] diff --git a/site/src/util/schedule.test.ts b/site/src/util/schedule.test.ts new file mode 100644 index 0000000000000..6cf936b0d1e24 --- /dev/null +++ b/site/src/util/schedule.test.ts @@ -0,0 +1,33 @@ +import { expandScheduleCronString, extractTimezone, stripTimezone } from "./schedule" + +describe("util/schedule", () => { + describe("stripTimezone", () => { + it.each<[string, string]>([ + ["CRON_TZ=Canada/Eastern 30 9 1-5", "30 9 1-5"], + ["CRON_TZ=America/Central 0 8 1,2,4,5", "0 8 1,2,4,5"], + ["30 9 1-5", "30 9 1-5"], + ])(`stripTimezone(%p) returns %p`, (input, expected) => { + expect(stripTimezone(input)).toBe(expected) + }) + }) + + describe("extractTimezone", () => { + it.each<[string, string]>([ + ["CRON_TZ=Canada/Eastern 30 9 1-5", "Canada/Eastern"], + ["CRON_TZ=America/Central 0 8 1,2,4,5", "America/Central"], + ["30 9 1-5", "UTC"], + ])(`extractTimezone(%p) returns %p`, (input, expected) => { + expect(extractTimezone(input)).toBe(expected) + }) + }) + + describe("expandScheduleCronString", () => { + it.each<[string, string]>([ + ["CRON_TZ=Canada/Eastern 30 9 1-5", "30 9 * * 1-5"], + ["CRON_TZ=America/Central 0 8 1,2,4,5", "0 8 * * 1,2,4,5"], + ["30 9 1-5", "30 9 * * 1-5"], + ])(`expandScheduleCronString(%p) returns %p`, (input, expected) => { + expect(expandScheduleCronString(input)).toBe(expected) + }) + }) +}) diff --git a/site/src/util/schedule.ts b/site/src/util/schedule.ts new file mode 100644 index 0000000000000..dc082233a7a8e --- /dev/null +++ b/site/src/util/schedule.ts @@ -0,0 +1,54 @@ +/** + * @fileoverview Client-side counterpart of the coderd/autostart/schedule Go + * package. This package is a variation on crontab that uses minute, hour and + * day of week. + */ + +/** + * DEFAULT_TIMEZONE is the default timezone that crontab assumes unless one is + * specified. + */ +const DEFAULT_TIMEZONE = "UTC" + +/** + * stripTimezone strips a leading timezone from a schedule string + */ +export const stripTimezone = (raw: string): string => { + return raw.replace(/CRON_TZ=\S*\s/, "") +} + +/** + * extractTimezone returns a leading timezone from a schedule string if one is + * specified; otherwise DEFAULT_TIMEZONE + */ +export const extractTimezone = (raw: string): 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 + } +} + +/** + * expandScheduleCronString ensures a Schedule is expanded to a valid 5-value + * cron string by inserting '*' in month and day positions. If there is a + * leading timezone, it is removed. + * + * @example + * expandScheduleCronString("30 9 1-5") // -> "30 9 * * 1-5" + */ +export const expandScheduleCronString = (schedule: string): string => { + const prepared = stripTimezone(schedule).trim() + + const parts = prepared.split(" ") + + while (parts.length < 5) { + // insert '*' in the second to last position + // ie [a, b, c] --> [a, b, *, c] + parts.splice(parts.length - 1, 0, "*") + } + + return parts.join(" ") +} diff --git a/site/yarn.lock b/site/yarn.lock index ecbedc6aebb22..cbeff9c97dad1 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -5458,6 +5458,11 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== +cronstrue@2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-2.2.0.tgz#8e02b8ef0fa70a9eab9999f1f838df4bd378b471" + integrity sha512-oM/ftAvCNIdygVGGfYp8gxrVc81mDSA2mff0kvu6+ehrZhfYPzGHG8DVcFdrRVizjHnzWoFIlgEq6KTM/9lPBw== + cross-fetch@^3.0.4: version "3.1.5" resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f"