chore: refactor schedule banner (#4274)

* Start refactor

* Fix color of auto stop switch

* Format

* Use helper functions for min/max check

* Fix type

* Put new component in own file

* Fix decrease deadline bug

* Simplify functions

* Use ChooseOne

* Remove commented code
This commit is contained in:
Presley Pizzo
2022-09-30 17:39:14 -04:00
committed by GitHub
parent 139bc6f58b
commit d931b2c10d
7 changed files with 310 additions and 153 deletions
@@ -309,6 +309,7 @@ export const WorkspaceScheduleForm: FC<React.PropsWithChildren<WorkspaceSchedule
name="autoStopEnabled"
checked={form.values.autoStopEnabled}
onChange={handleToggleAutoStop}
color="primary"
/>
}
label={Language.stopSwitch}
+23 -126
View File
@@ -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 (
<div className={styles.error}>
{Boolean(getWorkspaceError) && <ErrorSummary error={getWorkspaceError} />}
{Boolean(getTemplateWarning) && <ErrorSummary error={getTemplateWarning} />}
{Boolean(checkPermissionsError) && <ErrorSummary error={checkPermissionsError} />}
</div>
)
} else if (!workspace || !permissions) {
return <FullScreenLoader />
} else if (!template) {
return <FullScreenLoader />
} else {
const deadline = dayjs(workspace.latest_build.deadline).utc()
const favicon = getFaviconByStatus(workspace.latest_build)
return (
<>
<Helmet>
<title>{pageTitle(`${workspace.owner_name}/${workspace.name}`)}</title>
<link rel="alternate icon" type="image/png" href={`/favicons/${favicon}.png`} />
<link rel="icon" type="image/svg+xml" href={`/favicons/${favicon}.svg`} />
</Helmet>
<Workspace
bannerProps={{
isLoading: bannerState.hasTag("loading"),
onExtend: () => {
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}
/>
<DeleteDialog
entity="workspace"
name={workspace.name}
info={t("deleteDialog.info", { timeAgo: dayjs(workspace.created_at).fromNow() })}
isOpen={workspaceState.matches({ ready: { build: "askingDelete" } })}
onCancel={() => workspaceSend("CANCEL_DELETE")}
onConfirm={() => {
workspaceSend("DELETE")
}}
/>
</>
)
}
return (
<ChooseOne>
<Cond condition={workspaceState.matches("error")}>
<div className={styles.error}>
{Boolean(getWorkspaceError) && <ErrorSummary error={getWorkspaceError} />}
{Boolean(getTemplateWarning) && <ErrorSummary error={getTemplateWarning} />}
{Boolean(checkPermissionsError) && <ErrorSummary error={checkPermissionsError} />}
</div>
</Cond>
<Cond condition={Boolean(workspace) && workspaceState.matches("ready")}>
<WorkspaceReadyPage workspaceState={workspaceState} workspaceSend={workspaceSend} />
</Cond>
<Cond>
<FullScreenLoader />
</Cond>
</ChooseOne>
)
}
const useStyles = makeStyles((theme) => ({
@@ -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<typeof workspaceMachine>
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 (
<>
<Helmet>
<title>{pageTitle(`${workspace.owner_name}/${workspace.name}`)}</title>
<link rel="alternate icon" type="image/png" href={`/favicons/${favicon}.png`} />
<link rel="icon" type="image/svg+xml" href={`/favicons/${favicon}.svg`} />
</Helmet>
<Workspace
bannerProps={{
isLoading: bannerState.hasTag("loading"),
onExtend: () => {
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}
/>
<DeleteDialog
entity="workspace"
name={workspace.name}
info={t("deleteDialog.info", { timeAgo: dayjs(workspace.created_at).fromNow() })}
isOpen={workspaceState.matches({ ready: { build: "askingDelete" } })}
onCancel={() => workspaceSend({ type: "CANCEL_DELETE" })}
onConfirm={() => {
workspaceSend({ type: "DELETE" })
}}
/>
</>
)
}
+5 -5
View File
@@ -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())
})
})
+21 -4
View File
@@ -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()
@@ -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,
@@ -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<typeof workspaceScheduleBannerMachine>
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: "idle",
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: "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)
},
},
},