Skip to content

feat: ui autostop extension #1987

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 2, 2022
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -15,6 +16,7 @@ const Template: Story<WorkspaceScheduleBannerProps> = (args) => <WorkspaceSchedu

export const Example = Template.bind({})
Example.args = {
__onExtend: action("extend"),
workspace: {
...Mocks.MockWorkspace,
latest_build: {
Expand All @@ -26,6 +28,6 @@ Example.args = {
},
transition: "start",
},
ttl: 2 * 60 * 60 * 1000 * 1_000_000, // 2 hours
ttl_ms: 2 * 60 * 60 * 1000, // 2 hours
},
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
import Button from "@material-ui/core/Button"
import Alert from "@material-ui/lab/Alert"
import AlertTitle from "@material-ui/lab/AlertTitle"
import { useMachine } from "@xstate/react"
import dayjs from "dayjs"
import isSameOrBefore from "dayjs/plugin/isSameOrBefore"
import utc from "dayjs/plugin/utc"
import { FC } from "react"
import * as TypesGen from "../../api/typesGenerated"
import { isWorkspaceOn } from "../../util/workspace"
import { workspaceScheduleBanner } from "../../xServices/workspaceSchedule/workspaceScheduleBannerXService"

dayjs.extend(utc)
dayjs.extend(isSameOrBefore)

export const Language = {
bannerAction: "Extend",
bannerTitle: "Your workspace is scheduled to automatically shut down soon.",
}

export interface WorkspaceScheduleBannerProps {
/**
* @remarks __onExtend is used for testing purposes
*/
__onExtend?: () => void
workspace: TypesGen.Workspace
}

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

export const WorkspaceScheduleBanner: FC<WorkspaceScheduleBannerProps> = ({ workspace }) => {
export const WorkspaceScheduleBanner: FC<WorkspaceScheduleBannerProps> = ({ __onExtend, workspace }) => {
const [bannerState, bannerSend] = useMachine(workspaceScheduleBanner)

if (!shouldDisplay(workspace)) {
return null
} else {
return (
<Alert severity="warning">
<Alert
action={
<Button
color="inherit"
disabled={bannerState.hasTag("loading")}
onClick={() => {
if (__onExtend) {
__onExtend()
} else {
bannerSend({ type: "EXTEND_DEADLINE_DEFAULT", workspaceId: workspace.id })
}
}}
size="small"
>
{Language.bannerAction}
</Button>
}
severity="warning"
>
<AlertTitle>{Language.bannerTitle}</AlertTitle>
</Alert>
)
Expand Down
7 changes: 5 additions & 2 deletions site/src/testHelpers/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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))
}),
Expand Down
25 changes: 24 additions & 1 deletion site/src/util/workspace.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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)
})
})
})
29 changes: 22 additions & 7 deletions site/src/util/workspace.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -66,7 +69,7 @@ export const DisplayStatusLanguage = {

export const getDisplayStatus = (
theme: Theme,
build: WorkspaceBuild,
build: TypesGen.WorkspaceBuild,
): {
color: string
status: string
Expand Down Expand Up @@ -132,7 +135,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) {
Expand All @@ -144,7 +147,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
}
Expand All @@ -157,7 +163,7 @@ export const DisplayAgentStatusLanguage = {

export const getDisplayAgentStatus = (
theme: Theme,
agent: WorkspaceAgent,
agent: TypesGen.WorkspaceAgent,
): {
color: string
status: string
Expand Down Expand Up @@ -186,8 +192,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(),
}
}
Original file line number Diff line number Diff line change
@@ -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 workspaceScheduleBanner = 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())
},
},
},
)