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) }, }, },