mirror of
https://github.com/coder/coder.git
synced 2025-07-09 11:45:56 +00:00
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:
@ -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
|
||||||
|
@ -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.
|
||||||
|
@ -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",
|
||||||
|
@ -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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
@ -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>
|
||||||
|
17
site/src/components/Workspace/WorkspaceSchedule.stories.tsx
Normal file
17
site/src/components/Workspace/WorkspaceSchedule.stories.tsx
Normal 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: "",
|
||||||
|
}
|
59
site/src/components/Workspace/WorkspaceSchedule.tsx
Normal file
59
site/src/components/Workspace/WorkspaceSchedule.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -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 = {
|
||||||
|
@ -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))
|
||||||
|
}),
|
||||||
]
|
]
|
||||||
|
33
site/src/util/schedule.test.ts
Normal file
33
site/src/util/schedule.test.ts
Normal 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
54
site/src/util/schedule.ts
Normal 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(" ")
|
||||||
|
}
|
@ -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"
|
||||||
|
Reference in New Issue
Block a user