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 (
} onClick={handleAction}>
Cancel
@@ -164,7 +170,7 @@ interface DisabledProps {
export const DisabledButton: FC = ({ label }) => {
return (
- } disabled>
+ } disabled>
{label}
);
@@ -181,3 +187,21 @@ export const ActionLoadingButton: FC = ({ label }) => {
);
};
+
+type DebugButtonProps = Omit & {
+ debug?: boolean;
+};
+
+export const RetryButton = ({
+ handleAction,
+ debug = false,
+}: DebugButtonProps) => {
+ return (
+ : }
+ onClick={handleAction}
+ >
+ Retry{debug && " (Debug)"}
+
+ );
+};
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}
/>
+
+
+
+
+
{