mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
fix: derive running ws stop time from deadline (#1920)
* 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
This commit is contained in:
@@ -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<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,
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -91,7 +91,6 @@ describe("WorkspaceScheduleBanner", () => {
|
||||
latest_build: {
|
||||
...Mocks.MockWorkspaceBuild,
|
||||
deadline: dayjs().add(27, "minutes").utc().format(),
|
||||
job: Mocks.MockRunningProvisionerJob,
|
||||
transition: "start",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user