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..1cb296bf24008 --- /dev/null +++ b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx @@ -0,0 +1,48 @@ +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: "Your workspace is scheduled to automatically shut down 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} + + ) + } +} 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]