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

Skip to content

Commit 0b59ed3

Browse files
authored
feat: ui autostop extension (#1987)
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 1a07d02 commit 0b59ed3

File tree

10 files changed

+174
-14
lines changed

10 files changed

+174
-14
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/Workspace/Workspace.stories.tsx

+4
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ const Template: Story<WorkspaceProps> = (args) => <Workspace {...args} />
1313

1414
export const Started = Template.bind({})
1515
Started.args = {
16+
bannerProps: {
17+
isLoading: false,
18+
onExtend: action("extend"),
19+
},
1620
workspace: Mocks.MockWorkspace,
1721
handleStart: action("start"),
1822
handleStop: action("stop"),

site/src/components/Workspace/Workspace.tsx

+14-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection"
1313
import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats"
1414

1515
export interface WorkspaceProps {
16+
bannerProps: {
17+
isLoading?: boolean
18+
onExtend: () => void
19+
}
1620
handleStart: () => void
1721
handleStop: () => void
1822
handleDelete: () => void
@@ -28,6 +32,7 @@ export interface WorkspaceProps {
2832
* Workspace is the top-level component for viewing an individual workspace
2933
*/
3034
export const Workspace: FC<WorkspaceProps> = ({
35+
bannerProps,
3136
handleStart,
3237
handleStop,
3338
handleDelete,
@@ -54,6 +59,7 @@ export const Workspace: FC<WorkspaceProps> = ({
5459
{workspace.owner_name}
5560
</Typography>
5661
</div>
62+
5763
<WorkspaceActions
5864
workspace={workspace}
5965
handleStart={handleStart}
@@ -70,9 +76,16 @@ export const Workspace: FC<WorkspaceProps> = ({
7076

7177
<Stack direction="row" spacing={3}>
7278
<Stack direction="column" className={styles.firstColumnSpacer} spacing={3}>
73-
<WorkspaceScheduleBanner workspace={workspace} />
79+
<WorkspaceScheduleBanner
80+
isLoading={bannerProps.isLoading}
81+
onExtend={bannerProps.onExtend}
82+
workspace={workspace}
83+
/>
84+
7485
<WorkspaceStats workspace={workspace} />
86+
7587
<Resources resources={resources} getResourcesError={getResourcesError} workspace={workspace} />
88+
7689
<WorkspaceSection title="Timeline" contentsProps={{ className: styles.timelineContents }}>
7790
<BuildsTable builds={builds} className={styles.timelineTable} />
7891
</WorkspaceSection>

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

+12-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,8 +16,11 @@ const Template: Story<WorkspaceScheduleBannerProps> = (args) => <WorkspaceSchedu
1516

1617
export const Example = Template.bind({})
1718
Example.args = {
19+
isLoading: false,
20+
onExtend: action("extend"),
1821
workspace: {
1922
...Mocks.MockWorkspace,
23+
2024
latest_build: {
2125
...Mocks.MockWorkspaceBuild,
2226
deadline: dayjs().utc().format(),
@@ -26,6 +30,13 @@ Example.args = {
2630
},
2731
transition: "start",
2832
},
29-
ttl: 2 * 60 * 60 * 1000 * 1_000_000, // 2 hours
33+
34+
ttl_ms: 2 * 60 * 60 * 1000, // 2 hours
3035
},
3136
}
37+
38+
export const Loading = Template.bind({})
39+
Loading.args = {
40+
...Example.args,
41+
isLoading: true,
42+
}

site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx

+13-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Button from "@material-ui/core/Button"
12
import Alert from "@material-ui/lab/Alert"
23
import AlertTitle from "@material-ui/lab/AlertTitle"
34
import dayjs from "dayjs"
@@ -11,10 +12,13 @@ dayjs.extend(utc)
1112
dayjs.extend(isSameOrBefore)
1213

1314
export const Language = {
15+
bannerAction: "Extend",
1416
bannerTitle: "Your workspace is scheduled to automatically shut down soon.",
1517
}
1618

1719
export interface WorkspaceScheduleBannerProps {
20+
isLoading?: boolean
21+
onExtend: () => void
1822
workspace: TypesGen.Workspace
1923
}
2024

@@ -31,12 +35,19 @@ export const shouldDisplay = (workspace: TypesGen.Workspace): boolean => {
3135
}
3236
}
3337

34-
export const WorkspaceScheduleBanner: FC<WorkspaceScheduleBannerProps> = ({ workspace }) => {
38+
export const WorkspaceScheduleBanner: FC<WorkspaceScheduleBannerProps> = ({ isLoading, onExtend, workspace }) => {
3539
if (!shouldDisplay(workspace)) {
3640
return null
3741
} else {
3842
return (
39-
<Alert severity="warning">
43+
<Alert
44+
action={
45+
<Button color="inherit" disabled={isLoading} onClick={onExtend} size="small">
46+
{Language.bannerAction}
47+
</Button>
48+
}
49+
severity="warning"
50+
>
4051
<AlertTitle>{Language.bannerTitle}</AlertTitle>
4152
</Alert>
4253
)

site/src/pages/WorkspacePage/WorkspacePage.tsx

+9
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Stack } from "../../components/Stack/Stack"
99
import { Workspace } from "../../components/Workspace/Workspace"
1010
import { firstOrItem } from "../../util/array"
1111
import { workspaceMachine } from "../../xServices/workspace/workspaceXService"
12+
import { workspaceScheduleBannerMachine } from "../../xServices/workspaceSchedule/workspaceScheduleBannerXService"
1213

1314
export const WorkspacePage: React.FC = () => {
1415
const { workspace: workspaceQueryParam } = useParams()
@@ -18,6 +19,8 @@ export const WorkspacePage: React.FC = () => {
1819
const [workspaceState, workspaceSend] = useMachine(workspaceMachine)
1920
const { workspace, resources, getWorkspaceError, getResourcesError, builds } = workspaceState.context
2021

22+
const [bannerState, bannerSend] = useMachine(workspaceScheduleBannerMachine)
23+
2124
/**
2225
* Get workspace, template, and organization on mount and whenever workspaceId changes.
2326
* workspaceSend should not change.
@@ -36,6 +39,12 @@ export const WorkspacePage: React.FC = () => {
3639
<Stack spacing={4}>
3740
<>
3841
<Workspace
42+
bannerProps={{
43+
isLoading: bannerState.hasTag("loading"),
44+
onExtend: () => {
45+
bannerSend({ type: "EXTEND_DEADLINE_DEFAULT", workspaceId: workspace.id })
46+
},
47+
}}
3948
workspace={workspace}
4049
handleStart={() => workspaceSend("START")}
4150
handleStop={() => workspaceSend("STOP")}

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-02T16: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-02T16:26:20Z",
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) {
@@ -67,7 +70,7 @@ export const DisplayStatusLanguage = {
6770
// Localize workspace status and provide corresponding color from theme
6871
export const getDisplayStatus = (
6972
theme: Theme,
70-
build: WorkspaceBuild,
73+
build: TypesGen.WorkspaceBuild,
7174
): {
7275
color: string
7376
status: string
@@ -133,7 +136,7 @@ export const getDisplayStatus = (
133136
throw new Error("unknown status " + status)
134137
}
135138

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

139142
if (!isCompleted) {
@@ -145,7 +148,10 @@ export const getWorkspaceBuildDurationInSeconds = (build: WorkspaceBuild): numbe
145148
return completedAt.diff(startedAt, "seconds")
146149
}
147150

148-
export const displayWorkspaceBuildDuration = (build: WorkspaceBuild, inProgressLabel = "In progress"): string => {
151+
export const displayWorkspaceBuildDuration = (
152+
build: TypesGen.WorkspaceBuild,
153+
inProgressLabel = "In progress",
154+
): string => {
149155
const duration = getWorkspaceBuildDurationInSeconds(build)
150156
return duration ? `${duration} seconds` : inProgressLabel
151157
}
@@ -158,7 +164,7 @@ export const DisplayAgentStatusLanguage = {
158164

159165
export const getDisplayAgentStatus = (
160166
theme: Theme,
161-
agent: WorkspaceAgent,
167+
agent: TypesGen.WorkspaceAgent,
162168
): {
163169
color: string
164170
status: string
@@ -187,8 +193,17 @@ export const getDisplayAgentStatus = (
187193
}
188194
}
189195

190-
export const isWorkspaceOn = (workspace: Workspace): boolean => {
196+
export const isWorkspaceOn = (workspace: TypesGen.Workspace): boolean => {
191197
const transition = workspace.latest_build.transition
192198
const status = workspace.latest_build.job.status
193199
return transition === "start" && status === "succeeded"
194200
}
201+
202+
export const defaultWorkspaceExtension = (__startDate?: dayjs.Dayjs): TypesGen.PutExtendWorkspaceRequest => {
203+
const now = __startDate ? dayjs(__startDate) : dayjs()
204+
const NinetyMinutesFromNow = now.add(90, "minutes").utc()
205+
206+
return {
207+
deadline: NinetyMinutesFromNow.format(),
208+
}
209+
}
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 workspaceScheduleBannerMachine = 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)