From f049ae9173a616832b9ccc6a5fe691eadd373f7c Mon Sep 17 00:00:00 2001 From: G r e y Date: Fri, 27 May 2022 06:19:43 +0000 Subject: [PATCH 1/4] feat: ui alert <= 30mins from deadline Summary: When a workspace build is <= 30 minutes from auto-scheduled shutdown, then an alert banner is displayed on the workspace page. --- site/src/components/Workspace/Workspace.tsx | 5 + .../WorkspaceScheduleBanner.stories.tsx | 32 +++++ .../WorkspaceScheduleBanner.test.tsx | 117 ++++++++++++++++++ .../WorkspaceScheduleBanner.tsx | 50 ++++++++ .../xServices/workspace/workspaceXService.ts | 3 +- 5 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.stories.tsx create mode 100644 site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.test.tsx create mode 100644 site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 1194d024acb48..ec9343d5e18e7 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -8,6 +8,7 @@ import { Resources } from "../Resources/Resources" import { Stack } from "../Stack/Stack" import { WorkspaceActions } from "../WorkspaceActions/WorkspaceActions" import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule" +import { WorkspaceScheduleBanner } from "../WorkspaceScheduleBanner/WorkspaceScheduleBanner" import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats" @@ -63,8 +64,12 @@ export const Workspace: React.FC = ({ + + + + diff --git a/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.stories.tsx b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.stories.tsx new file mode 100644 index 0000000000000..7324d1e2d4cfb --- /dev/null +++ b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.stories.tsx @@ -0,0 +1,32 @@ +import { Story } from "@storybook/react" +import dayjs from "dayjs" +import utc from "dayjs/plugin/utc" +import React from "react" +import * as Mocks from "../../testHelpers/entities" +import { WorkspaceScheduleBanner, WorkspaceScheduleBannerProps } from "./WorkspaceScheduleBanner" + +dayjs.extend(utc) + +export default { + title: "components/WorkspaceScheduleBanner", + component: WorkspaceScheduleBanner, +} + +const Template: Story = (args) => + +export const Example = Template.bind({}) +Example.args = { + workspace: { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + deadline: dayjs().utc().format(), + job: { + ...Mocks.MockProvisionerJob, + status: "succeeded", + }, + transition: "start", + }, + ttl: 2 * 60 * 60 * 1000 * 1_000_000, // 2 hours + }, +} diff --git a/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.test.tsx b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.test.tsx new file mode 100644 index 0000000000000..b701db44afed9 --- /dev/null +++ b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.test.tsx @@ -0,0 +1,117 @@ +import dayjs from "dayjs" +import utc from "dayjs/plugin/utc" +import * as TypesGen from "../../api/typesGenerated" +import * as Mocks from "../../testHelpers/entities" +import { shouldDisplay } from "./WorkspaceScheduleBanner" + +dayjs.extend(utc) + +describe("WorkspaceScheduleBanner", () => { + describe("shouldDisplay", () => { + // Manual TTL case + it("should not display if the build does not have a deadline", () => { + // Given: a workspace with deadline of '"0001-01-01T00:00:00Z"' + const workspace: TypesGen.Workspace = { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + deadline: "0001-01-01T00:00:00Z", + transition: "start", + }, + } + + // Then: shouldDisplay is false + expect(shouldDisplay(workspace)).toBeFalsy() + }) + + // Transition Checks + it("should not display if the latest build is not transition=start", () => { + // Given: a workspace with latest build as "stop" + const workspace: TypesGen.Workspace = { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + transition: "stop", + }, + } + + // Then: shouldDisplay is false + expect(shouldDisplay(workspace)).toBeFalsy() + }) + + // Provisioner Job Checks + it("should not display if the latest build is canceling", () => { + // Given: a workspace with latest build as "canceling" + const workspace: TypesGen.Workspace = { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + job: Mocks.MockCancelingProvisionerJob, + transition: "start", + }, + } + + // Then: shouldDisplay is false + expect(shouldDisplay(workspace)).toBeFalsy() + }) + it("should not display if the latest build is canceled", () => { + // Given: a workspace with latest build as "canceled" + const workspace: TypesGen.Workspace = { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + job: Mocks.MockCanceledProvisionerJob, + transition: "start", + }, + } + + // Then: shouldDisplay is false + expect(shouldDisplay(workspace)).toBeFalsy() + }) + it("should not display if the latest build failed", () => { + // Given: a workspace with latest build as "failed" + const workspace: TypesGen.Workspace = { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + job: Mocks.MockFailedProvisionerJob, + transition: "start", + }, + } + + // Then: shouldDisplay is false + expect(shouldDisplay(workspace)).toBeFalsy() + }) + + // Deadline Checks + it("should display if deadline is within 30 minutes", () => { + // Given: a workspace with latest build as start and deadline in ~30 mins + const workspace: TypesGen.Workspace = { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + deadline: dayjs().add(27, "minutes").utc().format(), + job: Mocks.MockRunningProvisionerJob, + transition: "start", + }, + } + + // Then: shouldDisplay is true + expect(shouldDisplay(workspace)).toBeTruthy() + }) + it("should not display if deadline is 45 minutes", () => { + // Given: a workspace with latest build as start and deadline in 45 mins + const workspace: TypesGen.Workspace = { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + deadline: dayjs().add(45, "minutes").utc().format(), + transition: "start", + }, + } + + // Then: shouldDisplay is false + expect(shouldDisplay(workspace)).toBeFalsy() + }) + }) +}) diff --git a/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx new file mode 100644 index 0000000000000..631b944b6391f --- /dev/null +++ b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx @@ -0,0 +1,50 @@ +import Alert from "@material-ui/lab/Alert" +import AlertTitle from "@material-ui/lab/AlertTitle" +import dayjs from "dayjs" +import isSameOrBefore from "dayjs/plugin/isSameOrBefore" +import utc from "dayjs/plugin/utc" +import React from "react" +import * as TypesGen from "../../api/typesGenerated" + +dayjs.extend(utc) +dayjs.extend(isSameOrBefore) + +export const Language = { + bannerTitle: "Workspace Shutdown", + bannerDetail: "Your workspace will shutdown soon.", +} + +export interface WorkspaceScheduleBannerProps { + workspace: TypesGen.Workspace +} + +export const shouldDisplay = (workspace: TypesGen.Workspace): boolean => { + const transition = workspace.latest_build.transition + const status = workspace.latest_build.job.status + + if (transition !== "start") { + return false + } else if (status === "canceled" || status === "canceling" || status === "failed") { + return false + } else { + // a mannual shutdown has a deadline of '"0001-01-01T00:00:00Z"' + // SEE: #1834 + const deadline = dayjs(workspace.latest_build.deadline).utc() + const hasDeadline = deadline.year() > 1 + const thirtyMinutesFromNow = dayjs().add(30, "minutes").utc() + return hasDeadline && deadline.isSameOrBefore(thirtyMinutesFromNow) + } +} + +export const WorkspaceScheduleBanner: React.FC = ({ workspace }) => { + if (!shouldDisplay(workspace)) { + return null + } else { + return ( + + {Language.bannerTitle} + {Language.bannerDetail} + + ) + } +} diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 075475b45a9d0..0d5571194f3ff 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -370,7 +370,8 @@ export const workspaceMachine = createMachine( const oldBuilds = context.builds if (!oldBuilds) { - throw new Error("Builds not loaded") + // This state is theoretically impossible, but helps TS + throw new Error("workspaceXService: failed to load workspace builds") } return [...oldBuilds, ...event.data] From 877b8a810b04103137ce38a9a978ba938ecb0961 Mon Sep 17 00:00:00 2001 From: G r e y Date: Fri, 27 May 2022 11:38:59 -0400 Subject: [PATCH 2/4] Update site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx --- .../WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx index 631b944b6391f..4fd41f699e3e1 100644 --- a/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx +++ b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx @@ -10,7 +10,7 @@ dayjs.extend(utc) dayjs.extend(isSameOrBefore) export const Language = { - bannerTitle: "Workspace Shutdown", + bannerTitle: "Workspace shutdown", bannerDetail: "Your workspace will shutdown soon.", } From 59c0695b9ae80109fe1ee154cd8dd6618a1e8b58 Mon Sep 17 00:00:00 2001 From: G r e y Date: Fri, 27 May 2022 11:39:12 -0400 Subject: [PATCH 3/4] Update site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx --- .../WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx index 4fd41f699e3e1..20cf6077db35c 100644 --- a/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx +++ b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx @@ -11,7 +11,7 @@ dayjs.extend(isSameOrBefore) export const Language = { bannerTitle: "Workspace shutdown", - bannerDetail: "Your workspace will shutdown soon.", + bannerDetail: "Your workspace is scheduled to automatically shut down soon.", } export interface WorkspaceScheduleBannerProps { From e4db833d861f77ebe3d583eb7e5df865edff9352 Mon Sep 17 00:00:00 2001 From: G r e y Date: Fri, 27 May 2022 14:44:49 -0400 Subject: [PATCH 4/4] Apply suggestions from code review --- .../WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx index 20cf6077db35c..1cb296bf24008 100644 --- a/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx +++ b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx @@ -10,8 +10,7 @@ dayjs.extend(utc) dayjs.extend(isSameOrBefore) export const Language = { - bannerTitle: "Workspace shutdown", - bannerDetail: "Your workspace is scheduled to automatically shut down soon.", + bannerTitle: "Your workspace is scheduled to automatically shut down soon.", } export interface WorkspaceScheduleBannerProps { @@ -43,7 +42,6 @@ export const WorkspaceScheduleBanner: React.FC = ( return ( {Language.bannerTitle} - {Language.bannerDetail} ) }