Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 22c66a6

Browse files
committed
feat: workspace view for schedules
Summary: This adds the client-side implementation to match the types introduced in #879 and #844 as well as a card in the Workspaces page to present workspace the data. Details: * Added a convenient line break in the example schedule.Weekly * Added missing `json:""` annotations in codersdk/workspaces.go * Installed cronstrue for displaying human-friendly cron strings * Adjusted/Added client-side types to match codersdk/workspaces.go * Added new component WorkspaceSchedule.tsx Next Steps: The WorkspaceSchedule.tsx card only presents data (on purpose). In order to make it PUT/modify data, a few changes will be made: - a form for updating workspace schedule will be created - the form will wrapped in a dialog or modal - the WorkspaceSchedule card will have a way of opening the modal which will likely be generalized up to WorkspaceSection.tsx Impact: This is user-facing This does not fully resolve either #274 or #275 (I may further decompose that work to reflect reality and keep things in small deliverable increments), but adds significant progress towards both.
1 parent ce49966 commit 22c66a6

File tree

13 files changed

+235
-3
lines changed

13 files changed

+235
-3
lines changed

coderd/autostart/schedule/schedule.go

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ var defaultParser = cron.NewParser(parserFormatWeekly)
2626
// local_sched, _ := schedule.Weekly("59 23 *")
2727
// fmt.Println(sched.Next(time.Now().Format(time.RFC3339)))
2828
// // Output: 2022-04-04T23:59:00Z
29+
//
2930
// us_sched, _ := schedule.Weekly("CRON_TZ=US/Central 30 9 1-5")
3031
// fmt.Println(sched.Next(time.Now()).Format(time.RFC3339))
3132
// // Output: 2022-04-04T14:30:00Z

