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

Lines changed: 7 additions & 0 deletions
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

Lines changed: 4 additions & 0 deletions
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

Lines changed: 14 additions & 1 deletion
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

Lines changed: 12 additions & 1 deletion
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

Lines changed: 13 additions & 2 deletions
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

Lines changed: 9 additions & 0 deletions
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

Lines changed: 5 additions & 2 deletions
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

Lines changed: 24 additions & 1 deletion
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

Lines changed: 22 additions & 7 deletions
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+
}
Lines changed: 64 additions & 0 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy