diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 68c41b77e2ae7..4e06ccc5306e8 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -270,3 +270,10 @@ export const getWorkspaceBuildLogs = async (buildname: string): Promise(`/api/v2/workspacebuilds/${buildname}/logs`) return response.data } + +export const putWorkspaceExtension = async ( + workspaceId: string, + extendWorkspaceRequest: TypesGen.PutExtendWorkspaceRequest, +): Promise => { + await axios.put(`/api/v2/workspaces/${workspaceId}/extend`, extendWorkspaceRequest) +} diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index 41b26c608abc7..2b2cb317bde1b 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -13,6 +13,10 @@ const Template: Story = (args) => export const Started = Template.bind({}) Started.args = { + bannerProps: { + isLoading: false, + onExtend: action("extend"), + }, workspace: Mocks.MockWorkspace, handleStart: action("start"), handleStop: action("stop"), diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 54ee44fa77a3e..3903dcdd2a1c6 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -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 = ({ + bannerProps, handleStart, handleStop, handleDelete, @@ -54,6 +59,7 @@ export const Workspace: FC = ({ {workspace.owner_name} + = ({ - + + + + diff --git a/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.stories.tsx b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.stories.tsx index da760b2499694..787b9aa2c1d73 100644 --- a/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.stories.tsx +++ b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.stories.tsx @@ -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 = (args) => void workspace: TypesGen.Workspace } @@ -31,12 +35,19 @@ export const shouldDisplay = (workspace: TypesGen.Workspace): boolean => { } } -export const WorkspaceScheduleBanner: FC = ({ workspace }) => { +export const WorkspaceScheduleBanner: FC = ({ isLoading, onExtend, workspace }) => { if (!shouldDisplay(workspace)) { return null } else { return ( - + + {Language.bannerAction} + + } + severity="warning" + > {Language.bannerTitle} ) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 7129fd99dee7b..5dced78ba09e1 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -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 = () => { <> { + bannerSend({ type: "EXTEND_DEADLINE_DEFAULT", workspaceId: workspace.id }) + }, + }} workspace={workspace} handleStart={() => workspaceSend("START")} handleStop={() => workspaceSend("STOP")} diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index ccebc5eae5375..35419d8e333e5 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -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)) }), diff --git a/site/src/util/workspace.test.ts b/site/src/util/workspace.test.ts index 69657a75015bd..f14f5a04bdc40 100644 --- a/site/src/util/workspace.test.ts +++ b/site/src/util/workspace.test.ts @@ -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) + }) + }) }) diff --git a/site/src/util/workspace.ts b/site/src/util/workspace.ts index 5e4f678b48680..3cd98d5daab10 100644 --- a/site/src/util/workspace.ts +++ b/site/src/util/workspace.ts @@ -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 = { } // 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) { @@ -66,7 +69,7 @@ export const DisplayStatusLanguage = { export const getDisplayStatus = ( theme: Theme, - build: WorkspaceBuild, + build: TypesGen.WorkspaceBuild, ): { color: string status: string @@ -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) { @@ -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 } @@ -157,7 +163,7 @@ export const DisplayAgentStatusLanguage = { export const getDisplayAgentStatus = ( theme: Theme, - agent: WorkspaceAgent, + agent: TypesGen.WorkspaceAgent, ): { color: string status: string @@ -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(), + } +} diff --git a/site/src/xServices/workspaceSchedule/workspaceScheduleBannerXService.ts b/site/src/xServices/workspaceSchedule/workspaceScheduleBannerXService.ts new file mode 100644 index 0000000000000..418177519bf0b --- /dev/null +++ b/site/src/xServices/workspaceSchedule/workspaceScheduleBannerXService.ts @@ -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()) + }, + }, + }, +)