codersdk/workspaces.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ func (c *Client) WorkspaceBuildByName(ctx context.Context, workspace uuid.UUID,
9292

9393
// UpdateWorkspaceAutostartRequest is a request to update a workspace's autostart schedule.
9494
type UpdateWorkspaceAutostartRequest struct {
95-
Schedule string
95+
Schedule string `json:"schedule"`
9696
}
9797

9898
// UpdateWorkspaceAutostart sets the autostart schedule for workspace by id.
@@ -112,7 +112,7 @@ func (c *Client) UpdateWorkspaceAutostart(ctx context.Context, id uuid.UUID, req
112112

113113
// UpdateWorkspaceAutostopRequest is a request to update a workspace's autostop schedule.
114114
type UpdateWorkspaceAutostopRequest struct {
115-
Schedule string
115+
Schedule string `json:"schedule"`
116116
}
117117

118118
// UpdateWorkspaceAutostop sets the autostop schedule for workspace by id.

site/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"@xstate/inspect": "0.6.5",
3333
"@xstate/react": "3.0.0",
3434
"axios": "0.26.1",
35+
"cronstrue": "2.2.0",
3536
"formik": "2.2.9",
3637
"history": "5.3.0",
3738
"react": "17.0.2",

site/src/api/index.ts

+20
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,23 @@ export const getBuildInfo = async (): Promise<Types.BuildInfoResponse> => {
7373
const response = await axios.get("/api/v2/buildinfo")
7474
return response.data
7575
}
76+
77+
export const putWorkspaceAutostart = async (
78+
workspaceID: string,
79+
autostart: Types.WorkspaceAutostartRequest,
80+
): Promise<void> => {
81+
const payload = JSON.stringify(autostart)
82+
await axios.put(`/api/v2/workspaces/${workspaceID}/autostart`, payload, {
83+
headers: { ...CONTENT_TYPE_JSON },
84+
})
85+
}
86+
87+
export const putWorkspaceAutostop = async (
88+
workspaceID: string,
89+
autostop: Types.WorkspaceAutostopRequest,
90+
): Promise<void> => {
91+
const payload = JSON.stringify(autostop)
92+
await axios.put(`/api/v2/workspaces/${workspaceID}/autostop`, payload, {
93+
headers: { ...CONTENT_TYPE_JSON },
94+
})
95+
}

site/src/api/types.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,18 @@ export interface CreateWorkspaceRequest {
5454
template_id: string
5555
}
5656

57-
// Must be kept in sync with backend Workspace struct
57+
/**
58+
* @remarks Keep in sync with codersdk/workspaces.go
59+
*/
5860
export interface Workspace {
5961
id: string
6062
created_at: string
6163
updated_at: string
6264
owner_id: string
6365
template_id: string
6466
name: string
67+
autostart_schedule: string
68+
autostop_schedule: string
6569
}
6670

6771
export interface APIKeyResponse {
@@ -74,3 +78,11 @@ export interface UserAgent {
7478
readonly ip_address: string
7579
readonly os: string
7680
}
81+
82+
export interface WorkspaceAutostartRequest {
83+
schedule: string
84+
}
85+
86+
export interface WorkspaceAutostopRequest {
87+
schedule: string
88+
}

site/src/components/Workspace/Workspace.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import React from "react"
77
import { Link } from "react-router-dom"
88
import * as Types from "../../api/types"
99
import * as Constants from "./constants"
10+
import { WorkspaceSchedule } from "./WorkspaceSchedule"
1011
import { WorkspaceSection } from "./WorkspaceSection"
1112

1213
export interface WorkspaceProps {
@@ -30,6 +31,7 @@ export const Workspace: React.FC<WorkspaceProps> = ({ organization, template, wo
3031
<WorkspaceSection title="Applications">
3132
<Placeholder />
3233
</WorkspaceSection>
34+
<WorkspaceSchedule autostart={workspace.autostart_schedule} autostop={workspace.autostop_schedule} />
3335
<WorkspaceSection title="Dev URLs">
3436
<Placeholder />
3537
</WorkspaceSection>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Story } from "@storybook/react"
2+
import React from "react"
3+
import { MockWorkspaceAutostartEnabled } from "../../test_helpers"
4+
import { WorkspaceSchedule, WorkspaceScheduleProps } from "./WorkspaceSchedule"
5+
6+
export default {
7+
title: "Workspaces/WorkspaceSchedule",
8+
component: WorkspaceSchedule,
9+
}
10+
11+
const Template: Story<WorkspaceScheduleProps> = (args) => <WorkspaceSchedule {...args} />
12+
13+
export const Example = Template.bind({})
14+
Example.args = {
15+
autostart: MockWorkspaceAutostartEnabled.schedule,
16+
autostop: "",
17+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import Box from "@material-ui/core/Box"
2+
import Typography from "@material-ui/core/Typography"
3+
import cronstrue from "cronstrue"
4+
import React from "react"
5+
import { expandScheduleCronString, extractTimezone } from "../../util/schedule"
6+
import { WorkspaceSection } from "./WorkspaceSection"
7+
8+
const Language = {
9+
autoStartLabel: (schedule: string): string => {
10+
const prefix = "Workspace start"
11+
12+
if (schedule) {
13+
return `${prefix} (${extractTimezone(schedule)})`
14+
} else {
15+
return prefix
16+
}
17+
},
18+
autoStopLabel: (schedule: string): string => {
19+
const prefix = "Workspace shutdown"
20+
21+
if (schedule) {
22+
return `${prefix} (${extractTimezone(schedule)})`
23+
} else {
24+
return prefix
25+
}
26+
},
27+
cronHumanDisplay: (schedule: string): string => {
28+
if (schedule) {
29+
return cronstrue.toString(expandScheduleCronString(schedule), { throwExceptionOnParseError: false })
30+
}
31+
return "Manual"
32+
},
33+
}
34+
35+
export interface WorkspaceScheduleProps {
36+
autostart: string
37+
autostop: string
38+
}
39+
40+
/**
41+
* WorkspaceSchedule displays a workspace schedule in a human-readable format
42+
*
43+
* @remarks Visual Component
44+
*/
45+
export const WorkspaceSchedule: React.FC<WorkspaceScheduleProps> = ({ autostart, autostop }) => {
46+
return (
47+
<WorkspaceSection title="Workspace schedule">
48+
<Box mt={2}>
49+
<Typography variant="h6">{Language.autoStartLabel(autostart)}</Typography>
50+
<Typography>{Language.cronHumanDisplay(autostart)}</Typography>
51+
</Box>
52+
53+
<Box mt={2}>
54+
<Typography variant="h6">{Language.autoStopLabel(autostop)}</Typography>
55+
<Typography>{Language.cronHumanDisplay(autostop)}</Typography>
56+
</Box>
57+
</WorkspaceSection>
58+
)
59+
}

site/src/test_helpers/entities.ts

+22
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
UserAgent,
77
UserResponse,
88
Workspace,
9+
WorkspaceAutostartRequest,
910
} from "../api/types"
1011

1112
export const MockSessionToken = { session_token: "my-session-token" }
@@ -46,13 +47,34 @@ export const MockTemplate: Template = {
4647
active_version_id: "",
4748
}
4849

50+
export const MockWorkspaceAutostartDisabled: WorkspaceAutostartRequest = {
51+
schedule: "",
52+
}
53+
54+
export const MockWorkspaceAutostartEnabled: WorkspaceAutostartRequest = {
55+
// Runs at 9:30am Monday through Friday using Canada/Eastern
56+
// (America/Toronto) time
57+
schedule: "CRON_TZ=Canada/Eastern 30 9 1-5",
58+
}
59+
60+
export const MockWorkspaceAutostopDisabled: WorkspaceAutostartRequest = {
61+
schedule: "",
62+
}
63+
64+
export const MockWorkspaceAutostopEnabled: WorkspaceAutostartRequest = {
65+
// Runs at 9:30pm Monday through Friday using America/Toronto
66+
schedule: "CRON_TZ=America/Toronto 30 21 1-5",
67+
}
68+
4969
export const MockWorkspace: Workspace = {
5070
id: "test-workspace",
5171
name: "Test-Workspace",
5272
created_at: "",
5373
updated_at: "",
5474
template_id: MockTemplate.id,
5575
owner_id: MockUser.id,
76+
autostart_schedule: MockWorkspaceAutostartEnabled.schedule,
77+
autostop_schedule: MockWorkspaceAutostopEnabled.schedule,
5678
}
5779

5880
export const MockUserAgent: UserAgent = {

site/src/test_helpers/handlers.ts

+6
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,10 @@ export const handlers = [
4444
rest.get("/api/v2/workspaces/:workspaceId", async (req, res, ctx) => {
4545
return res(ctx.status(200), ctx.json(M.MockWorkspace))
4646
}),
47+
rest.get("/api/v2/workspaces/:workspaceId/autostart", async (req, res, ctx) => {
48+
return res(ctx.status(200))
49+
}),
50+
rest.get("/api/v2/workspaces/:workspaceId/autostop", async (req, res, ctx) => {
51+
return res(ctx.status(200))
52+
}),
4753
]

site/src/util/schedule.test.ts

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { expandScheduleCronString, extractTimezone, stripTimezone } from "./schedule"
2+
3+
describe("util/schedule", () => {
4+
describe("stripTimezone", () => {
5+
it.each<[string, string]>([
6+
["CRON_TZ=Canada/Eastern 30 9 1-5", "30 9 1-5"],
7+
["CRON_TZ=America/Central 0 8 1,2,4,5", "0 8 1,2,4,5"],
8+
["30 9 1-5", "30 9 1-5"],
9+
])(`stripTimezone(%p) returns %p`, (input, expected) => {
10+
expect(stripTimezone(input)).toBe(expected)
11+
})
12+
})
13+
14+
describe("extractTimezone", () => {
15+
it.each<[string, string]>([
16+
["CRON_TZ=Canada/Eastern 30 9 1-5", "Canada/Eastern"],
17+
["CRON_TZ=America/Central 0 8 1,2,4,5", "America/Central"],
18+
["30 9 1-5", "UTC"],
19+
])(`extractTimezone(%p) returns %p`, (input, expected) => {
20+
expect(extractTimezone(input)).toBe(expected)
21+
})
22+
})
23+
24+
describe("expandScheduleCronString", () => {
25+
it.each<[string, string]>([
26+
["CRON_TZ=Canada/Eastern 30 9 1-5", "30 9 * * 1-5"],
27+
["CRON_TZ=America/Central 0 8 1,2,4,5", "0 8 * * 1,2,4,5"],
28+
["30 9 1-5", "30 9 * * 1-5"],
29+
])(`expandScheduleCronString(%p) returns %p`, (input, expected) => {
30+
expect(expandScheduleCronString(input)).toBe(expected)
31+
})
32+
})
33+
})

site/src/util/schedule.ts

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* @fileoverview Client-side counterpart of the coderd/autostart/schedule Go
3+
* package. This package is a variation on crontab that uses minute, hour and
4+
* day of week.
5+
*/
6+
7+
/**
8+
* DEFAULT_TIMEZONE is the default timezone that crontab assumes unless one is
9+
* specified.
10+
*/
11+
const DEFAULT_TIMEZONE = "UTC"
12+
13+
/**
14+
* stripTimezone strips a leading timezone from a schedule string
15+
*/
16+
export const stripTimezone = (raw: string): string => {
17+
return raw.replace(/CRON_TZ=\S*\s/, "")
18+
}
19+
20+
/**
21+
* extractTimezone returns a leading timezone from a schedule string if one is
22+
* specified; otherwise DEFAULT_TIMEZONE
23+
*/
24+
export const extractTimezone = (raw: string): string => {
25+
const matches = raw.match(/CRON_TZ=\S*\s/g)
26+
27+
if (matches && matches.length) {
28+
return matches[0].replace(/CRON_TZ=/, "").trim()
29+
} else {
30+
return DEFAULT_TIMEZONE
31+
}
32+
}
33+
34+
/**
35+
* expandScheduleCronString ensures a Schedule is expanded to a valid 5-value
36+
* cron string by inserting '*' in month and day positions. If there is a
37+
* leading timezone, it is removed.
38+
*
39+
* @example
40+
* expandScheduleCronString("30 9 1-5") // -> "30 9 * * 1-5"
41+
*/
42+
export const expandScheduleCronString = (schedule: string): string => {
43+
const prepared = stripTimezone(schedule).trim()
44+
45+
const parts = prepared.split(" ")
46+
47+
while (parts.length < 5) {
48+
// insert '*' in the second to last position
49+
// ie [a, b, c] --> [a, b, *, c]
50+
parts.splice(parts.length - 1, 0, "*")
51+
}
52+
53+
return parts.join(" ")
54+
}

site/yarn.lock

+5
Original file line numberDiff line numberDiff line change
@@ -5458,6 +5458,11 @@ create-require@^1.1.0:
54585458
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
54595459
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
54605460

5461+
5462+
version "2.2.0"
5463+
resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-2.2.0.tgz#8e02b8ef0fa70a9eab9999f1f838df4bd378b471"
5464+
integrity sha512-oM/ftAvCNIdygVGGfYp8gxrVc81mDSA2mff0kvu6+ehrZhfYPzGHG8DVcFdrRVizjHnzWoFIlgEq6KTM/9lPBw==
5465+
54615466
cross-fetch@^3.0.4:
54625467
version "3.1.5"
54635468
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f"

0 commit comments

Comments
 (0)