Skip to content

Commit

Permalink
fix: derive running ws stop time from deadline (#1920)
Browse files Browse the repository at this point in the history
* refactor: isWorkspaceOn utility

Summary:

A utility is function is added that answers the question if a workspace
is on.

Impact:

This is a shared piece of logic in workspace scheduling presentations.
In particular it unblocks work in 1779, or at least allows an
implementation that shares details with the WorkspaceScheduleBanner.

Notes:

We could possibly instead return whether the workspace is "ON",
"UNKNOWN", or "OFF". Maybe a future improvement for that could be made
as the neds arrises.

* fix: derive running ws stop time from deadline

Summary:

When a workspace is on, the remaining time until shutdown needs to be
derived from the deadline timestamp, not implied from the TTL
  • Loading branch information
greyscaled committed May 31, 2022
1 parent c6167a9 commit 56ec53d
Show file tree
Hide file tree
Showing 8 changed files with 164 additions and 60 deletions.
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],
])(`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"
}

0 comments on commit 56ec53d

Please sign in to comment.