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

Skip to content

Commit 1cc3ecd

Browse files
committed
feat: ui autostop extension
Resolves: #1460 Summary: An 'Extend' CTA on workspace schedule banner is added so that a user can extend their workspace lease from the UI. Details: * feat: putWorkspaceExtension handler * refactor: TypesGen dflt import in workspace.ts * feat: defaultWorkspaceExtension util Impact: This completes the UI<-->CLI parity epic in an MVP way. Of course, a future improvement to make is extending by times other than the default 90 minutes.
1 parent e09cd3e commit 1cc3ecd

File tree

7 files changed

+155
-13
lines changed

7 files changed

+155
-13
lines changed

site/src/api/api.ts

+7
Original file line numberDiff line numberDiff line change
@@ -270,3 +270,10 @@ export const getWorkspaceBuildLogs = async (buildname: string): Promise<TypesGen
270270
const response = await axios.get<TypesGen.ProvisionerJobLog[]>(`/api/v2/workspacebuilds/${buildname}/logs`)
271271
return response.data
272272
}
273+
274+
export const putWorkspaceExtension = async (
275+
workspaceId: string,
276+
extendWorkspaceRequest: TypesGen.PutExtendWorkspaceRequest,
277+
): Promise<void> => {
278+
await axios.put(`/api/v2/workspaces/${workspaceId}/extend`, extendWorkspaceRequest)
279+
}

site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.stories.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { action } from "@storybook/addon-actions"
12
import { Story } from "@storybook/react"
23
import dayjs from "dayjs"
34
import utc from "dayjs/plugin/utc"
@@ -15,6 +16,7 @@ const Template: Story<WorkspaceScheduleBannerProps> = (args) => <WorkspaceSchedu
1516

1617
export const Example = Template.bind({})
1718
Example.args = {
19+
__onExtend: action("extend"),
1820
workspace: {
1921
...Mocks.MockWorkspace,
2022
latest_build: {
@@ -26,6 +28,6 @@ Example.args = {
2628
},
2729
transition: "start",
2830
},
29-
ttl: 2 * 60 * 60 * 1000 * 1_000_000, // 2 hours
31+
ttl_ms: 2 * 60 * 60 * 1000, // 2 hours
3032
},
3133
}

site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx

+30-2
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
1+
import Button from "@material-ui/core/Button"
12
import Alert from "@material-ui/lab/Alert"
23
import AlertTitle from "@material-ui/lab/AlertTitle"
4+
import { useMachine } from "@xstate/react"
35
import dayjs from "dayjs"
46
import isSameOrBefore from "dayjs/plugin/isSameOrBefore"
57
import utc from "dayjs/plugin/utc"
68
import { FC } from "react"
79
import * as TypesGen from "../../api/typesGenerated"
810
import { isWorkspaceOn } from "../../util/workspace"
11+
import { workspaceScheduleBanner } from "../../xServices/workspaceSchedule/workspaceScheduleBannerXService"
912

1013
dayjs.extend(utc)
1114
dayjs.extend(isSameOrBefore)
1215

1316
export const Language = {
17+
bannerAction: "Extend",
1418
bannerTitle: "Your workspace is scheduled to automatically shut down soon.",
1519
}
1620

1721
export interface WorkspaceScheduleBannerProps {
22+
/**
23+
* @remarks __onExtend is used for testing purposes
24+
*/
25+
__onExtend?: () => void
1826
workspace: TypesGen.Workspace
1927
}
2028

@@ -31,12 +39,32 @@ export const shouldDisplay = (workspace: TypesGen.Workspace): boolean => {
3139
}
3240
}
3341

