Skip to content

fix: derive running ws stop time from deadline #1920

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
May 31, 2022
77 changes: 50 additions & 27 deletions site/src/components/Workspace/Workspace.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -27,36 +13,73 @@ const Template: Story<WorkspaceProps> = (args) => <Workspace {...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,
}
Original file line number Diff line number Diff line change
@@ -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<WorkspaceScheduleProps> = (args) => <WorkspaceSchedule {...args} />
Expand All @@ -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,
},
}
Expand All @@ -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
},
Expand All @@ -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
},
Expand All @@ -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
},
Expand All @@ -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
},
Expand Down
43 changes: 28 additions & 15 deletions site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ describe("WorkspaceScheduleBanner", () => {
latest_build: {
...Mocks.MockWorkspaceBuild,
deadline: dayjs().add(27, "minutes").utc().format(),
job: Mocks.MockRunningProvisionerJob,
transition: "start",
},
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"'
Expand Down
21 changes: 17 additions & 4 deletions site/src/testHelpers/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions site/src/util/workspace.test.ts
Original file line number Diff line number Diff line change
@@ -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],
Comment on lines +8 to +27
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💝

])(`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)
})
})
})
8 changes: 7 additions & 1 deletion site/src/util/workspace.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"
}
Comment on lines +189 to +193
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 it's a narrow definition of 'on' but I think that's the correct one.