diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index fe88847804..41b26c608a 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -1,20 +1,6 @@ import { action } from "@storybook/addon-actions" import { Story } from "@storybook/react" -import { - MockCanceledWorkspace, - MockCancelingWorkspace, - MockDeletedWorkspace, - MockDeletingWorkspace, - MockFailedWorkspace, - MockOutdatedWorkspace, - MockStartingWorkspace, - MockStoppedWorkspace, - MockStoppingWorkspace, - MockWorkspace, - MockWorkspaceBuild, - MockWorkspaceResource, - MockWorkspaceResource2, -} from "../../testHelpers/renderHelpers" +import * as Mocks from "../../testHelpers/entities" import { Workspace, WorkspaceProps } from "./Workspace" export default { @@ -27,36 +13,73 @@ const Template: Story = (args) => export const Started = Template.bind({}) Started.args = { - workspace: MockWorkspace, + workspace: Mocks.MockWorkspace, handleStart: action("start"), handleStop: action("stop"), - resources: [MockWorkspaceResource, MockWorkspaceResource2], - builds: [MockWorkspaceBuild], + resources: [Mocks.MockWorkspaceResource, Mocks.MockWorkspaceResource2], + builds: [Mocks.MockWorkspaceBuild], } export const Starting = Template.bind({}) -Starting.args = { ...Started.args, workspace: MockStartingWorkspace } +Starting.args = { + ...Started.args, + workspace: Mocks.MockStartingWorkspace, +} export const Stopped = Template.bind({}) -Stopped.args = { ...Started.args, workspace: MockStoppedWorkspace } +Stopped.args = { + ...Started.args, + workspace: Mocks.MockStoppedWorkspace, +} export const Stopping = Template.bind({}) -Stopping.args = { ...Started.args, workspace: MockStoppingWorkspace } +Stopping.args = { + ...Started.args, + workspace: Mocks.MockStoppingWorkspace, +} export const Error = Template.bind({}) -Error.args = { ...Started.args, workspace: MockFailedWorkspace } +Error.args = { + ...Started.args, + workspace: { + ...Mocks.MockFailedWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + job: { + ...Mocks.MockProvisionerJob, + status: "failed", + }, + transition: "start", + }, + }, +} export const Deleting = Template.bind({}) -Deleting.args = { ...Started.args, workspace: MockDeletingWorkspace } +Deleting.args = { + ...Started.args, + workspace: Mocks.MockDeletingWorkspace, +} export const Deleted = Template.bind({}) -Deleted.args = { ...Started.args, workspace: MockDeletedWorkspace } +Deleted.args = { + ...Started.args, + workspace: Mocks.MockDeletedWorkspace, +} export const Canceling = Template.bind({}) -Canceling.args = { ...Started.args, workspace: MockCancelingWorkspace } +Canceling.args = { + ...Started.args, + workspace: Mocks.MockCancelingWorkspace, +} export const Canceled = Template.bind({}) -Canceled.args = { ...Started.args, workspace: MockCanceledWorkspace } +Canceled.args = { + ...Started.args, + workspace: Mocks.MockCanceledWorkspace, +} export const Outdated = Template.bind({}) -Outdated.args = { ...Started.args, workspace: MockOutdatedWorkspace } +Outdated.args = { + ...Started.args, + workspace: Mocks.MockOutdatedWorkspace, +} diff --git a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.stories.tsx b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.stories.tsx index 415e2553ab..83477970b2 100644 --- a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.stories.tsx +++ b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.stories.tsx @@ -1,12 +1,20 @@ import { Story } from "@storybook/react" import dayjs from "dayjs" +import utc from "dayjs/plugin/utc" import * as Mocks from "../../testHelpers/entities" import { WorkspaceSchedule, WorkspaceScheduleProps } from "./WorkspaceSchedule" +dayjs.extend(utc) + +// REMARK: There's a known problem with storybook and using date libraries that +// call string.toLowerCase +// SEE: https:github.com/storybookjs/storybook/issues/12208#issuecomment-697044557 +const ONE = 1 +const SEVEN = 7 + export default { title: "components/WorkspaceSchedule", component: WorkspaceSchedule, - argTypes: {}, } const Template: Story = (args) => @@ -15,6 +23,12 @@ export const NoTTL = Template.bind({}) NoTTL.args = { workspace: { ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + // a mannual shutdown has a deadline of '"0001-01-01T00:00:00Z"' + // SEE: #1834 + deadline: "0001-01-01T00:00:00Z", + }, ttl: undefined, }, } @@ -23,11 +37,10 @@ export const ShutdownSoon = Template.bind({}) ShutdownSoon.args = { workspace: { ...Mocks.MockWorkspace, - latest_build: { ...Mocks.MockWorkspaceBuild, + deadline: dayjs().add(ONE, "hour").utc().format(), transition: "start", - updated_at: dayjs().subtract(1, "hour").toString(), // 1 hour ago }, ttl: 2 * 60 * 60 * 1000 * 1_000_000, // 2 hours }, @@ -40,8 +53,8 @@ ShutdownLong.args = { latest_build: { ...Mocks.MockWorkspaceBuild, + deadline: dayjs().add(SEVEN, "days").utc().format(), transition: "start", - updated_at: dayjs().toString(), }, ttl: 7 * 24 * 60 * 60 * 1000 * 1_000_000, // 7 days }, @@ -55,7 +68,6 @@ WorkspaceOffShort.args = { latest_build: { ...Mocks.MockWorkspaceBuild, transition: "stop", - updated_at: dayjs().subtract(2, "days").toString(), }, ttl: 2 * 60 * 60 * 1000 * 1_000_000, // 2 hours }, @@ -69,7 +81,6 @@ WorkspaceOffLong.args = { latest_build: { ...Mocks.MockWorkspaceBuild, transition: "stop", - updated_at: dayjs().subtract(2, "days").toString(), }, ttl: 2 * 365 * 24 * 60 * 60 * 1000 * 1_000_000, // 2 years }, diff --git a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx index b273748488..6787a84494 100644 --- a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx +++ b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx @@ -6,17 +6,20 @@ import cronstrue from "cronstrue" import dayjs from "dayjs" import duration from "dayjs/plugin/duration" import relativeTime from "dayjs/plugin/relativeTime" +import utc from "dayjs/plugin/utc" import { FC } from "react" import { Link as RouterLink } from "react-router-dom" import { Workspace } from "../../api/typesGenerated" import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" import { extractTimezone, stripTimezone } from "../../util/schedule" +import { isWorkspaceOn } from "../../util/workspace" import { Stack } from "../Stack/Stack" +dayjs.extend(utc) dayjs.extend(duration) dayjs.extend(relativeTime) -const Language = { +export const Language = { autoStartDisplay: (schedule: string): string => { if (schedule) { return cronstrue.toString(stripTimezone(schedule), { throwExceptionOnParseError: false }) @@ -33,24 +36,34 @@ const Language = { } }, autoStopDisplay: (workspace: Workspace): string => { - const latest = workspace.latest_build + const deadline = dayjs(workspace.latest_build.deadline).utc() + // a mannual shutdown has a deadline of '"0001-01-01T00:00:00Z"' + // SEE: #1834 + const hasDeadline = deadline.year() > 1 + const ttl = workspace.ttl - if (!workspace.ttl || workspace.ttl < 1) { - return "Manual" - } - - if (latest.transition === "start") { - const now = dayjs() - const updatedAt = dayjs(latest.updated_at) - const deadline = updatedAt.add(workspace.ttl / 1_000_000, "ms") + if (isWorkspaceOn(workspace) && hasDeadline) { + // Workspace is on --> derive from latest_build.deadline. Note that the + // user may modify their workspace object (ttl) while the workspace is + // running and depending on system semantics, the deadline may still + // represent the previously defined ttl. Thus, we always derive from the + // deadline as the source of truth. + const now = dayjs().utc() if (now.isAfter(deadline)) { - return "Workspace is shutting down now" + return "Workspace is shutting down" + } else { + return now.to(deadline) } - return now.to(deadline) + } else if (!ttl || ttl < 1) { + // If the workspace is not on, and the ttl is 0 or undefined, then the + // workspace is set to manually shutdown. + return "Manual" + } else { + // The workspace has a ttl set, but is either in an unknown state or is + // not running. Therefore, we derive from workspace.ttl. + const duration = dayjs.duration(ttl / 1_000_000, "milliseconds") + return `${duration.humanize()} after start` } - - const duration = dayjs.duration(workspace.ttl / 1_000_000, "milliseconds") - return `${duration.humanize()} after start` }, editScheduleLink: "Edit schedule", schedule: "Schedule", diff --git a/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.test.tsx b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.test.tsx index b701db44af..d91315c0c6 100644 --- a/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.test.tsx +++ b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.test.tsx @@ -91,7 +91,6 @@ describe("WorkspaceScheduleBanner", () => { latest_build: { ...Mocks.MockWorkspaceBuild, deadline: dayjs().add(27, "minutes").utc().format(), - job: Mocks.MockRunningProvisionerJob, transition: "start", }, } diff --git a/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx index 55d6026fbb..97fdab6562 100644 --- a/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx +++ b/site/src/components/WorkspaceScheduleBanner/WorkspaceScheduleBanner.tsx @@ -5,6 +5,7 @@ import isSameOrBefore from "dayjs/plugin/isSameOrBefore" import utc from "dayjs/plugin/utc" import { FC } from "react" import * as TypesGen from "../../api/typesGenerated" +import { isWorkspaceOn } from "../../util/workspace" dayjs.extend(utc) dayjs.extend(isSameOrBefore) @@ -18,12 +19,7 @@ export interface WorkspaceScheduleBannerProps { } 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") { + if (!isWorkspaceOn(workspace)) { return false } else { // a mannual shutdown has a deadline of '"0001-01-01T00:00:00Z"' diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 85b84581c8..0c17805ba9 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -167,14 +167,24 @@ export const MockWorkspace: TypesGen.Workspace = { latest_build: MockWorkspaceBuild, } -export const MockStoppedWorkspace: TypesGen.Workspace = { ...MockWorkspace, latest_build: MockWorkspaceBuildStop } +export const MockStoppedWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + latest_build: MockWorkspaceBuildStop, +} export const MockStoppingWorkspace: TypesGen.Workspace = { ...MockWorkspace, - latest_build: { ...MockWorkspaceBuildStop, job: MockRunningProvisionerJob }, + latest_build: { + ...MockWorkspaceBuildStop, + job: MockRunningProvisionerJob, + }, } export const MockStartingWorkspace: TypesGen.Workspace = { ...MockWorkspace, - latest_build: { ...MockWorkspaceBuild, job: MockRunningProvisionerJob }, + latest_build: { + ...MockWorkspaceBuild, + job: MockRunningProvisionerJob, + transition: "start", + }, } export const MockCancelingWorkspace: TypesGen.Workspace = { ...MockWorkspace, @@ -186,7 +196,10 @@ export const MockCanceledWorkspace: TypesGen.Workspace = { } export const MockFailedWorkspace: TypesGen.Workspace = { ...MockWorkspace, - latest_build: { ...MockWorkspaceBuild, job: MockFailedProvisionerJob }, + latest_build: { + ...MockWorkspaceBuild, + job: MockFailedProvisionerJob, + }, } export const MockDeletingWorkspace: TypesGen.Workspace = { ...MockWorkspace, diff --git a/site/src/util/workspace.test.ts b/site/src/util/workspace.test.ts new file mode 100644 index 0000000000..69657a7501 --- /dev/null +++ b/site/src/util/workspace.test.ts @@ -0,0 +1,43 @@ +import * as TypesGen from "../api/typesGenerated" +import * as Mocks from "../testHelpers/entities" +import { isWorkspaceOn } from "./workspace" + +describe("util > workspace", () => { + describe("isWorkspaceOn", () => { + it.each<[TypesGen.WorkspaceTransition, TypesGen.ProvisionerJobStatus, boolean]>([ + ["delete", "canceled", false], + ["delete", "canceling", false], + ["delete", "failed", false], + ["delete", "pending", false], + ["delete", "running", false], + ["delete", "succeeded", false], + + ["stop", "canceled", false], + ["stop", "canceling", false], + ["stop", "failed", false], + ["stop", "pending", false], + ["stop", "running", false], + ["stop", "succeeded", false], + + ["start", "canceled", false], + ["start", "canceling", false], + ["start", "failed", false], + ["start", "pending", false], + ["start", "running", false], + ["start", "succeeded", true], + ])(`transition=%p, status=%p, isWorkspaceOn=%p`, (transition, status, isOn) => { + const workspace: TypesGen.Workspace = { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + job: { + ...Mocks.MockProvisionerJob, + status, + }, + transition, + }, + } + expect(isWorkspaceOn(workspace)).toBe(isOn) + }) + }) +}) diff --git a/site/src/util/workspace.ts b/site/src/util/workspace.ts index b176b9de96..5e4f678b48 100644 --- a/site/src/util/workspace.ts +++ b/site/src/util/workspace.ts @@ -1,7 +1,7 @@ import { Theme } from "@material-ui/core/styles" import dayjs from "dayjs" import { WorkspaceBuildTransition } from "../api/types" -import { WorkspaceAgent, WorkspaceBuild } from "../api/typesGenerated" +import { Workspace, WorkspaceAgent, WorkspaceBuild } from "../api/typesGenerated" export type WorkspaceStatus = | "queued" @@ -185,3 +185,9 @@ export const getDisplayAgentStatus = ( } } } + +export const isWorkspaceOn = (workspace: Workspace): boolean => { + const transition = workspace.latest_build.transition + const status = workspace.latest_build.job.status + return transition === "start" && status === "succeeded" +}