feat: workspace view for schedules (#991)

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.
This commit is contained in:
G r e y
2022-04-13 20:35:47 -04:00
committed by GitHub
parent 027d89dd9b
commit 42e9956779
13 changed files with 235 additions and 3 deletions

View File

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

View File

@ -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. // UpdateWorkspaceAutostartRequest is a request to update a workspace's autostart schedule.
type UpdateWorkspaceAutostartRequest struct { type UpdateWorkspaceAutostartRequest struct {
Schedule string Schedule string `json:"schedule"`
} }
// UpdateWorkspaceAutostart sets the autostart schedule for workspace by id. // 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. // UpdateWorkspaceAutostopRequest is a request to update a workspace's autostop schedule.
type UpdateWorkspaceAutostopRequest struct { type UpdateWorkspaceAutostopRequest struct {
Schedule string Schedule string `json:"schedule"`
} }
// UpdateWorkspaceAutostop sets the autostop schedule for workspace by id. // UpdateWorkspaceAutostop sets the autostop schedule for workspace by id.

View File

@ -33,6 +33,7 @@
"@xstate/inspect": "0.6.5", "@xstate/inspect": "0.6.5",
"@xstate/react": "3.0.0", "@xstate/react": "3.0.0",
"axios": "0.26.1", "axios": "0.26.1",
"cronstrue": "2.2.0",
"formik": "2.2.9", "formik": "2.2.9",
"history": "5.3.0", "history": "5.3.0",
"react": "17.0.2", "react": "17.0.2",

View File

@ -73,3 +73,23 @@ export const getBuildInfo = async (): Promise<Types.BuildInfoResponse> => {
const response = await axios.get("/api/v2/buildinfo") const response = await axios.get("/api/v2/buildinfo")
return response.data return response.data
} }
export const putWorkspaceAutostart = async (
workspaceID: string,
autostart: Types.WorkspaceAutostartRequest,
): Promise<void> => {
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<void> => {
const payload = JSON.stringify(autostop)
await axios.put(`/api/v2/workspaces/${workspaceID}/autostop`, payload, {
headers: { ...CONTENT_TYPE_JSON },
})
}

View File

@ -54,7 +54,9 @@ export interface CreateWorkspaceRequest {
template_id: string template_id: string
} }
// Must be kept in sync with backend Workspace struct /**
* @remarks Keep in sync with codersdk/workspaces.go
*/
export interface Workspace { export interface Workspace {
id: string id: string
created_at: string created_at: string
@ -62,6 +64,8 @@ export interface Workspace {
owner_id: string owner_id: string
template_id: string template_id: string
name: string name: string
autostart_schedule: string
autostop_schedule: string
} }
export interface APIKeyResponse { export interface APIKeyResponse {
@ -74,3 +78,11 @@ export interface UserAgent {
readonly ip_address: string readonly ip_address: string
readonly os: string readonly os: string
} }
export interface WorkspaceAutostartRequest {
schedule: string
}
export interface WorkspaceAutostopRequest {
schedule: string
}

View File

@ -7,6 +7,7 @@ import React from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import * as Types from "../../api/types" import * as Types from "../../api/types"
import * as Constants from "./constants" import * as Constants from "./constants"
import { WorkspaceSchedule } from "./WorkspaceSchedule"
import { WorkspaceSection } from "./WorkspaceSection" import { WorkspaceSection } from "./WorkspaceSection"
export interface WorkspaceProps { export interface WorkspaceProps {
@ -30,6 +31,7 @@ export const Workspace: React.FC<WorkspaceProps> = ({ organization, template, wo
<WorkspaceSection title="Applications"> <WorkspaceSection title="Applications">
<Placeholder /> <Placeholder />
</WorkspaceSection> </WorkspaceSection>
<WorkspaceSchedule autostart={workspace.autostart_schedule} autostop={workspace.autostop_schedule} />
<WorkspaceSection title="Dev URLs"> <WorkspaceSection title="Dev URLs">
<Placeholder /> <Placeholder />
</WorkspaceSection> </WorkspaceSection>

View File

@ -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<WorkspaceScheduleProps> = (args) => <WorkspaceSchedule {...args} />
export const Example = Template.bind({})
Example.args = {
autostart: MockWorkspaceAutostartEnabled.schedule,
autostop: "",
}

View File

@ -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<WorkspaceScheduleProps> = ({ autostart, autostop }) => {
return (
<WorkspaceSection title="Workspace schedule">
<Box mt={2}>
<Typography variant="h6">{Language.autoStartLabel(autostart)}</Typography>
<Typography>{Language.cronHumanDisplay(autostart)}</Typography>
</Box>
<Box mt={2}>
<Typography variant="h6">{Language.autoStopLabel(autostop)}</Typography>
<Typography>{Language.cronHumanDisplay(autostop)}</Typography>
</Box>
</WorkspaceSection>
)
}

View File

@ -6,6 +6,7 @@ import {
UserAgent, UserAgent,
UserResponse, UserResponse,
Workspace, Workspace,
WorkspaceAutostartRequest,
} from "../api/types" } from "../api/types"
export const MockSessionToken = { session_token: "my-session-token" } export const MockSessionToken = { session_token: "my-session-token" }
@ -46,6 +47,25 @@ export const MockTemplate: Template = {
active_version_id: "", 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 = { export const MockWorkspace: Workspace = {
id: "test-workspace", id: "test-workspace",
name: "Test-Workspace", name: "Test-Workspace",
@ -53,6 +73,8 @@ export const MockWorkspace: Workspace = {
updated_at: "", updated_at: "",
template_id: MockTemplate.id, template_id: MockTemplate.id,
owner_id: MockUser.id, owner_id: MockUser.id,
autostart_schedule: MockWorkspaceAutostartEnabled.schedule,
autostop_schedule: MockWorkspaceAutostopEnabled.schedule,
} }
export const MockUserAgent: UserAgent = { export const MockUserAgent: UserAgent = {

View File

@ -44,4 +44,10 @@ export const handlers = [
rest.get("/api/v2/workspaces/:workspaceId", async (req, res, ctx) => { rest.get("/api/v2/workspaces/:workspaceId", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockWorkspace)) 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))
}),
] ]

View File

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

54
site/src/util/schedule.ts Normal file
View File

@ -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(" ")
}

View File

@ -5450,6 +5450,11 @@ create-require@^1.1.0:
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== 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: cross-fetch@^3.0.4:
version "3.1.5" version "3.1.5"
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f"