diff --git a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx
index 106a653ecb66e..05f225813d371 100644
--- a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx
+++ b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx
@@ -309,6 +309,7 @@ export const WorkspaceScheduleForm: FC
}
label={Language.stopSwitch}
diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx
index 20259771298e5..7daa934990507 100644
--- a/site/src/pages/WorkspacePage/WorkspacePage.tsx
+++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx
@@ -1,52 +1,21 @@
import { makeStyles } from "@material-ui/core/styles"
-import { useActor, useMachine, useSelector } from "@xstate/react"
-import { FeatureNames } from "api/types"
-import dayjs from "dayjs"
-import minMax from "dayjs/plugin/minMax"
-import { FC, useContext, useEffect } from "react"
-import { Helmet } from "react-helmet-async"
-import { useTranslation } from "react-i18next"
+import { useMachine } from "@xstate/react"
+import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
+import { FC, useEffect } from "react"
import { useParams } from "react-router-dom"
-import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
-import { DeleteDialog } from "../../components/Dialogs/DeleteDialog/DeleteDialog"
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
import { FullScreenLoader } from "../../components/Loader/FullScreenLoader"
-import { Workspace, WorkspaceErrors } from "../../components/Workspace/Workspace"
import { firstOrItem } from "../../util/array"
-import { pageTitle } from "../../util/page"
-import { canExtendDeadline, canReduceDeadline, maxDeadline, minDeadline } from "../../util/schedule"
-import { getFaviconByStatus } from "../../util/workspace"
-import { XServiceContext } from "../../xServices/StateContext"
import { workspaceMachine } from "../../xServices/workspace/workspaceXService"
-import { workspaceScheduleBannerMachine } from "../../xServices/workspaceSchedule/workspaceScheduleBannerXService"
-
-dayjs.extend(minMax)
+import { WorkspaceReadyPage } from "./WorkspaceReadyPage"
export const WorkspacePage: FC = () => {
const { username: usernameQueryParam, workspace: workspaceQueryParam } = useParams()
const username = firstOrItem(usernameQueryParam, null)
const workspaceName = firstOrItem(workspaceQueryParam, null)
- const { t } = useTranslation("workspacePage")
- const xServices = useContext(XServiceContext)
- const featureVisibility = useSelector(xServices.entitlementsXService, selectFeatureVisibility)
const [workspaceState, workspaceSend] = useMachine(workspaceMachine)
- const {
- workspace,
- getWorkspaceError,
- template,
- getTemplateWarning,
- refreshWorkspaceWarning,
- builds,
- getBuildsError,
- permissions,
- checkPermissionsError,
- buildError,
- cancellationError,
- applicationsHost,
- } = workspaceState.context
- const canUpdateWorkspace = Boolean(permissions?.updateWorkspace)
- const [bannerState, bannerSend] = useMachine(workspaceScheduleBannerMachine)
- const [buildInfoState] = useActor(xServices.buildInfoXService)
+ const { workspace, getWorkspaceError, getTemplateWarning, checkPermissionsError } =
+ workspaceState.context
const styles = useStyles()
/**
@@ -57,95 +26,23 @@ export const WorkspacePage: FC = () => {
username && workspaceName && workspaceSend({ type: "GET_WORKSPACE", username, workspaceName })
}, [username, workspaceName, workspaceSend])
- if (workspaceState.matches("error")) {
- return (
-
- {Boolean(getWorkspaceError) && }
- {Boolean(getTemplateWarning) && }
- {Boolean(checkPermissionsError) && }
-
- )
- } else if (!workspace || !permissions) {
- return
- } else if (!template) {
- return
- } else {
- const deadline = dayjs(workspace.latest_build.deadline).utc()
- const favicon = getFaviconByStatus(workspace.latest_build)
- return (
- <>
-
- {pageTitle(`${workspace.owner_name}/${workspace.name}`)}
-
-
-
-
- {
- bannerSend({
- type: "UPDATE_DEADLINE",
- workspaceId: workspace.id,
- newDeadline: dayjs.min(deadline.add(4, "hours"), maxDeadline(workspace, template)),
- })
- },
- }}
- scheduleProps={{
- onDeadlineMinus: () => {
- bannerSend({
- type: "UPDATE_DEADLINE",
- workspaceId: workspace.id,
- newDeadline: dayjs.max(deadline.add(-1, "hours"), minDeadline()),
- })
- },
- onDeadlinePlus: () => {
- bannerSend({
- type: "UPDATE_DEADLINE",
- workspaceId: workspace.id,
- newDeadline: dayjs.min(deadline.add(1, "hours"), maxDeadline(workspace, template)),
- })
- },
- deadlineMinusEnabled: () => {
- return canReduceDeadline(deadline)
- },
- deadlinePlusEnabled: () => {
- return canExtendDeadline(deadline, workspace, template)
- },
- }}
- isUpdating={workspaceState.hasTag("updating")}
- workspace={workspace}
- handleStart={() => workspaceSend("START")}
- handleStop={() => workspaceSend("STOP")}
- handleDelete={() => workspaceSend("ASK_DELETE")}
- handleUpdate={() => workspaceSend("UPDATE")}
- handleCancel={() => workspaceSend("CANCEL")}
- resources={workspace.latest_build.resources}
- builds={builds}
- canUpdateWorkspace={canUpdateWorkspace}
- hideSSHButton={featureVisibility[FeatureNames.BrowserOnly]}
- workspaceErrors={{
- [WorkspaceErrors.GET_RESOURCES_ERROR]: refreshWorkspaceWarning,
- [WorkspaceErrors.GET_BUILDS_ERROR]: getBuildsError,
- [WorkspaceErrors.BUILD_ERROR]: buildError,
- [WorkspaceErrors.CANCELLATION_ERROR]: cancellationError,
- }}
- buildInfo={buildInfoState.context.buildInfo}
- applicationsHost={applicationsHost}
- />
- workspaceSend("CANCEL_DELETE")}
- onConfirm={() => {
- workspaceSend("DELETE")
- }}
- />
- >
- )
- }
+ return (
+
+
+
+ {Boolean(getWorkspaceError) && }
+ {Boolean(getTemplateWarning) && }
+ {Boolean(checkPermissionsError) && }
+
+
+
+
+
+
+
+
+
+ )
}
const useStyles = makeStyles((theme) => ({
diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx
new file mode 100644
index 0000000000000..25dad00ca3181
--- /dev/null
+++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx
@@ -0,0 +1,112 @@
+import { useActor, useSelector } from "@xstate/react"
+import { FeatureNames } from "api/types"
+import dayjs from "dayjs"
+import { useContext } from "react"
+import { Helmet } from "react-helmet-async"
+import { useTranslation } from "react-i18next"
+import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
+import { StateFrom } from "xstate"
+import { DeleteDialog } from "../../components/Dialogs/DeleteDialog/DeleteDialog"
+import { Workspace, WorkspaceErrors } from "../../components/Workspace/Workspace"
+import { pageTitle } from "../../util/page"
+import { getFaviconByStatus } from "../../util/workspace"
+import { XServiceContext } from "../../xServices/StateContext"
+import { WorkspaceEvent, workspaceMachine } from "../../xServices/workspace/workspaceXService"
+
+interface WorkspaceReadyPageProps {
+ workspaceState: StateFrom
+ workspaceSend: (event: WorkspaceEvent) => void
+}
+
+export const WorkspaceReadyPage = ({
+ workspaceState,
+ workspaceSend,
+}: WorkspaceReadyPageProps): JSX.Element => {
+ const [bannerState, bannerSend] = useActor(workspaceState.children["scheduleBannerMachine"])
+ const xServices = useContext(XServiceContext)
+ const featureVisibility = useSelector(xServices.entitlementsXService, selectFeatureVisibility)
+ const [buildInfoState] = useActor(xServices.buildInfoXService)
+ const {
+ workspace,
+ refreshWorkspaceWarning,
+ builds,
+ getBuildsError,
+ buildError,
+ cancellationError,
+ applicationsHost,
+ permissions,
+ } = workspaceState.context
+ if (workspace === undefined) {
+ throw Error("Workspace is undefined")
+ }
+ const canUpdateWorkspace = Boolean(permissions?.updateWorkspace)
+ const { t } = useTranslation("workspacePage")
+ const favicon = getFaviconByStatus(workspace.latest_build)
+
+ return (
+ <>
+
+ {pageTitle(`${workspace.owner_name}/${workspace.name}`)}
+
+
+
+
+ {
+ bannerSend({
+ type: "INCREASE_DEADLINE",
+ hours: 4,
+ })
+ },
+ }}
+ scheduleProps={{
+ onDeadlineMinus: () => {
+ bannerSend({
+ type: "DECREASE_DEADLINE",
+ hours: 1,
+ })
+ },
+ onDeadlinePlus: () => {
+ bannerSend({
+ type: "INCREASE_DEADLINE",
+ hours: 1,
+ })
+ },
+ deadlineMinusEnabled: () => !bannerState.matches("atMinDeadline"),
+ deadlinePlusEnabled: () => !bannerState.matches("atMaxDeadline"),
+ }}
+ isUpdating={workspaceState.hasTag("updating")}
+ workspace={workspace}
+ handleStart={() => workspaceSend({ type: "START" })}
+ handleStop={() => workspaceSend({ type: "STOP" })}
+ handleDelete={() => workspaceSend({ type: "ASK_DELETE" })}
+ handleUpdate={() => workspaceSend({ type: "UPDATE" })}
+ handleCancel={() => workspaceSend({ type: "CANCEL" })}
+ resources={workspace.latest_build.resources}
+ builds={builds}
+ canUpdateWorkspace={canUpdateWorkspace}
+ hideSSHButton={featureVisibility[FeatureNames.BrowserOnly]}
+ workspaceErrors={{
+ [WorkspaceErrors.GET_RESOURCES_ERROR]: refreshWorkspaceWarning,
+ [WorkspaceErrors.GET_BUILDS_ERROR]: getBuildsError,
+ [WorkspaceErrors.BUILD_ERROR]: buildError,
+ [WorkspaceErrors.CANCELLATION_ERROR]: cancellationError,
+ }}
+ buildInfo={buildInfoState.context.buildInfo}
+ applicationsHost={applicationsHost}
+ />
+ workspaceSend({ type: "CANCEL_DELETE" })}
+ onConfirm={() => {
+ workspaceSend({ type: "DELETE" })
+ }}
+ />
+ >
+ )
+}
diff --git a/site/src/util/schedule.test.ts b/site/src/util/schedule.test.ts
index 584fc9b12a422..998c4666cd866 100644
--- a/site/src/util/schedule.test.ts
+++ b/site/src/util/schedule.test.ts
@@ -8,8 +8,8 @@ import {
deadlineExtensionMax,
deadlineExtensionMin,
extractTimezone,
- maxDeadline,
- minDeadline,
+ getMaxDeadline,
+ getMinDeadline,
stripTimezone,
} from "./schedule"
@@ -55,7 +55,7 @@ describe("maxDeadline", () => {
}
// Then: deadlineMinusDisabled should be falsy
- const delta = maxDeadline(workspace, template).diff(now)
+ const delta = getMaxDeadline(workspace, template).diff(now)
expect(delta).toBeLessThanOrEqual(deadlineExtensionMax.asMilliseconds())
})
})
@@ -68,7 +68,7 @@ describe("maxDeadline", () => {
}
// Then: deadlineMinusDisabled should be falsy
- const delta = maxDeadline(workspace, template).diff(now)
+ const delta = getMaxDeadline(workspace, template).diff(now)
expect(delta).toBeLessThanOrEqual(deadlineExtensionMax.asMilliseconds())
})
})
@@ -76,7 +76,7 @@ describe("maxDeadline", () => {
describe("minDeadline", () => {
it("should never be less than 30 minutes", () => {
- const delta = minDeadline().diff(now)
+ const delta = getMinDeadline().diff(now)
expect(delta).toBeGreaterThanOrEqual(deadlineExtensionMin.asMilliseconds())
})
})
diff --git a/site/src/util/schedule.ts b/site/src/util/schedule.ts
index 41560d0b934ce..fffa8cb294d47 100644
--- a/site/src/util/schedule.ts
+++ b/site/src/util/schedule.ts
@@ -113,16 +113,30 @@ export const autoStopDisplay = (workspace: Workspace): string => {
export const deadlineExtensionMin = dayjs.duration(30, "minutes")
export const deadlineExtensionMax = dayjs.duration(24, "hours")
-export function maxDeadline(ws: Workspace, tpl: Template): dayjs.Dayjs {
+/**
+ * Depends on the time the workspace was last updated, the template config,
+ * and a global constant.
+ * @param ws workspace
+ * @param tpl template
+ * @returns the latest datetime at which the workspace can be automatically shut down.
+ */
+export function getMaxDeadline(ws: Workspace | undefined, tpl: Template): dayjs.Dayjs {
// note: we count runtime from updated_at as started_at counts from the start of
// the workspace build process, which can take a while.
+ if (ws === undefined) {
+ throw Error("Cannot calculate max deadline because workspace is undefined")
+ }
const startedAt = dayjs(ws.latest_build.updated_at)
const maxTemplateDeadline = startedAt.add(dayjs.duration(tpl.max_ttl_ms, "milliseconds"))
const maxGlobalDeadline = startedAt.add(deadlineExtensionMax)
return dayjs.min(maxTemplateDeadline, maxGlobalDeadline)
}
-export function minDeadline(): dayjs.Dayjs {
+/**
+ * Depends on the current time and a global constant.
+ * @returns the earliest datetime at which the workspace can be automatically shut down.
+ */
+export function getMinDeadline(): dayjs.Dayjs {
return dayjs().add(deadlineExtensionMin)
}
@@ -131,9 +145,12 @@ export function canExtendDeadline(
workspace: Workspace,
template: Template,
): boolean {
- return deadline < maxDeadline(workspace, template)
+ return deadline < getMaxDeadline(workspace, template)
}
export function canReduceDeadline(deadline: dayjs.Dayjs): boolean {
- return deadline > minDeadline()
+ return deadline > getMinDeadline()
}
+
+export const getDeadline = (workspace: Workspace): dayjs.Dayjs =>
+ dayjs(workspace.latest_build.deadline).utc()
diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts
index 7538a2453f0fa..e8cee0d67f6e5 100644
--- a/site/src/xServices/workspace/workspaceXService.ts
+++ b/site/src/xServices/workspace/workspaceXService.ts
@@ -1,4 +1,5 @@
import { getErrorMessage } from "api/errors"
+import { workspaceScheduleBannerMachine } from "xServices/workspaceSchedule/workspaceScheduleBannerXService"
import { assign, createMachine, send } from "xstate"
import * as API from "../../api/api"
import * as Types from "../../api/types"
@@ -78,6 +79,8 @@ export type WorkspaceEvent =
| { type: "CANCEL" }
| { type: "REFRESH_TIMELINE"; checkRefresh?: boolean; data?: TypesGen.ServerSentEvent["data"] }
| { type: "EVENT_SOURCE_ERROR"; error: Error | unknown }
+ | { type: "INCREASE_DEADLINE"; hours: number }
+ | { type: "DECREASE_DEADLINE"; hours: number }
export const checks = {
readWorkspace: "readWorkspace",
@@ -216,6 +219,7 @@ export const workspaceMachine = createMachine(
},
],
},
+ tags: "loading",
},
ready: {
type: "parallel",
@@ -440,6 +444,19 @@ export const workspaceMachine = createMachine(
},
},
},
+ schedule: {
+ invoke: {
+ id: "scheduleBannerMachine",
+ src: workspaceScheduleBannerMachine,
+ data: {
+ workspace: (context: WorkspaceContext) => context.workspace,
+ template: (context: WorkspaceContext) => context.template,
+ },
+ },
+ on: {
+ REFRESH_WORKSPACE: { actions: "sendWorkspaceToSchedule" },
+ },
+ },
},
},
error: {
@@ -551,6 +568,13 @@ export const workspaceMachine = createMachine(
const message = getErrorMessage(data, "Error getting the applications host.")
displayError(message)
},
+ sendWorkspaceToSchedule: send(
+ (context) => ({
+ type: "REFRESH_WORKSPACE",
+ workspace: context.workspace,
+ }),
+ { to: "scheduleBannerMachine" },
+ ),
},
guards: {
moreBuildsAvailable,
diff --git a/site/src/xServices/workspaceSchedule/workspaceScheduleBannerXService.ts b/site/src/xServices/workspaceSchedule/workspaceScheduleBannerXService.ts
index 5dd803bc3b9ae..0f09b48825594 100644
--- a/site/src/xServices/workspaceSchedule/workspaceScheduleBannerXService.ts
+++ b/site/src/xServices/workspaceSchedule/workspaceScheduleBannerXService.ts
@@ -3,22 +3,49 @@
* presented as an Alert/banner, for reactively updating a workspace schedule.
*/
import { getErrorMessage } from "api/errors"
+import { Template, Workspace } from "api/typesGenerated"
import dayjs from "dayjs"
-import { createMachine } from "xstate"
+import minMax from "dayjs/plugin/minMax"
+import {
+ canExtendDeadline,
+ canReduceDeadline,
+ getDeadline,
+ getMaxDeadline,
+ getMinDeadline,
+} from "util/schedule"
+import { ActorRefFrom, assign, createMachine } from "xstate"
import * as API from "../../api/api"
import { displayError, displaySuccess } from "../../components/GlobalSnackbar/utils"
+dayjs.extend(minMax)
+
export const Language = {
errorExtension: "Failed to update workspace shutdown time.",
successExtension: "Updated workspace shutdown time.",
}
-export type WorkspaceScheduleBannerEvent = {
- type: "UPDATE_DEADLINE"
- workspaceId: string
- newDeadline: dayjs.Dayjs
+export interface WorkspaceScheduleBannerContext {
+ workspace: Workspace
+ template: Template
+ deadline?: dayjs.Dayjs
}
+export type WorkspaceScheduleBannerEvent =
+ | {
+ type: "INCREASE_DEADLINE"
+ hours: number
+ }
+ | {
+ type: "DECREASE_DEADLINE"
+ hours: number
+ }
+ | {
+ type: "REFRESH_WORKSPACE"
+ workspace: Workspace
+ }
+
+export type WorkspaceScheduleBannerMachineRef = ActorRefFrom
+
export const workspaceScheduleBannerMachine = createMachine(
{
id: "workspaceScheduleBannerState",
@@ -26,24 +53,75 @@ export const workspaceScheduleBannerMachine = createMachine(
tsTypes: {} as import("./workspaceScheduleBannerXService.typegen").Typegen0,
schema: {
events: {} as WorkspaceScheduleBannerEvent,
+ context: {} as WorkspaceScheduleBannerContext,
+ },
+ initial: "initialize",
+ on: {
+ REFRESH_WORKSPACE: { actions: "assignWorkspace" },
},
- initial: "idle",
states: {
- idle: {
+ initialize: {
+ always: [
+ { cond: "isAtMaxDeadline", target: "atMaxDeadline" },
+ { cond: "isAtMinDeadline", target: "atMinDeadline" },
+ { target: "midRange" },
+ ],
+ },
+ midRange: {
on: {
- UPDATE_DEADLINE: "updatingDeadline",
+ INCREASE_DEADLINE: "increasingDeadline",
+ DECREASE_DEADLINE: "decreasingDeadline",
},
},
- updatingDeadline: {
+ atMaxDeadline: {
+ on: {
+ DECREASE_DEADLINE: "decreasingDeadline",
+ },
+ },
+ atMinDeadline: {
+ on: {
+ INCREASE_DEADLINE: "increasingDeadline",
+ },
+ },
+ increasingDeadline: {
invoke: {
- src: "updateDeadline",
- id: "updateDeadline",
- onDone: {
- target: "idle",
- actions: "displaySuccessMessage",
+ src: "increaseDeadline",
+ id: "increaseDeadline",
+ onDone: [
+ {
+ cond: "isAtMaxDeadline",
+ target: "atMaxDeadline",
+ actions: "displaySuccessMessage",
+ },
+ {
+ target: "midRange",
+ actions: "displaySuccessMessage",
+ },
+ ],
+ onError: {
+ target: "midRange",
+ actions: "displayFailureMessage",
},
+ },
+ tags: "loading",
+ },
+ decreasingDeadline: {
+ invoke: {
+ src: "decreaseDeadline",
+ id: "decreaseDeadline",
+ onDone: [
+ {
+ cond: "isAtMinDeadline",
+ target: "atMinDeadline",
+ actions: "displaySuccessMessage",
+ },
+ {
+ target: "midRange",
+ actions: "displaySuccessMessage",
+ },
+ ],
onError: {
- target: "idle",
+ target: "midRange",
actions: "displayFailureMessage",
},
},
@@ -52,6 +130,14 @@ export const workspaceScheduleBannerMachine = createMachine(
},
},
{
+ guards: {
+ isAtMaxDeadline: (context) =>
+ context.deadline
+ ? !canExtendDeadline(context.deadline, context.workspace, context.template)
+ : false,
+ isAtMinDeadline: (context) =>
+ context.deadline ? !canReduceDeadline(context.deadline) : false,
+ },
actions: {
// This error does not have a detail, so using the snackbar is okay
displayFailureMessage: (_, event) => {
@@ -60,11 +146,31 @@ export const workspaceScheduleBannerMachine = createMachine(
displaySuccessMessage: () => {
displaySuccess(Language.successExtension)
},
+ assignWorkspace: assign((_, event) => ({
+ workspace: event.workspace,
+ deadline: getDeadline(event.workspace),
+ })),
},
services: {
- updateDeadline: async (_, event) => {
- await API.putWorkspaceExtension(event.workspaceId, event.newDeadline)
+ increaseDeadline: async (context, event) => {
+ if (!context.deadline) {
+ throw Error("Deadline is undefined.")
+ }
+ const proposedDeadline = context.deadline.add(event.hours, "hours")
+ const newDeadline = dayjs.min(
+ proposedDeadline,
+ getMaxDeadline(context.workspace, context.template),
+ )
+ await API.putWorkspaceExtension(context.workspace.id, newDeadline)
+ },
+ decreaseDeadline: async (context, event) => {
+ if (!context.deadline) {
+ throw Error("Deadline is undefined.")
+ }
+ const proposedDeadline = context.deadline.subtract(event.hours, "hours")
+ const newDeadline = dayjs.max(proposedDeadline, getMinDeadline())
+ await API.putWorkspaceExtension(context.workspace.id, newDeadline)
},
},
},