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.
This commit is contained in:
G r e y
2022-06-02 15:44:11 -04:00
committed by GitHub
parent 1a07d021fe
commit 0b59ed30d0
10 changed files with 174 additions and 14 deletions
+7
View File
@@ -270,3 +270,10 @@ export const getWorkspaceBuildLogs = async (buildname: string): Promise<TypesGen
const response = await axios.get<TypesGen.ProvisionerJobLog[]>(`/api/v2/workspacebuilds/${buildname}/logs`)
return response.data
}
export const putWorkspaceExtension = async (
workspaceId: string,
extendWorkspaceRequest: TypesGen.PutExtendWorkspaceRequest,
): Promise<void> => {
await axios.put(`/api/v2/workspaces/${workspaceId}/extend`, extendWorkspaceRequest)
}
@@ -13,6 +13,10 @@ const Template: Story<WorkspaceProps> = (args) => <Workspace {...args} />
export const Started = Template.bind({})
Started.args = {
bannerProps: {
isLoading: false,
onExtend: action("extend"),
},
workspace: Mocks.MockWorkspace,
handleStart: action("start"),
handleStop: action("stop"),
+14 -1
View File
@@ -13,6 +13,10 @@ import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection"
import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats"
export interface WorkspaceProps {
bannerProps: {
isLoading?: boolean
onExtend: () => void
}
handleStart: () => void
handleStop: () => void
handleDelete: () => void
@@ -28,6 +32,7 @@ export interface WorkspaceProps {
* Workspace is the top-level component for viewing an individual workspace
*/
export const Workspace: FC<WorkspaceProps> = ({
bannerProps,
handleStart,
handleStop,
handleDelete,
@@ -54,6 +59,7 @@ export const Workspace: FC<WorkspaceProps> = ({
{workspace.owner_name}
</Typography>
</div>
<WorkspaceActions
workspace={workspace}
handleStart={handleStart}
@@ -70,9 +76,16 @@ export const Workspace: FC<WorkspaceProps> = ({
<Stack direction="row" spacing={3}>
<Stack direction="column" className={styles.firstColumnSpacer} spacing={3}>
<WorkspaceScheduleBanner workspace={workspace} />
<WorkspaceScheduleBanner
isLoading={bannerProps.isLoading}
onExtend={bannerProps.onExtend}
workspace={workspace}
/>
<WorkspaceStats workspace={workspace} />
<Resources resources={resources} getResourcesError={getResourcesError} workspace={workspace} />
<WorkspaceSection title="Timeline" contentsProps={{ className: styles.timelineContents }}>
<BuildsTable builds={builds} className={styles.timelineTable} />
</WorkspaceSection>
@@ -1,3 +1,4 @@
import { action } from "@storybook/addon-actions"
import { Story } from "@storybook/react"
import dayjs from "dayjs"
import utc from "dayjs/plugin/utc"
@@ -15,8 +16,11 @@ const Template: Story<WorkspaceScheduleBannerProps> = (args) => <WorkspaceSchedu
export const Example = Template.bind({})
Example.args = {
isLoading: false,
onExtend: action("extend"),
workspace: {
...Mocks.MockWorkspace,
latest_build: {
...Mocks.MockWorkspaceBuild,
deadline: dayjs().utc().format(),
@@ -26,6 +30,13 @@ Example.args = {
},
transition: "start",
},
ttl: 2 * 60 * 60 * 1000 * 1_000_000, // 2 hours
ttl_ms: 2 * 60 * 60 * 1000, // 2 hours
},
}
export const Loading = Template.bind({})
Loading.args = {
...Example.args,
isLoading: true,
}
@@ -1,3 +1,4 @@
import Button from "@material-ui/core/Button"
import Alert from "@material-ui/lab/Alert"
import AlertTitle from "@material-ui/lab/AlertTitle"
import dayjs from "dayjs"
@@ -11,10 +12,13 @@ dayjs.extend(utc)
dayjs.extend(isSameOrBefore)
export const Language = {
bannerAction: "Extend",
bannerTitle: "Your workspace is scheduled to automatically shut down soon.",
}
export interface WorkspaceScheduleBannerProps {
isLoading?: boolean
onExtend: () => void
workspace: TypesGen.Workspace
}
@@ -31,12 +35,19 @@ export const shouldDisplay = (workspace: TypesGen.Workspace): boolean => {
}
}
export const WorkspaceScheduleBanner: FC<WorkspaceScheduleBannerProps> = ({ workspace }) => {
export const WorkspaceScheduleBanner: FC<WorkspaceScheduleBannerProps> = ({ isLoading, onExtend, workspace }) => {
if (!shouldDisplay(workspace)) {
return null
} else {
return (
<Alert severity="warning">
<Alert
action={
<Button color="inherit" disabled={isLoading} onClick={onExtend} size="small">
{Language.bannerAction}
</Button>
}
severity="warning"
>
<AlertTitle>{Language.bannerTitle}</AlertTitle>
</Alert>
)
@@ -9,6 +9,7 @@ import { Stack } from "../../components/Stack/Stack"
import { Workspace } from "../../components/Workspace/Workspace"
import { firstOrItem } from "../../util/array"
import { workspaceMachine } from "../../xServices/workspace/workspaceXService"
import { workspaceScheduleBannerMachine } from "../../xServices/workspaceSchedule/workspaceScheduleBannerXService"
export const WorkspacePage: React.FC = () => {
const { workspace: workspaceQueryParam } = useParams()
@@ -18,6 +19,8 @@ export const WorkspacePage: React.FC = () => {
const [workspaceState, workspaceSend] = useMachine(workspaceMachine)
const { workspace, resources, getWorkspaceError, getResourcesError, builds } = workspaceState.context
const [bannerState, bannerSend] = useMachine(workspaceScheduleBannerMachine)
/**
* Get workspace, template, and organization on mount and whenever workspaceId changes.
* workspaceSend should not change.
@@ -36,6 +39,12 @@ export const WorkspacePage: React.FC = () => {
<Stack spacing={4}>
<>
<Workspace
bannerProps={{
isLoading: bannerState.hasTag("loading"),
onExtend: () => {
bannerSend({ type: "EXTEND_DEADLINE_DEFAULT", workspaceId: workspace.id })
},
}}
workspace={workspace}
handleStart={() => workspaceSend("START")}
handleStop={() => workspaceSend("STOP")}
+5 -2
View File
@@ -109,6 +109,11 @@ export const handlers = [
rest.put("/api/v2/workspaces/:workspaceId/ttl", async (req, res, ctx) => {
return res(ctx.status(200))
}),
rest.put("/api/v2/workspaces/:workspaceId/extend", async (req, res, ctx) => {
return res(ctx.status(200))
}),
// workspace builds
rest.post("/api/v2/workspaces/:workspaceId/builds", async (req, res, ctx) => {
const { transition } = req.body as CreateWorkspaceBuildRequest
const transitionToBuild = {
@@ -122,8 +127,6 @@ export const handlers = [
rest.get("/api/v2/workspaces/:workspaceId/builds", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockBuilds))
}),
// workspace builds
rest.get("/api/v2/workspacebuilds/:workspaceBuildId", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockWorkspaceBuild))
}),
+24 -1
View File
@@ -1,6 +1,7 @@
import dayjs from "dayjs"
import * as TypesGen from "../api/typesGenerated"
import * as Mocks from "../testHelpers/entities"
import { isWorkspaceOn } from "./workspace"
import { defaultWorkspaceExtension, isWorkspaceOn } from "./workspace"
describe("util > workspace", () => {
describe("isWorkspaceOn", () => {
@@ -40,4 +41,26 @@ describe("util > workspace", () => {
expect(isWorkspaceOn(workspace)).toBe(isOn)
})
})
describe("defaultWorkspaceExtension", () => {
it.each<[string, TypesGen.PutExtendWorkspaceRequest]>([
[
"2022-06-02T14:56:34Z",
{
deadline: "2022-06-02T16:26:34Z",
},
],
// This case is the same as above, but in a different timezone to prove
// that UTC conversion for deadline works as expected
[
"2022-06-02T10:56:20-04:00",
{
deadline: "2022-06-02T16:26:20Z",
},
],
])(`defaultWorkspaceExtension(%p) returns %p`, (startTime, request) => {
expect(defaultWorkspaceExtension(dayjs(startTime))).toEqual(request)
})
})
})
+22 -7
View File
@@ -1,7 +1,10 @@
import { Theme } from "@material-ui/core/styles"
import dayjs from "dayjs"
import utc from "dayjs/plugin/utc"
import { WorkspaceBuildTransition } from "../api/types"
import { Workspace, WorkspaceAgent, WorkspaceBuild } from "../api/typesGenerated"
import * as TypesGen from "../api/typesGenerated"
dayjs.extend(utc)
export type WorkspaceStatus =
| "queued"
@@ -29,7 +32,7 @@ const succeededToStatus: Record<WorkspaceBuildTransition, WorkspaceStatus> = {
}
// Converts a workspaces status to a human-readable form.
export const getWorkspaceStatus = (workspaceBuild?: WorkspaceBuild): WorkspaceStatus => {
export const getWorkspaceStatus = (workspaceBuild?: TypesGen.WorkspaceBuild): WorkspaceStatus => {
const transition = workspaceBuild?.transition as WorkspaceBuildTransition
const jobStatus = workspaceBuild?.job.status
switch (jobStatus) {
@@ -67,7 +70,7 @@ export const DisplayStatusLanguage = {
// Localize workspace status and provide corresponding color from theme
export const getDisplayStatus = (
theme: Theme,
build: WorkspaceBuild,
build: TypesGen.WorkspaceBuild,
): {
color: string
status: string
@@ -133,7 +136,7 @@ export const getDisplayStatus = (
throw new Error("unknown status " + status)
}
export const getWorkspaceBuildDurationInSeconds = (build: WorkspaceBuild): number | undefined => {
export const getWorkspaceBuildDurationInSeconds = (build: TypesGen.WorkspaceBuild): number | undefined => {
const isCompleted = build.job.started_at && build.job.completed_at
if (!isCompleted) {
@@ -145,7 +148,10 @@ export const getWorkspaceBuildDurationInSeconds = (build: WorkspaceBuild): numbe
return completedAt.diff(startedAt, "seconds")
}
export const displayWorkspaceBuildDuration = (build: WorkspaceBuild, inProgressLabel = "In progress"): string => {
export const displayWorkspaceBuildDuration = (
build: TypesGen.WorkspaceBuild,
inProgressLabel = "In progress",
): string => {
const duration = getWorkspaceBuildDurationInSeconds(build)
return duration ? `${duration} seconds` : inProgressLabel
}
@@ -158,7 +164,7 @@ export const DisplayAgentStatusLanguage = {
export const getDisplayAgentStatus = (
theme: Theme,
agent: WorkspaceAgent,
agent: TypesGen.WorkspaceAgent,
): {
color: string
status: string
@@ -187,8 +193,17 @@ export const getDisplayAgentStatus = (
}
}
export const isWorkspaceOn = (workspace: Workspace): boolean => {
export const isWorkspaceOn = (workspace: TypesGen.Workspace): boolean => {
const transition = workspace.latest_build.transition
const status = workspace.latest_build.job.status
return transition === "start" && status === "succeeded"
}
export const defaultWorkspaceExtension = (__startDate?: dayjs.Dayjs): TypesGen.PutExtendWorkspaceRequest => {
const now = __startDate ? dayjs(__startDate) : dayjs()
const NinetyMinutesFromNow = now.add(90, "minutes").utc()
return {
deadline: NinetyMinutesFromNow.format(),
}
}
@@ -0,0 +1,64 @@
/**
* @fileoverview workspaceScheduleBanner is an xstate machine backing a form,
* presented as an Alert/banner, for reactively extending a workspace schedule.
*/
import { createMachine } from "xstate"
import * as API from "../../api/api"
import { displayError, displaySuccess } from "../../components/GlobalSnackbar/utils"
import { defaultWorkspaceExtension } from "../../util/workspace"
export const Language = {
errorExtension: "Failed to extend workspace deadline.",
successExtension: "Successfully extended workspace deadline.",
}
export type WorkspaceScheduleBannerEvent = { type: "EXTEND_DEADLINE_DEFAULT"; workspaceId: string }
export const workspaceScheduleBannerMachine = createMachine(
{
tsTypes: {} as import("./workspaceScheduleBannerXService.typegen").Typegen0,
schema: {
events: {} as WorkspaceScheduleBannerEvent,
},
id: "workspaceScheduleBannerState",
initial: "idle",
states: {
idle: {
on: {
EXTEND_DEADLINE_DEFAULT: "extendingDeadline",
},
},
extendingDeadline: {
invoke: {
src: "extendDeadlineDefault",
id: "extendDeadlineDefault",
onDone: {
target: "idle",
actions: "displaySuccessMessage",
},
onError: {
target: "idle",
actions: "displayFailureMessage",
},
},
tags: "loading",
},
},
},
{
actions: {
displayFailureMessage: () => {
displayError(Language.errorExtension)
},
displaySuccessMessage: () => {
displaySuccess(Language.successExtension)
},
},
services: {
extendDeadlineDefault: async (_, event) => {
await API.putWorkspaceExtension(event.workspaceId, defaultWorkspaceExtension())
},
},
},
)