34-
export const WorkspaceScheduleBanner: FC<WorkspaceScheduleBannerProps> = ({ workspace }) => {
42+
export const WorkspaceScheduleBanner: FC<WorkspaceScheduleBannerProps> = ({ __onExtend, workspace }) => {
43+
const [bannerState, bannerSend] = useMachine(workspaceScheduleBanner)
44+
3545
if (!shouldDisplay(workspace)) {
3646
return null
3747
} else {
3848
return (
39-
<Alert severity="warning">
49+
<Alert
50+
action={
51+
<Button
52+
color="inherit"
53+
disabled={bannerState.hasTag("loading")}
54+
onClick={() => {
55+
if (__onExtend) {
56+
__onExtend()
57+
} else {
58+
bannerSend({ type: "EXTEND_DEADLINE_DEFAULT", workspaceId: workspace.id })
59+
}
60+
}}
61+
size="small"
62+
>
63+
{Language.bannerAction}
64+
</Button>
65+
}
66+
severity="warning"
67+
>
4068
<AlertTitle>{Language.bannerTitle}</AlertTitle>
4169
</Alert>
4270
)

site/src/testHelpers/handlers.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ export const handlers = [
109109
rest.put("/api/v2/workspaces/:workspaceId/ttl", async (req, res, ctx) => {
110110
return res(ctx.status(200))
111111
}),
112+
rest.put("/api/v2/workspaces/:workspaceId/extend", async (req, res, ctx) => {
113+
return res(ctx.status(200))
114+
}),
115+
116+
// workspace builds
112117
rest.post("/api/v2/workspaces/:workspaceId/builds", async (req, res, ctx) => {
113118
const { transition } = req.body as CreateWorkspaceBuildRequest
114119
const transitionToBuild = {
@@ -122,8 +127,6 @@ export const handlers = [
122127
rest.get("/api/v2/workspaces/:workspaceId/builds", async (req, res, ctx) => {
123128
return res(ctx.status(200), ctx.json(M.MockBuilds))
124129
}),
125-
126-
// workspace builds
127130
rest.get("/api/v2/workspacebuilds/:workspaceBuildId", (req, res, ctx) => {
128131
return res(ctx.status(200), ctx.json(M.MockWorkspaceBuild))
129132
}),

site/src/util/workspace.test.ts

+24-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import dayjs from "dayjs"
12
import * as TypesGen from "../api/typesGenerated"
23
import * as Mocks from "../testHelpers/entities"
3-
import { isWorkspaceOn } from "./workspace"
4+
import { defaultWorkspaceExtension, isWorkspaceOn } from "./workspace"
45

56
describe("util > workspace", () => {
67
describe("isWorkspaceOn", () => {
@@ -40,4 +41,26 @@ describe("util > workspace", () => {
4041
expect(isWorkspaceOn(workspace)).toBe(isOn)
4142
})
4243
})
44+
45+
describe("defaultWorkspaceExtension", () => {
46+
it.each<[string, TypesGen.PutExtendWorkspaceRequest]>([
47+
[
48+
"2022-06-02T14:56:34Z",
49+
{
50+
deadline: "2022-06-02T15:26:34Z",
51+
},
52+
],
53+
54+
// This case is the same as above, but in a different timezone to prove
55+
// that UTC conversion for deadline works as expected
56+
[
57+
"2022-06-02T10:56:20-04:00",
58+
{
59+
deadline: "2022-06-02T15:26:34Z",
60+
},
61+
],
62+
])(`defaultWorkspaceExtension(%p) returns %p`, (startTime, request) => {
63+
expect(defaultWorkspaceExtension(dayjs(startTime))).toEqual(request)
64+
})
65+
})
4366
})

site/src/util/workspace.ts

+22-7
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { Theme } from "@material-ui/core/styles"
22
import dayjs from "dayjs"
3+
import utc from "dayjs/plugin/utc"
34
import { WorkspaceBuildTransition } from "../api/types"
4-
import { Workspace, WorkspaceAgent, WorkspaceBuild } from "../api/typesGenerated"
5+
import * as TypesGen from "../api/typesGenerated"
6+
7+
dayjs.extend(utc)
58

69
export type WorkspaceStatus =
710
| "queued"
@@ -29,7 +32,7 @@ const succeededToStatus: Record<WorkspaceBuildTransition, WorkspaceStatus> = {
2932
}
3033

3134
// Converts a workspaces status to a human-readable form.
32-
export const getWorkspaceStatus = (workspaceBuild?: WorkspaceBuild): WorkspaceStatus => {
35+
export const getWorkspaceStatus = (workspaceBuild?: TypesGen.WorkspaceBuild): WorkspaceStatus => {
3336
const transition = workspaceBuild?.transition as WorkspaceBuildTransition
3437
const jobStatus = workspaceBuild?.job.status
3538
switch (jobStatus) {
@@ -66,7 +69,7 @@ export const DisplayStatusLanguage = {
6669

6770
export const getDisplayStatus = (
6871
theme: Theme,
69-
build: WorkspaceBuild,
72+
build: TypesGen.WorkspaceBuild,
7073
): {
7174
color: string
7275
status: string
@@ -132,7 +135,7 @@ export const getDisplayStatus = (
132135
throw new Error("unknown status " + status)
133136
}
134137

135-
export const getWorkspaceBuildDurationInSeconds = (build: WorkspaceBuild): number | undefined => {
138+
export const getWorkspaceBuildDurationInSeconds = (build: TypesGen.WorkspaceBuild): number | undefined => {
136139
const isCompleted = build.job.started_at && build.job.completed_at
137140

138141
if (!isCompleted) {
@@ -144,7 +147,10 @@ export const getWorkspaceBuildDurationInSeconds = (build: WorkspaceBuild): numbe
144147
return completedAt.diff(startedAt, "seconds")
145148
}
146149

147-
export const displayWorkspaceBuildDuration = (build: WorkspaceBuild, inProgressLabel = "In progress"): string => {
150+
export const displayWorkspaceBuildDuration = (
151+
build: TypesGen.WorkspaceBuild,
152+
inProgressLabel = "In progress",
153+
): string => {
148154
const duration = getWorkspaceBuildDurationInSeconds(build)
149155
return duration ? `${duration} seconds` : inProgressLabel
150156
}
@@ -157,7 +163,7 @@ export const DisplayAgentStatusLanguage = {
157163

158164
export const getDisplayAgentStatus = (
159165
theme: Theme,
160-
agent: WorkspaceAgent,
166+
agent: TypesGen.WorkspaceAgent,
161167
): {
162168
color: string
163169
status: string
@@ -186,8 +192,17 @@ export const getDisplayAgentStatus = (
186192
}
187193
}
188194

189-
export const isWorkspaceOn = (workspace: Workspace): boolean => {
195+
export const isWorkspaceOn = (workspace: TypesGen.Workspace): boolean => {
190196
const transition = workspace.latest_build.transition
191197
const status = workspace.latest_build.job.status
192198
return transition === "start" && status === "succeeded"
193199
}
200+
201+
export const defaultWorkspaceExtension = (__startDate?: dayjs.Dayjs): TypesGen.PutExtendWorkspaceRequest => {
202+
const now = __startDate ? dayjs(__startDate) : dayjs()
203+
const NinetyMinutesFromNow = now.add(90, "minutes").utc()
204+
205+
return {
206+
deadline: NinetyMinutesFromNow.format(),
207+
}
208+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* @fileoverview workspaceScheduleBanner is an xstate machine backing a form,
3+
* presented as an Alert/banner, for reactively extending a workspace schedule.
4+
*/
5+
import { createMachine } from "xstate"
6+
import * as API from "../../api/api"
7+
import { displayError, displaySuccess } from "../../components/GlobalSnackbar/utils"
8+
import { defaultWorkspaceExtension } from "../../util/workspace"
9+
10+
export const Language = {
11+
errorExtension: "Failed to extend workspace deadline.",
12+
successExtension: "Successfully extended workspace deadline.",
13+
}
14+
15+
export type WorkspaceScheduleBannerEvent = { type: "EXTEND_DEADLINE_DEFAULT"; workspaceId: string }
16+
17+
export const workspaceScheduleBanner = createMachine(
18+
{
19+
tsTypes: {} as import("./workspaceScheduleBannerXService.typegen").Typegen0,
20+
schema: {
21+
events: {} as WorkspaceScheduleBannerEvent,
22+
},
23+
id: "workspaceScheduleBannerState",
24+
initial: "idle",
25+
states: {
26+
idle: {
27+
on: {
28+
EXTEND_DEADLINE_DEFAULT: "extendingDeadline",
29+
},
30+
},
31+
extendingDeadline: {
32+
invoke: {
33+
src: "extendDeadlineDefault",
34+
id: "extendDeadlineDefault",
35+
onDone: {
36+
target: "idle",
37+
actions: "displaySuccessMessage",
38+
},
39+
onError: {
40+
target: "idle",
41+
actions: "displayFailureMessage",
42+
},
43+
},
44+
tags: "loading",
45+
},
46+
},
47+
},
48+
{
49+
actions: {
50+
displayFailureMessage: () => {
51+
displayError(Language.errorExtension)
52+
},
53+
displaySuccessMessage: () => {
54+
displaySuccess(Language.successExtension)
55+
},
56+
},
57+
58+
services: {
59+
extendDeadlineDefault: async (_, event) => {
60+
await API.putWorkspaceExtension(event.workspaceId, defaultWorkspaceExtension())
61+
},
62+
},
63+
},
64+
)

0 commit comments

Comments
 (0)