diff --git a/site/src/pages/WorkspacePage/Workspace.stories.tsx b/site/src/pages/WorkspacePage/Workspace.stories.tsx index bd7fc91db52e5..f3f27e840c9f9 100644 --- a/site/src/pages/WorkspacePage/Workspace.stories.tsx +++ b/site/src/pages/WorkspacePage/Workspace.stories.tsx @@ -3,7 +3,7 @@ import { Meta, StoryObj } from "@storybook/react"; import { WatchAgentMetadataContext } from "components/Resources/AgentMetadata"; import { ProvisionerJobLog } from "api/typesGenerated"; import * as Mocks from "testHelpers/entities"; -import { Workspace, WorkspaceErrors } from "./Workspace"; +import { Workspace } from "./Workspace"; import { withReactContext } from "storybook-react-context"; import EventSource from "eventsourcemock"; import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"; @@ -133,7 +133,7 @@ export const Failed: Story = { ...Running.args, workspace: Mocks.MockFailedWorkspace, workspaceErrors: { - [WorkspaceErrors.BUILD_ERROR]: Mocks.mockApiError({ + buildError: Mocks.mockApiError({ message: "A workspace build is already active.", }), }, @@ -172,7 +172,6 @@ export const FailedWithRetry: Story = { }, }, }, - canRetryDebugMode: true, buildLogs: , }, }; @@ -224,7 +223,7 @@ export const GetBuildsError: Story = { args: { ...Running.args, workspaceErrors: { - [WorkspaceErrors.GET_BUILDS_ERROR]: Mocks.mockApiError({ + getBuildsError: Mocks.mockApiError({ message: "There is a problem fetching builds.", }), }, @@ -235,7 +234,7 @@ export const CancellationError: Story = { args: { ...Failed.args, workspaceErrors: { - [WorkspaceErrors.CANCELLATION_ERROR]: Mocks.mockApiError({ + cancellationError: Mocks.mockApiError({ message: "Job could not be canceled.", }), }, diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index d64cfdbccc156..cab58bab690d8 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -29,11 +29,13 @@ import { BuildsTable } from "./BuildsTable"; import { WorkspaceDeletedBanner } from "./WorkspaceDeletedBanner"; import { WorkspaceStats } from "./WorkspaceStats"; -export enum WorkspaceErrors { - GET_BUILDS_ERROR = "getBuildsError", - BUILD_ERROR = "buildError", - CANCELLATION_ERROR = "cancellationError", -} +export type WorkspaceError = + | "getBuildsError" + | "buildError" + | "cancellationError"; + +export type WorkspaceErrors = Partial>; + export interface WorkspaceProps { scheduleProps: { onDeadlinePlus: (hours: number) => void; @@ -56,16 +58,17 @@ export interface WorkspaceProps { resources?: TypesGen.WorkspaceResource[]; canUpdateWorkspace: boolean; updateMessage?: string; - canRetryDebugMode: boolean; canChangeVersions: boolean; hideSSHButton?: boolean; hideVSCodeDesktopButton?: boolean; - workspaceErrors: Partial>; + workspaceErrors: WorkspaceErrors; buildInfo?: TypesGen.BuildInfoResponse; sshPrefix?: string; template?: TypesGen.Template; quotaBudget?: number; + canRetryDebugMode: boolean; handleBuildRetry: () => void; + handleBuildRetryDebug: () => void; buildLogs?: React.ReactNode; builds: TypesGen.WorkspaceBuild[] | undefined; onLoadMoreBuilds: () => void; @@ -87,7 +90,7 @@ export const Workspace: FC> = ({ handleCancel, handleSettings, handleChangeVersion, - handleDormantActivate: handleDormantActivate, + handleDormantActivate, workspace, isUpdating, isRestarting, @@ -95,7 +98,6 @@ export const Workspace: FC> = ({ builds, canUpdateWorkspace, updateMessage, - canRetryDebugMode, canChangeVersions, workspaceErrors, hideSSHButton, @@ -103,7 +105,9 @@ export const Workspace: FC> = ({ buildInfo, sshPrefix, template, + canRetryDebugMode, handleBuildRetry, + handleBuildRetryDebug, buildLogs, onLoadMoreBuilds, isLoadingMoreBuilds, @@ -111,31 +115,14 @@ export const Workspace: FC> = ({ canAutostart, }) => { const navigate = useNavigate(); - const serverVersion = buildInfo?.version || ""; const { saveLocal, getLocal } = useLocalStorage(); - const buildError = Boolean(workspaceErrors[WorkspaceErrors.BUILD_ERROR]) && ( - - ); - - const cancellationError = Boolean( - workspaceErrors[WorkspaceErrors.CANCELLATION_ERROR], - ) && ( - - ); - - let transitionStats: TypesGen.TransitionStats | undefined = undefined; - if (template !== undefined) { - transitionStats = ActiveTransition(template, workspace); - } - const [showAlertPendingInQueue, setShowAlertPendingInQueue] = useState(false); + + // 2023-11-15 - MES - This effect will be called every single render because + // "now" will always change and invalidate the dependency array. Need to + // figure out if this effect really should run every render (possibly meaning + // no dependency array at all), or how to get the array stabilized (ideal) const now = dayjs(); useEffect(() => { if ( @@ -174,6 +161,9 @@ export const Workspace: FC> = ({ const autoStartFailing = workspace.autostart_schedule && !canAutostart; const requiresManualUpdate = updateRequired && autoStartFailing; + const transitionStats = + template !== undefined ? ActiveTransition(template, workspace) : undefined; + return ( <> @@ -213,8 +203,11 @@ export const Workspace: FC> = ({ handleUpdate={handleUpdate} handleCancel={handleCancel} handleSettings={handleSettings} + handleRetry={handleBuildRetry} + handleRetryDebug={handleBuildRetryDebug} handleChangeVersion={handleChangeVersion} handleDormantActivate={handleDormantActivate} + canRetryDebug={canRetryDebugMode} canChangeVersions={canChangeVersions} isUpdating={isUpdating} isRestarting={isRestarting} @@ -244,8 +237,15 @@ export const Workspace: FC> = ({ {updateMessage && {updateMessage}} ))} - {buildError} - {cancellationError} + + {Boolean(workspaceErrors.buildError) && ( + + )} + + {Boolean(workspaceErrors.cancellationError) && ( + + )} + {workspace.latest_build.status === "running" && !workspace.health.healthy && ( > = ({ - Try in debug mode - - ) + } > Workspace build failed @@ -357,17 +356,15 @@ export const Workspace: FC> = ({ showBuiltinApps={canUpdateWorkspace} hideSSHButton={hideSSHButton} hideVSCodeDesktopButton={hideVSCodeDesktopButton} - serverVersion={serverVersion} + serverVersion={buildInfo?.version || ""} onUpdateAgent={handleUpdate} // On updating the workspace the agent version is also updated /> )} /> )} - {workspaceErrors[WorkspaceErrors.GET_BUILDS_ERROR] ? ( - + {workspaceErrors.getBuildsError ? ( + ) : ( void; disabled?: boolean; tooltipText?: string; } -export const UpdateButton: FC = ({ +export const UpdateButton: FC = ({ handleAction, loading, }) => { @@ -37,7 +40,7 @@ export const UpdateButton: FC = ({ ); }; -export const ActivateButton: FC = ({ +export const ActivateButton: FC = ({ handleAction, loading, }) => { @@ -54,7 +57,7 @@ export const ActivateButton: FC = ({ }; export const StartButton: FC< - Omit & { + Omit & { workspace: Workspace; handleAction: (buildParameters?: WorkspaceBuildParameter[]) => void; } @@ -63,7 +66,7 @@ export const StartButton: FC< button:hover + button": { borderLeft: "1px solid #FFF", }, @@ -94,7 +97,10 @@ export const StartButton: FC< ); }; -export const StopButton: FC = ({ handleAction, loading }) => { +export const StopButton: FC = ({ + handleAction, + loading, +}) => { return ( = ({ handleAction, loading }) => { }; export const RestartButton: FC< - Omit & { + Omit & { workspace: Workspace; handleAction: (buildParameters?: WorkspaceBuildParameter[]) => void; } @@ -118,7 +124,7 @@ export const RestartButton: FC< button:hover + button": { borderLeft: "1px solid #FFF", }, @@ -150,7 +156,7 @@ export const RestartButton: FC< ); }; -export const CancelButton: FC = ({ handleAction }) => { +export const CancelButton: FC = ({ handleAction }) => { return ( ); @@ -181,3 +187,21 @@ export const ActionLoadingButton: FC = ({ label }) => { ); }; + +type DebugButtonProps = Omit & { + debug?: boolean; +}; + +export const RetryButton = ({ + handleAction, + debug = false, +}: DebugButtonProps) => { + return ( + + ); +}; diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx index b31962853660f..181c3b4ba0803 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx @@ -1,6 +1,10 @@ -import { FC, Fragment, ReactNode } from "react"; +import { type FC, type ReactNode, Fragment } from "react"; import { Workspace, WorkspaceBuildParameter } from "api/typesGenerated"; import { useWorkspaceDuplication } from "pages/CreateWorkspacePage/useWorkspaceDuplication"; + +import { workspaceUpdatePolicy } from "utils/workspace"; +import { type ButtonType, actionsByWorkspaceStatus } from "./constants"; + import { ActionLoadingButton, CancelButton, @@ -10,12 +14,8 @@ import { RestartButton, UpdateButton, ActivateButton, + RetryButton, } from "./Buttons"; -import { - ButtonMapping, - ButtonTypesEnum, - actionsByWorkspaceStatus, -} from "./constants"; import Divider from "@mui/material/Divider"; import DuplicateIcon from "@mui/icons-material/FileCopyOutlined"; @@ -30,7 +30,6 @@ import { MoreMenuTrigger, ThreeDotsButton, } from "components/MoreMenu/MoreMenu"; -import { workspaceUpdatePolicy } from "utils/workspace"; export interface WorkspaceActionsProps { workspace: Workspace; @@ -42,11 +41,14 @@ export interface WorkspaceActionsProps { handleCancel: () => void; handleSettings: () => void; handleChangeVersion: () => void; + handleRetry: () => void; + handleRetryDebug: () => void; handleDormantActivate: () => void; isUpdating: boolean; isRestarting: boolean; children?: ReactNode; canChangeVersions: boolean; + canRetryDebug: boolean; } export const WorkspaceActions: FC = ({ @@ -58,92 +60,78 @@ export const WorkspaceActions: FC = ({ handleUpdate, handleCancel, handleSettings, + handleRetry, + handleRetryDebug, handleChangeVersion, - handleDormantActivate: handleDormantActivate, + handleDormantActivate, isUpdating, isRestarting, canChangeVersions, + canRetryDebug, }) => { - const { - canCancel, - canAcceptJobs, - actions: actionsByStatus, - } = actionsByWorkspaceStatus(workspace, workspace.latest_build.status); - const canBeUpdated = workspace.outdated && canAcceptJobs; const { duplicateWorkspace, isDuplicationReady } = useWorkspaceDuplication(workspace); - const disabled = + const { actions, canCancel, canAcceptJobs } = actionsByWorkspaceStatus( + workspace, + canRetryDebug, + ); + + const mustUpdate = workspaceUpdatePolicy(workspace, canChangeVersions) === "always" && workspace.outdated; - const tooltipText = ((): string => { - if (!disabled) { - return ""; - } - if (workspace.template_require_active_version) { - return "This template requires automatic updates"; - } - if (workspace.automatic_updates === "always") { - return "You have enabled automatic updates for this workspace"; - } - return ""; - })(); + const tooltipText = getTooltipText(workspace, mustUpdate); + const canBeUpdated = workspace.outdated && canAcceptJobs; // A mapping of button type to the corresponding React component - const buttonMapping: ButtonMapping = { - [ButtonTypesEnum.update]: , - [ButtonTypesEnum.updating]: ( - - ), - [ButtonTypesEnum.start]: ( + const buttonMapping: Record = { + update: , + updating: , + start: ( ), - [ButtonTypesEnum.starting]: ( + starting: ( ), - [ButtonTypesEnum.stop]: , - [ButtonTypesEnum.stopping]: ( - - ), - [ButtonTypesEnum.restart]: ( + stop: , + stopping: , + restart: ( ), - [ButtonTypesEnum.restarting]: ( + restarting: ( ), - [ButtonTypesEnum.deleting]: , - [ButtonTypesEnum.canceling]: , - [ButtonTypesEnum.deleted]: , - [ButtonTypesEnum.pending]: , - [ButtonTypesEnum.activate]: ( - - ), - [ButtonTypesEnum.activating]: ( - - ), + deleting: , + canceling: , + deleted: , + pending: , + activate: , + activating: , + retry: , + retryDebug: , }; return ( @@ -151,19 +139,18 @@ export const WorkspaceActions: FC = ({ css={{ display: "flex", alignItems: "center", gap: 12 }} data-testid="workspace-actions" > - {canBeUpdated && - (isUpdating - ? buttonMapping[ButtonTypesEnum.updating] - : buttonMapping[ButtonTypesEnum.update])} + {canBeUpdated && ( + <>{isUpdating ? buttonMapping.updating : buttonMapping.update} + )} - {isRestarting && buttonMapping[ButtonTypesEnum.restarting]} - - {!isRestarting && - actionsByStatus.map((action) => ( - {buttonMapping[action]} - ))} + {isRestarting + ? buttonMapping.restarting + : actions.map((action) => ( + {buttonMapping[action]} + ))} {canCancel && } + = ({ ); }; + +function getTooltipText(workspace: Workspace, disabled: boolean): string { + if (!disabled) { + return ""; + } + + if (workspace.template_require_active_version) { + return "This template requires automatic updates"; + } + + if (workspace.automatic_updates === "always") { + return "You have enabled automatic updates for this workspace"; + } + + return ""; +} diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts b/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts index d6f2704a18f80..5d043cc300e5d 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts +++ b/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts @@ -1,101 +1,119 @@ -import { Workspace, WorkspaceStatus } from "api/typesGenerated"; -import { ReactNode } from "react"; +import { type Workspace, type WorkspaceStatus } from "api/typesGenerated"; -// the button types we have -export enum ButtonTypesEnum { - start = "start", - starting = "starting", - stop = "stop", - stopping = "stopping", - restart = "restart", - restarting = "restarting", - deleting = "deleting", - update = "update", - updating = "updating", - activate = "activate", - activating = "activating", - // disabled buttons - canceling = "canceling", - deleted = "deleted", - pending = "pending", -} +/** + * An iterable of all button types supported by the workspace actions UI + */ +export const buttonTypes = [ + "start", + "starting", + "stop", + "stopping", + "restart", + "restarting", + "deleting", + "update", + "updating", + "activate", + "activating", -export type ButtonMapping = { - [key in ButtonTypesEnum]: ReactNode; -}; + // There's no need for a retrying state because retrying starts a transition + // into one of the starting, stopping, or deleting states (based on the + // WorkspaceTransition type) + "retry", + "retryDebug", + + // These are buttons that should be used with disabled UI elements + "canceling", + "deleted", + "pending", +] as const; + +/** + * A button type supported by the workspace actions UI + */ +export type ButtonType = (typeof buttonTypes)[number]; -interface WorkspaceAbilities { - actions: ButtonTypesEnum[]; +type WorkspaceAbilities = { + actions: readonly ButtonType[]; canCancel: boolean; canAcceptJobs: boolean; -} +}; export const actionsByWorkspaceStatus = ( workspace: Workspace, - status: WorkspaceStatus, + canRetryDebug: boolean, ): WorkspaceAbilities => { if (workspace.dormant_at) { return { - actions: [ButtonTypesEnum.activate], + actions: ["activate"], canCancel: false, canAcceptJobs: false, }; } + + const status = workspace.latest_build.status; + if (status === "failed" && canRetryDebug) { + return { + ...statusToActions.failed, + actions: ["retry", "retryDebug"], + }; + } + return statusToActions[status]; }; const statusToActions: Record = { starting: { - actions: [ButtonTypesEnum.starting], + actions: ["starting"], canCancel: true, canAcceptJobs: false, }, running: { - actions: [ButtonTypesEnum.stop, ButtonTypesEnum.restart], + actions: ["stop", "restart"], canCancel: false, canAcceptJobs: true, }, stopping: { - actions: [ButtonTypesEnum.stopping], + actions: ["stopping"], canCancel: true, canAcceptJobs: false, }, stopped: { - actions: [ButtonTypesEnum.start], + actions: ["start"], canCancel: false, canAcceptJobs: true, }, canceled: { - actions: [ButtonTypesEnum.start, ButtonTypesEnum.stop], + actions: ["start", "stop"], canCancel: false, canAcceptJobs: true, }, + // in the case of an error failed: { - actions: [ButtonTypesEnum.start, ButtonTypesEnum.stop], + actions: ["retry"], canCancel: false, canAcceptJobs: true, }, - /** - * disabled states - */ + + // Disabled states canceling: { - actions: [ButtonTypesEnum.canceling], + actions: ["canceling"], canCancel: false, canAcceptJobs: false, }, deleting: { - actions: [ButtonTypesEnum.deleting], + actions: ["deleting"], canCancel: true, canAcceptJobs: false, }, deleted: { - actions: [ButtonTypesEnum.deleted], + actions: ["deleted"], canCancel: false, canAcceptJobs: false, }, pending: { - actions: [ButtonTypesEnum.pending], + actions: ["pending"], canCancel: false, canAcceptJobs: false, }, diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 008a3d2c54ce5..cdaa83a92b569 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -1,3 +1,4 @@ +import { type Workspace } from "api/typesGenerated"; import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import EventSourceMock from "eventsourcemock"; @@ -5,6 +6,7 @@ import { rest } from "msw"; import { MockTemplate, MockWorkspace, + MockFailedWorkspace, MockWorkspaceBuild, MockStoppedWorkspace, MockStartingWorkspace, @@ -21,8 +23,9 @@ import { renderWithAuth } from "testHelpers/renderHelpers"; import { server } from "testHelpers/server"; import { WorkspacePage } from "./WorkspacePage"; -// It renders the workspace page and waits for it be loaded -const renderWorkspacePage = async () => { +// Renders the workspace page and waits for it be loaded +const renderWorkspacePage = async (workspace: Workspace) => { + jest.spyOn(api, "getWorkspaceByOwnerAndName").mockResolvedValue(workspace); jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate); jest.spyOn(api, "getTemplateVersionRichParameters").mockResolvedValueOnce([]); jest @@ -34,24 +37,33 @@ const renderWorkspacePage = async () => { options.onDone && options.onDone(); return new WebSocket(""); }); + renderWithAuth(, { - route: `/@${MockWorkspace.owner_name}/${MockWorkspace.name}`, + route: `/@${workspace.owner_name}/${workspace.name}`, path: "/:username/:workspace", }); - await screen.findByText(MockWorkspace.name); + + await screen.findByText(workspace.name); }; /** - * Requests and responses related to workspace status are unrelated, so we can't test in the usual way. - * Instead, test that button clicks produce the correct requests and that responses produce the correct UI. - * We don't need to test the UI exhaustively because Storybook does that; just enough to prove that the - * workspaceStatus was calculated correctly. + * Requests and responses related to workspace status are unrelated, so we can't + * test in the usual way. Instead, test that button clicks produce the correct + * requests and that responses produce the correct UI. + * + * We don't need to test the UI exhaustively because Storybook does that; just + * enough to prove that the workspaceStatus was calculated correctly. */ -const testButton = async (label: string, actionMock: jest.SpyInstance) => { - const user = userEvent.setup(); - await renderWorkspacePage(); +const testButton = async ( + workspace: Workspace, + name: string | RegExp, + actionMock: jest.SpyInstance, +) => { + await renderWorkspacePage(workspace); const workspaceActions = screen.getByTestId("workspace-actions"); - const button = within(workspaceActions).getByRole("button", { name: label }); + const button = within(workspaceActions).getByRole("button", { name }); + + const user = userEvent.setup(); await user.click(button); expect(actionMock).toBeCalled(); }; @@ -78,7 +90,7 @@ describe("WorkspacePage", () => { const deleteWorkspaceMock = jest .spyOn(api, "deleteWorkspace") .mockResolvedValueOnce(MockWorkspaceBuild); - await renderWorkspacePage(); + await renderWorkspacePage(MockWorkspace); // open the workspace action popover so we have access to all available ctas const trigger = screen.getByTestId("workspace-options-button"); @@ -121,7 +133,7 @@ describe("WorkspacePage", () => { const deleteWorkspaceMock = jest .spyOn(api, "deleteWorkspace") .mockResolvedValueOnce(MockWorkspaceBuildDelete); - await renderWorkspacePage(); + await renderWorkspacePage(MockWorkspace); // open the workspace action popover so we have access to all available ctas const trigger = screen.getByTestId("workspace-options-button"); @@ -166,10 +178,12 @@ describe("WorkspacePage", () => { }, ), ); + const startWorkspaceMock = jest .spyOn(api, "startWorkspace") .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)); - await testButton("Start", startWorkspaceMock); + + await testButton(MockStoppedWorkspace, "Start", startWorkspaceMock); }); it("requests a stop job when the user presses Stop", async () => { @@ -177,7 +191,7 @@ describe("WorkspacePage", () => { .spyOn(api, "stopWorkspace") .mockResolvedValueOnce(MockWorkspaceBuild); - await testButton("Stop", stopWorkspaceMock); + await testButton(MockWorkspace, "Stop", stopWorkspaceMock); }); it("requests a stop when the user presses Restart", async () => { @@ -186,7 +200,7 @@ describe("WorkspacePage", () => { .mockResolvedValueOnce(MockWorkspaceBuild); // Render - await renderWorkspacePage(); + await renderWorkspacePage(MockWorkspace); // Actions const user = userEvent.setup(); @@ -209,20 +223,12 @@ describe("WorkspacePage", () => { }, ), ); + const cancelWorkspaceMock = jest .spyOn(api, "cancelWorkspaceBuild") .mockImplementation(() => Promise.resolve({ message: "job canceled" })); - await renderWorkspacePage(); - - const workspaceActions = screen.getByTestId("workspace-actions"); - const cancelButton = within(workspaceActions).getByRole("button", { - name: "Cancel", - }); - - await userEvent.click(cancelButton); - - expect(cancelWorkspaceMock).toBeCalled(); + await testButton(MockStartingWorkspace, "Cancel", cancelWorkspaceMock); }); it("requests an update when the user presses Update", async () => { @@ -236,7 +242,7 @@ describe("WorkspacePage", () => { .mockResolvedValueOnce(MockWorkspaceBuild); // Render - await renderWorkspacePage(); + await renderWorkspacePage(MockWorkspace); // Actions const user = userEvent.setup(); @@ -265,7 +271,7 @@ describe("WorkspacePage", () => { ); // Render - await renderWorkspacePage(); + await renderWorkspacePage(MockWorkspace); // Actions const user = userEvent.setup(); @@ -312,7 +318,7 @@ describe("WorkspacePage", () => { }); it("shows the timeline build", async () => { - await renderWorkspacePage(); + await renderWorkspacePage(MockWorkspace); const table = await screen.findByTestId("builds-table"); // Wait for the results to be loaded @@ -339,7 +345,7 @@ describe("WorkspacePage", () => { }); const restartWorkspaceSpy = jest.spyOn(api, "restartWorkspace"); const user = userEvent.setup(); - await renderWorkspacePage(); + await renderWorkspacePage(MockWorkspace); await user.click(screen.getByTestId("build-parameters-button")); const buildParametersForm = await screen.findByTestId( "build-parameters-form", @@ -358,4 +364,91 @@ describe("WorkspacePage", () => { }); }); }); + + // Tried to get these wired up via describe.each to reduce repetition, but the + // syntax just got too convoluted because of the variance in what arguments + // each function gets called with + describe("Retrying failed workspaces", () => { + const retryButtonRe = /^Retry$/i; + const retryDebugButtonRe = /^Retry \(Debug\)$/i; + + describe("Retries a failed 'Start' transition", () => { + const mockStart = jest.spyOn(api, "startWorkspace"); + const failedStart: Workspace = { + ...MockFailedWorkspace, + latest_build: { + ...MockFailedWorkspace.latest_build, + transition: "start", + }, + }; + + test("Retry with no debug", async () => { + await testButton(failedStart, retryButtonRe, mockStart); + + expect(mockStart).toBeCalledWith( + failedStart.id, + failedStart.latest_build.template_version_id, + undefined, + undefined, + ); + }); + + test("Retry with debug logs", async () => { + await testButton(failedStart, retryDebugButtonRe, mockStart); + + expect(mockStart).toBeCalledWith( + failedStart.id, + failedStart.latest_build.template_version_id, + "debug", + undefined, + ); + }); + }); + + describe("Retries a failed 'Stop' transition", () => { + const mockStop = jest.spyOn(api, "stopWorkspace"); + const failedStop: Workspace = { + ...MockFailedWorkspace, + latest_build: { + ...MockFailedWorkspace.latest_build, + transition: "stop", + }, + }; + + test("Retry with no debug", async () => { + await testButton(failedStop, retryButtonRe, mockStop); + expect(mockStop).toBeCalledWith(failedStop.id, undefined); + }); + + test("Retry with debug logs", async () => { + await testButton(failedStop, retryDebugButtonRe, mockStop); + expect(mockStop).toBeCalledWith(failedStop.id, "debug"); + }); + }); + + describe("Retries a failed 'Delete' transition", () => { + const mockDelete = jest.spyOn(api, "deleteWorkspace"); + const failedDelete: Workspace = { + ...MockFailedWorkspace, + latest_build: { + ...MockFailedWorkspace.latest_build, + transition: "delete", + }, + }; + + test("Retry with no debug", async () => { + await testButton(failedDelete, retryButtonRe, mockDelete); + expect(mockDelete).toBeCalledWith(failedDelete.id, { + logLevel: undefined, + }); + }); + + test("Retry with debug logs", async () => { + await testButton(failedDelete, retryDebugButtonRe, mockDelete); + expect(mockDelete).toBeCalledWith(failedDelete.id, { + logLevel: "debug", + }); + }); + }); + }); }); diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index fd6a9343fac9c..77b3f9d0d5f04 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -9,7 +9,7 @@ import { getMaxDeadlineChange, getMinDeadline, } from "utils/schedule"; -import { Workspace, WorkspaceErrors } from "./Workspace"; +import { Workspace } from "./Workspace"; import { pageTitle } from "utils/page"; import { hasJobError } from "utils/workspace"; import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog"; @@ -79,9 +79,6 @@ export const WorkspaceReadyPage = ({ ...deploymentConfig(), enabled: permissions?.viewDeploymentValues, }); - const canRetryDebugMode = Boolean( - deploymentValues?.config.enable_terraform_debug_mode, - ); // Build logs const buildLogs = useWorkspaceBuildLogs(workspace.latest_build.id); @@ -196,6 +193,22 @@ export const WorkspaceReadyPage = ({ // Cancel build const cancelBuildMutation = useMutation(cancelBuild(workspace, queryClient)); + const handleBuildRetry = (debug = false) => { + const logLevel = debug ? "debug" : undefined; + + switch (workspace.latest_build.transition) { + case "start": + startWorkspaceMutation.mutate({ logLevel }); + break; + case "stop": + stopWorkspaceMutation.mutate({ logLevel }); + break; + case "delete": + deleteWorkspaceMutation.mutate({ logLevel }); + break; + } + }; + return ( <> @@ -242,19 +255,11 @@ export const WorkspaceReadyPage = ({ }} handleCancel={cancelBuildMutation.mutate} handleSettings={() => navigate("settings")} - handleBuildRetry={() => { - switch (workspace.latest_build.transition) { - case "start": - startWorkspaceMutation.mutate({ logLevel: "debug" }); - break; - case "stop": - stopWorkspaceMutation.mutate({ logLevel: "debug" }); - break; - case "delete": - deleteWorkspaceMutation.mutate({ logLevel: "debug" }); - break; - } - }} + handleBuildRetry={() => handleBuildRetry(false)} + handleBuildRetryDebug={() => handleBuildRetry(true)} + canRetryDebugMode={ + deploymentValues?.config.enable_terraform_debug_mode ?? false + } handleChangeVersion={() => { setChangeVersionDialogOpen(true); }} @@ -273,19 +278,18 @@ export const WorkspaceReadyPage = ({ hasMoreBuilds={hasMoreBuilds} canUpdateWorkspace={canUpdateWorkspace} updateMessage={latestVersion?.message} - canRetryDebugMode={canRetryDebugMode} canChangeVersions={canChangeVersions} hideSSHButton={featureVisibility["browser_only"]} hideVSCodeDesktopButton={featureVisibility["browser_only"]} workspaceErrors={{ - [WorkspaceErrors.GET_BUILDS_ERROR]: buildsError, - [WorkspaceErrors.BUILD_ERROR]: + getBuildsError: buildsError, + buildError: restartBuildError ?? startWorkspaceMutation.error ?? stopWorkspaceMutation.error ?? deleteWorkspaceMutation.error ?? updateWorkspaceMutation.error, - [WorkspaceErrors.CANCELLATION_ERROR]: cancelBuildMutation.error, + cancellationError: cancelBuildMutation.error, }} buildInfo={buildInfo} sshPrefix={sshPrefixQuery.data?.hostname_prefix} @@ -297,6 +301,7 @@ export const WorkspaceReadyPage = ({ } canAutostart={canAutostart} /> + + + + + {