From 1cc3ecd02d83697ff16a79e5101421368abe5fa8 Mon Sep 17 00:00:00 2001 From: G r e y Date: Thu, 2 Jun 2022 15:38:25 +0000 Subject: [PATCH 1/5] feat: ui autostop extension 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. --- site/src/api/api.ts | 7 ++ .../WorkspaceScheduleBanner.stories.tsx | 4 +- .../WorkspaceScheduleBanner.tsx | 32 +++++++++- site/src/testHelpers/handlers.ts | 7 +- site/src/util/workspace.test.ts | 25 +++++++- site/src/util/workspace.ts | 29 +++++++-- .../workspaceScheduleBannerXService.ts | 64 +++++++++++++++++++ 7 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 site/src/xServices/workspaceSchedule/workspaceScheduleBannerXService.ts 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/WorkspaceScheduleBanner/WorkspaceScheduleBanner.stories.tsx b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.stories.tsx index da760b2499694..496209e05587f 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,6 +16,7 @@ const Template: Story = (args) => void workspace: TypesGen.Workspace } @@ -31,12 +39,32 @@ export const shouldDisplay = (workspace: TypesGen.Workspace): boolean => { } } -export const WorkspaceScheduleBanner: FC = ({ workspace }) => { +export const WorkspaceScheduleBanner: FC = ({ __onExtend, workspace }) => { + const [bannerState, bannerSend] = useMachine(workspaceScheduleBanner) + if (!shouldDisplay(workspace)) { return null } else { return ( - + { + if (__onExtend) { + __onExtend() + } else { + bannerSend({ type: "EXTEND_DEADLINE_DEFAULT", workspaceId: workspace.id }) + } + }} + size="small" + > + {Language.bannerAction} + + } + severity="warning" + > {Language.bannerTitle} ) 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..693fc8a828996 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-02T15: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-02T15:26:34Z", + }, + ], + ])(`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..6201cdd10cf9f --- /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 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()) + }, + }, + }, +) From a378588cfbe199dcfaaca326a2839a0d69923dfb Mon Sep 17 00:00:00 2001 From: G r e y Date: Thu, 2 Jun 2022 15:50:31 +0000 Subject: [PATCH 2/5] fixup! feat: ui autostop extension --- site/src/util/workspace.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/util/workspace.test.ts b/site/src/util/workspace.test.ts index 693fc8a828996..f14f5a04bdc40 100644 --- a/site/src/util/workspace.test.ts +++ b/site/src/util/workspace.test.ts @@ -47,7 +47,7 @@ describe("util > workspace", () => { [ "2022-06-02T14:56:34Z", { - deadline: "2022-06-02T15:26:34Z", + deadline: "2022-06-02T16:26:34Z", }, ], @@ -56,7 +56,7 @@ describe("util > workspace", () => { [ "2022-06-02T10:56:20-04:00", { - deadline: "2022-06-02T15:26:34Z", + deadline: "2022-06-02T16:26:20Z", }, ], ])(`defaultWorkspaceExtension(%p) returns %p`, (startTime, request) => { From 650aa4ea8b8a7e797e6ceade0986f070454a231f Mon Sep 17 00:00:00 2001 From: G r e y Date: Thu, 2 Jun 2022 17:43:44 +0000 Subject: [PATCH 3/5] refactor: workspaceScheduleBanner -> ...Machine Renamed to reduce collision/overlap with the component name --- .../WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx | 8 ++------ .../workspaceSchedule/workspaceScheduleBannerXService.ts | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx index 5e0fcb62c8151..42bfc14f48009 100644 --- a/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx +++ b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx @@ -8,7 +8,7 @@ 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" +import { workspaceScheduleBannerMachine } from "../../xServices/workspaceSchedule/workspaceScheduleBannerXService" dayjs.extend(utc) dayjs.extend(isSameOrBefore) @@ -19,10 +19,6 @@ export const Language = { } export interface WorkspaceScheduleBannerProps { - /** - * @remarks __onExtend is used for testing purposes - */ - __onExtend?: () => void workspace: TypesGen.Workspace } @@ -40,7 +36,7 @@ export const shouldDisplay = (workspace: TypesGen.Workspace): boolean => { } export const WorkspaceScheduleBanner: FC = ({ __onExtend, workspace }) => { - const [bannerState, bannerSend] = useMachine(workspaceScheduleBanner) + const [bannerState, bannerSend] = useMachine(workspaceScheduleBannerMachine) if (!shouldDisplay(workspace)) { return null diff --git a/site/src/xServices/workspaceSchedule/workspaceScheduleBannerXService.ts b/site/src/xServices/workspaceSchedule/workspaceScheduleBannerXService.ts index 6201cdd10cf9f..418177519bf0b 100644 --- a/site/src/xServices/workspaceSchedule/workspaceScheduleBannerXService.ts +++ b/site/src/xServices/workspaceSchedule/workspaceScheduleBannerXService.ts @@ -14,7 +14,7 @@ export const Language = { export type WorkspaceScheduleBannerEvent = { type: "EXTEND_DEADLINE_DEFAULT"; workspaceId: string } -export const workspaceScheduleBanner = createMachine( +export const workspaceScheduleBannerMachine = createMachine( { tsTypes: {} as import("./workspaceScheduleBannerXService.typegen").Typegen0, schema: { From 0b3abe0f94bff155c337eba87c2d0104402a0c42 Mon Sep 17 00:00:00 2001 From: G r e y Date: Thu, 2 Jun 2022 18:16:37 +0000 Subject: [PATCH 4/5] fixup! refactor: workspaceScheduleBanner -> ...Machine --- .../WorkspaceScheduleBanner.stories.tsx | 2 -- .../WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx | 8 ++------ 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.stories.tsx b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.stories.tsx index 496209e05587f..7375669f91710 100644 --- a/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.stories.tsx +++ b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.stories.tsx @@ -1,4 +1,3 @@ -import { action } from "@storybook/addon-actions" import { Story } from "@storybook/react" import dayjs from "dayjs" import utc from "dayjs/plugin/utc" @@ -16,7 +15,6 @@ const Template: Story = (args) => { } } -export const WorkspaceScheduleBanner: FC = ({ __onExtend, workspace }) => { +export const WorkspaceScheduleBanner: FC = ({ workspace }) => { const [bannerState, bannerSend] = useMachine(workspaceScheduleBannerMachine) if (!shouldDisplay(workspace)) { @@ -48,11 +48,7 @@ export const WorkspaceScheduleBanner: FC = ({ __on color="inherit" disabled={bannerState.hasTag("loading")} onClick={() => { - if (__onExtend) { - __onExtend() - } else { - bannerSend({ type: "EXTEND_DEADLINE_DEFAULT", workspaceId: workspace.id }) - } + bannerSend({ type: "EXTEND_DEADLINE_DEFAULT", workspaceId: workspace.id }) }} size="small" > From 913f1f602241121872c60198875157c0202ae1f1 Mon Sep 17 00:00:00 2001 From: G r e y Date: Thu, 2 Jun 2022 19:25:12 +0000 Subject: [PATCH 5/5] do the thing --- .../components/Workspace/Workspace.stories.tsx | 4 ++++ site/src/components/Workspace/Workspace.tsx | 15 ++++++++++++++- .../WorkspaceScheduleBanner.stories.tsx | 11 +++++++++++ .../WorkspaceScheduleBanner.tsx | 17 ++++------------- site/src/pages/WorkspacePage/WorkspacePage.tsx | 9 +++++++++ 5 files changed, 42 insertions(+), 14 deletions(-) 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 7375669f91710..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 } @@ -35,23 +35,14 @@ export const shouldDisplay = (workspace: TypesGen.Workspace): boolean => { } } -export const WorkspaceScheduleBanner: FC = ({ workspace }) => { - const [bannerState, bannerSend] = useMachine(workspaceScheduleBannerMachine) - +export const WorkspaceScheduleBanner: FC = ({ isLoading, onExtend, workspace }) => { if (!shouldDisplay(workspace)) { return null } else { return ( { - bannerSend({ type: "EXTEND_DEADLINE_DEFAULT", workspaceId: workspace.id }) - }} - size="small" - > + } 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")}