mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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"),
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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))
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user