fix(site): retry and debug passing build parameters options (#12384)

This commit is contained in:
Bruno Quaresma
2024-03-04 10:25:53 -03:00
committed by GitHub
parent af4d0b148b
commit afcea74462
15 changed files with 400 additions and 77 deletions
@@ -74,14 +74,17 @@ interface SidebarIconButton extends ComponentProps<typeof TopbarIconButton> {
isActive: boolean;
}
export const SidebarIconButton: FC<SidebarIconButton> = (props) => {
export const SidebarIconButton: FC<SidebarIconButton> = ({
isActive,
...buttonProps
}) => {
return (
<TopbarIconButton
css={[
{ opacity: 0.75, "&:hover": { opacity: 1 } },
props.isActive && styles.activeSidebarIconButton,
isActive && styles.activeSidebarIconButton,
]}
{...props}
{...buttonProps}
/>
);
};
+10 -24
View File
@@ -1,5 +1,4 @@
import { type Interpolation, type Theme } from "@emotion/react";
import Button from "@mui/material/Button";
import AlertTitle from "@mui/material/AlertTitle";
import { type FC } from "react";
import { useNavigate } from "react-router-dom";
@@ -43,9 +42,9 @@ export interface WorkspaceProps {
buildInfo?: TypesGen.BuildInfoResponse;
sshPrefix?: string;
template: TypesGen.Template;
canRetryDebugMode: boolean;
handleBuildRetry: () => void;
handleBuildRetryDebug: () => void;
canDebugMode: boolean;
handleRetry: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void;
handleDebug: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void;
buildLogs?: React.ReactNode;
latestVersion?: TypesGen.TemplateVersion;
permissions: WorkspacePermissions;
@@ -75,9 +74,9 @@ export const Workspace: FC<WorkspaceProps> = ({
buildInfo,
sshPrefix,
template,
canRetryDebugMode,
handleBuildRetry,
handleBuildRetryDebug,
canDebugMode,
handleRetry,
handleDebug,
buildLogs,
latestVersion,
permissions,
@@ -129,12 +128,12 @@ export const Workspace: FC<WorkspaceProps> = ({
handleUpdate={handleUpdate}
handleCancel={handleCancel}
handleSettings={handleSettings}
handleBuildRetry={handleBuildRetry}
handleBuildRetryDebug={handleBuildRetryDebug}
handleRetry={handleRetry}
handleDebug={handleDebug}
handleChangeVersion={handleChangeVersion}
handleDormantActivate={handleDormantActivate}
handleToggleFavorite={handleToggleFavorite}
canRetryDebugMode={canRetryDebugMode}
canDebugMode={canDebugMode}
canChangeVersions={canChangeVersions}
isUpdating={isUpdating}
isRestarting={isRestarting}
@@ -208,20 +207,7 @@ export const Workspace: FC<WorkspaceProps> = ({
)}
{workspace.latest_build.job.error && (
<Alert
severity="error"
actions={
<Button
onClick={
canRetryDebugMode ? handleBuildRetryDebug : handleBuildRetry
}
variant="text"
size="small"
>
Retry{canRetryDebugMode && " in debug mode"}
</Button>
}
>
<Alert severity="error">
<AlertTitle>Workspace build failed</AlertTitle>
<AlertDetail>{workspace.latest_build.job.error}</AlertDetail>
</Alert>
@@ -32,16 +32,19 @@ import {
usePopover,
} from "components/Popover/Popover";
import { TopbarButton } from "components/FullPageLayout/Topbar";
import visuallyHidden from "@mui/utils/visuallyHidden";
interface BuildParametersPopoverProps {
workspace: Workspace;
disabled?: boolean;
onSubmit: (buildParameters: WorkspaceBuildParameter[]) => void;
label: string;
}
export const BuildParametersPopover: FC<BuildParametersPopoverProps> = ({
workspace,
disabled,
label,
onSubmit,
}) => {
const { data: parameters } = useQuery({
@@ -62,6 +65,7 @@ export const BuildParametersPopover: FC<BuildParametersPopoverProps> = ({
css={{ paddingLeft: 0, paddingRight: 0, minWidth: "28px !important" }}
>
<ExpandMoreOutlined css={{ fontSize: 14 }} />
<span css={{ ...visuallyHidden }}>{label}</span>
</TopbarButton>
</PopoverTrigger>
<PopoverContent
@@ -7,8 +7,6 @@ import ReplayIcon from "@mui/icons-material/Replay";
import BlockIcon from "@mui/icons-material/Block";
import OutlinedBlockIcon from "@mui/icons-material/BlockOutlined";
import PowerSettingsNewIcon from "@mui/icons-material/PowerSettingsNew";
import RetryIcon from "@mui/icons-material/BuildOutlined";
import RetryDebugIcon from "@mui/icons-material/BugReportOutlined";
import Star from "@mui/icons-material/Star";
import StarBorder from "@mui/icons-material/StarBorder";
import { type FC } from "react";
@@ -16,7 +14,7 @@ import type { Workspace, WorkspaceBuildParameter } from "api/typesGenerated";
import { BuildParametersPopover } from "./BuildParametersPopover";
import { TopbarButton } from "components/FullPageLayout/Topbar";
interface ActionButtonProps {
export interface ActionButtonProps {
loading?: boolean;
handleAction: (buildParameters?: WorkspaceBuildParameter[]) => void;
disabled?: boolean;
@@ -84,6 +82,7 @@ export const StartButton: FC<ActionButtonPropsWithWorkspace> = ({
{loading ? <>Starting&hellip;</> : "Start"}
</TopbarButton>
<BuildParametersPopover
label="Start with build parameters"
workspace={workspace}
disabled={loading}
onSubmit={handleAction}
@@ -141,6 +140,7 @@ export const RestartButton: FC<ActionButtonPropsWithWorkspace> = ({
{loading ? <>Restarting&hellip;</> : <>Restart&hellip;</>}
</TopbarButton>
<BuildParametersPopover
label="Restart with build parameters"
workspace={workspace}
disabled={loading}
onSubmit={handleAction}
@@ -175,24 +175,6 @@ export const DisabledButton: FC<DisabledButtonProps> = ({ label }) => {
);
};
type RetryButtonProps = Omit<ActionButtonProps, "loading"> & {
debug?: boolean;
};
export const RetryButton: FC<RetryButtonProps> = ({
handleAction,
debug = false,
}) => {
return (
<TopbarButton
startIcon={debug ? <RetryDebugIcon /> : <RetryIcon />}
onClick={() => handleAction()}
>
Retry{debug && " (Debug)"}
</TopbarButton>
);
};
interface FavoriteButtonProps {
onToggle: (workspaceID: string) => void;
workspaceID: string;
@@ -0,0 +1,54 @@
import { Meta, StoryObj } from "@storybook/react";
import { DebugButton } from "./DebugButton";
import { MockWorkspace } from "testHelpers/entities";
import { userEvent, waitFor, within, expect } from "@storybook/test";
const meta: Meta<typeof DebugButton> = {
title: "pages/WorkspacePage/DebugButton",
component: DebugButton,
};
export default meta;
type Story = StoryObj<typeof DebugButton>;
export const Default: Story = {};
export const WithBuildParameters: Story = {
args: {
enableBuildParameters: true,
workspace: MockWorkspace,
},
parameters: {
queries: [
{
key: ["workspace", MockWorkspace.id, "parameters"],
data: { templateVersionRichParameters: [], buildParameters: [] },
},
],
},
};
export const WithOpenBuildParameters: Story = {
args: {
enableBuildParameters: true,
workspace: MockWorkspace,
},
parameters: {
queries: [
{
key: ["workspace", MockWorkspace.id, "parameters"],
data: { templateVersionRichParameters: [], buildParameters: [] },
},
],
},
play: async ({ canvasElement, step }) => {
const screen = within(canvasElement);
await step("open popover", async () => {
await userEvent.click(screen.getByTestId("build-parameters-button"));
await waitFor(() =>
expect(screen.getByText("Build Options")).toBeInTheDocument(),
);
});
},
};
@@ -0,0 +1,47 @@
import ButtonGroup from "@mui/material/ButtonGroup";
import DebugIcon from "@mui/icons-material/BugReportOutlined";
import { type FC } from "react";
import type { Workspace } from "api/typesGenerated";
import { BuildParametersPopover } from "./BuildParametersPopover";
import { TopbarButton } from "components/FullPageLayout/Topbar";
import { ActionButtonProps } from "./Buttons";
type DebugButtonProps = Omit<ActionButtonProps, "loading"> & {
workspace: Workspace;
enableBuildParameters: boolean;
};
export const DebugButton: FC<DebugButtonProps> = ({
handleAction,
workspace,
enableBuildParameters,
}) => {
const mainAction = (
<TopbarButton startIcon={<DebugIcon />} onClick={() => handleAction()}>
Debug
</TopbarButton>
);
if (!enableBuildParameters) {
return mainAction;
}
return (
<ButtonGroup
variant="outlined"
css={{
// Workaround to make the border transitions smoothly on button groups
"& > button:hover + button": {
borderLeft: "1px solid #FFF",
},
}}
>
{mainAction}
<BuildParametersPopover
label="Debug with build parameters"
workspace={workspace}
onSubmit={handleAction}
/>
</ButtonGroup>
);
};
@@ -0,0 +1,54 @@
import { Meta, StoryObj } from "@storybook/react";
import { RetryButton } from "./RetryButton";
import { MockWorkspace } from "testHelpers/entities";
import { userEvent, waitFor, within, expect } from "@storybook/test";
const meta: Meta<typeof RetryButton> = {
title: "pages/WorkspacePage/RetryButton",
component: RetryButton,
};
export default meta;
type Story = StoryObj<typeof RetryButton>;
export const Default: Story = {};
export const WithBuildParameters: Story = {
args: {
enableBuildParameters: true,
workspace: MockWorkspace,
},
parameters: {
queries: [
{
key: ["workspace", MockWorkspace.id, "parameters"],
data: { templateVersionRichParameters: [], buildParameters: [] },
},
],
},
};
export const WithOpenBuildParameters: Story = {
args: {
enableBuildParameters: true,
workspace: MockWorkspace,
},
parameters: {
queries: [
{
key: ["workspace", MockWorkspace.id, "parameters"],
data: { templateVersionRichParameters: [], buildParameters: [] },
},
],
},
play: async ({ canvasElement, step }) => {
const screen = within(canvasElement);
await step("open popover", async () => {
await userEvent.click(screen.getByTestId("build-parameters-button"));
await waitFor(() =>
expect(screen.getByText("Build Options")).toBeInTheDocument(),
);
});
},
};
@@ -0,0 +1,47 @@
import ButtonGroup from "@mui/material/ButtonGroup";
import RetryIcon from "@mui/icons-material/CachedOutlined";
import { type FC } from "react";
import type { Workspace } from "api/typesGenerated";
import { BuildParametersPopover } from "./BuildParametersPopover";
import { TopbarButton } from "components/FullPageLayout/Topbar";
import { ActionButtonProps } from "./Buttons";
type RetryButtonProps = Omit<ActionButtonProps, "loading"> & {
enableBuildParameters: boolean;
workspace: Workspace;
};
export const RetryButton: FC<RetryButtonProps> = ({
handleAction,
workspace,
enableBuildParameters,
}) => {
const mainAction = (
<TopbarButton startIcon={<RetryIcon />} onClick={() => handleAction()}>
Retry
</TopbarButton>
);
if (!enableBuildParameters) {
return mainAction;
}
return (
<ButtonGroup
variant="outlined"
css={{
// Workaround to make the border transitions smoothly on button groups
"& > button:hover + button": {
borderLeft: "1px solid #FFF",
},
}}
>
{mainAction}
<BuildParametersPopover
label="Retry with build parameters"
workspace={workspace}
onSubmit={handleAction}
/>
</ButtonGroup>
);
};
@@ -73,6 +73,13 @@ export const Failed: Story = {
},
};
export const FailedWithDebug: Story = {
args: {
workspace: Mocks.MockFailedWorkspace,
canDebug: true,
},
};
export const Updating: Story = {
args: {
isUpdating: true,
@@ -11,7 +11,6 @@ import {
RestartButton,
UpdateButton,
ActivateButton,
RetryButton,
FavoriteButton,
} from "./Buttons";
@@ -28,6 +27,8 @@ import {
} from "components/MoreMenu/MoreMenu";
import { TopbarIconButton } from "components/FullPageLayout/Topbar";
import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined";
import { RetryButton } from "./RetryButton";
import { DebugButton } from "./DebugButton";
export interface WorkspaceActionsProps {
workspace: Workspace;
@@ -40,14 +41,14 @@ export interface WorkspaceActionsProps {
handleCancel: () => void;
handleSettings: () => void;
handleChangeVersion: () => void;
handleRetry: () => void;
handleRetryDebug: () => void;
handleRetry: (buildParameters?: WorkspaceBuildParameter[]) => void;
handleDebug: (buildParameters?: WorkspaceBuildParameter[]) => void;
handleDormantActivate: () => void;
isUpdating: boolean;
isRestarting: boolean;
children?: ReactNode;
canChangeVersions: boolean;
canRetryDebug: boolean;
canDebug: boolean;
isOwner: boolean;
}
@@ -62,13 +63,13 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
handleCancel,
handleSettings,
handleRetry,
handleRetryDebug,
handleDebug,
handleChangeVersion,
handleDormantActivate,
isUpdating,
isRestarting,
canChangeVersions,
canRetryDebug,
canDebug,
isOwner,
}) => {
const { duplicateWorkspace, isDuplicationReady } =
@@ -76,7 +77,7 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
const { actions, canCancel, canAcceptJobs } = abilitiesByWorkspaceStatus(
workspace,
canRetryDebug,
canDebug,
);
const showCancel =
canCancel &&
@@ -132,8 +133,20 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
pending: <DisabledButton label="Pending..." />,
activate: <ActivateButton handleAction={handleDormantActivate} />,
activating: <ActivateButton loading handleAction={handleDormantActivate} />,
retry: <RetryButton handleAction={handleRetry} />,
retryDebug: <RetryButton debug handleAction={handleRetryDebug} />,
retry: (
<RetryButton
handleAction={handleRetry}
workspace={workspace}
enableBuildParameters={workspace.latest_build.transition === "start"}
/>
),
debug: (
<DebugButton
handleAction={handleDebug}
workspace={workspace}
enableBuildParameters={workspace.latest_build.transition === "start"}
/>
),
toggleFavorite: (
<FavoriteButton
workspaceID={workspace.id}
@@ -21,7 +21,7 @@ export const actionTypes = [
// into one of the starting, stopping, or deleting states (based on the
// WorkspaceTransition type)
"retry",
"retryDebug",
"debug",
// These are buttons that should be used with disabled UI elements
"canceling",
@@ -39,7 +39,7 @@ type WorkspaceAbilities = {
export const abilitiesByWorkspaceStatus = (
workspace: Workspace,
canRetryDebug: boolean,
canDebug: boolean,
): WorkspaceAbilities => {
if (workspace.dormant_at) {
return {
@@ -50,10 +50,10 @@ export const abilitiesByWorkspaceStatus = (
}
const status = workspace.latest_build.status;
if (status === "failed" && canRetryDebug) {
if (status === "failed" && canDebug) {
return {
...statusToAbility.failed,
actions: ["retry", "retryDebug"],
actions: ["retry", "debug"],
};
}
@@ -1,4 +1,4 @@
import { type Workspace } from "api/typesGenerated";
import { TemplateVersionParameter, type Workspace } from "api/typesGenerated";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import EventSourceMock from "eventsourcemock";
@@ -357,7 +357,7 @@ describe("WorkspacePage", () => {
// each function gets called with
describe("Retrying failed workspaces", () => {
const retryButtonRe = /^Retry$/i;
const retryDebugButtonRe = /^Retry \(Debug\)$/i;
const retryDebugButtonRe = /^Debug$/i;
describe("Retries a failed 'Start' transition", () => {
const mockStart = jest.spyOn(api, "startWorkspace");
@@ -438,4 +438,102 @@ describe("WorkspacePage", () => {
});
});
});
it("retry with build parameters", async () => {
const user = userEvent.setup();
const workspace = {
...MockFailedWorkspace,
latest_build: {
...MockFailedWorkspace.latest_build,
transition: "start",
},
} satisfies Workspace;
const parameter = {
...MockTemplateVersionParameter1,
display_name: "Parameter 1",
ephemeral: true,
} satisfies TemplateVersionParameter;
server.use(
rest.get(
"/api/v2/templateversions/:versionId/rich-parameters",
(req, res, ctx) => {
return res(ctx.status(200), ctx.json([parameter]));
},
),
);
const startWorkspaceSpy = jest.spyOn(api, "startWorkspace");
await renderWorkspacePage(workspace);
const retryWithBuildParametersButton = await screen.findByRole("button", {
name: "Retry with build parameters",
});
await user.click(retryWithBuildParametersButton);
await screen.findByText("Build Options");
const parameterField = screen.getByLabelText(parameter.display_name, {
exact: false,
});
await user.clear(parameterField);
await user.type(parameterField, "some-value");
const submitButton = screen.getByText("Build workspace");
await user.click(submitButton);
await waitFor(() => {
expect(startWorkspaceSpy).toBeCalledWith(
workspace.id,
workspace.latest_build.template_version_id,
undefined,
[{ name: parameter.name, value: "some-value" }],
);
});
});
it("debug with build parameters", async () => {
const user = userEvent.setup();
const workspace = {
...MockFailedWorkspace,
latest_build: {
...MockFailedWorkspace.latest_build,
transition: "start",
},
} satisfies Workspace;
const parameter = {
...MockTemplateVersionParameter1,
display_name: "Parameter 1",
ephemeral: true,
} satisfies TemplateVersionParameter;
server.use(
rest.get(
"/api/v2/templateversions/:versionId/rich-parameters",
(req, res, ctx) => {
return res(ctx.status(200), ctx.json([parameter]));
},
),
);
const startWorkspaceSpy = jest.spyOn(api, "startWorkspace");
await renderWorkspacePage(workspace);
const retryWithBuildParametersButton = await screen.findByRole("button", {
name: "Debug with build parameters",
});
await user.click(retryWithBuildParametersButton);
await screen.findByText("Build Options");
const parameterField = screen.getByLabelText(parameter.display_name, {
exact: false,
});
await user.clear(parameterField);
await user.type(parameterField, "some-value");
const submitButton = screen.getByText("Build workspace");
await user.click(submitButton);
await waitFor(() => {
expect(startWorkspaceSpy).toBeCalledWith(
workspace.id,
workspace.latest_build.template_version_id,
"debug",
[{ name: parameter.name, value: "some-value" }],
);
});
});
});
@@ -153,12 +153,18 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
// Cancel build
const cancelBuildMutation = useMutation(cancelBuild(workspace, queryClient));
const handleBuildRetry = (debug = false) => {
const runLastBuild = (
buildParameters: TypesGen.WorkspaceBuildParameter[] | undefined,
debug: boolean,
) => {
const logLevel = debug ? "debug" : undefined;
switch (workspace.latest_build.transition) {
case "start":
startWorkspaceMutation.mutate({ logLevel });
startWorkspaceMutation.mutate({
logLevel,
buildParameters,
});
break;
case "stop":
stopWorkspaceMutation.mutate({ logLevel });
@@ -169,6 +175,18 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
}
};
const handleRetry = (
buildParameters?: TypesGen.WorkspaceBuildParameter[],
) => {
runLastBuild(buildParameters, false);
};
const handleDebug = (
buildParameters?: TypesGen.WorkspaceBuildParameter[],
) => {
runLastBuild(buildParameters, true);
};
return (
<>
<Helmet>
@@ -207,9 +225,9 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
}}
handleCancel={cancelBuildMutation.mutate}
handleSettings={() => navigate("settings")}
handleBuildRetry={() => handleBuildRetry(false)}
handleBuildRetryDebug={() => handleBuildRetry(true)}
canRetryDebugMode={
handleRetry={handleRetry}
handleDebug={handleDebug}
canDebugMode={
deploymentValues?.config.enable_terraform_debug_mode ?? false
}
handleChangeVersion={() => {
@@ -52,9 +52,9 @@ export interface WorkspaceProps {
workspace: TypesGen.Workspace;
canUpdateWorkspace: boolean;
canChangeVersions: boolean;
canRetryDebugMode: boolean;
handleBuildRetry: () => void;
handleBuildRetryDebug: () => void;
canDebugMode: boolean;
handleRetry: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void;
handleDebug: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void;
isOwner: boolean;
template: TypesGen.Template;
permissions: WorkspacePermissions;
@@ -78,9 +78,9 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
isRestarting,
canUpdateWorkspace,
canChangeVersions,
canRetryDebugMode,
handleBuildRetry,
handleBuildRetryDebug,
canDebugMode,
handleRetry,
handleDebug,
isOwner,
template,
latestVersion,
@@ -266,12 +266,12 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
handleUpdate={handleUpdate}
handleCancel={handleCancel}
handleSettings={handleSettings}
handleRetry={handleBuildRetry}
handleRetryDebug={handleBuildRetryDebug}
handleRetry={handleRetry}
handleDebug={handleDebug}
handleChangeVersion={handleChangeVersion}
handleDormantActivate={handleDormantActivate}
handleToggleFavorite={handleToggleFavorite}
canRetryDebug={canRetryDebugMode}
canDebug={canDebugMode}
canChangeVersions={canChangeVersions}
isUpdating={isUpdating}
isRestarting={isRestarting}
+10
View File
@@ -245,6 +245,12 @@ export const handlers = [
rest.put("/api/v2/workspaces/:workspaceId/extend", async (req, res, ctx) => {
return res(ctx.status(200));
}),
rest.get(
"/api/v2/workspaces/:workspaceId/resolve-autostart",
async (req, res, ctx) => {
return res(ctx.status(200), ctx.json({ parameter_mismatch: false }));
},
),
// workspace builds
rest.post("/api/v2/workspaces/:workspaceId/builds", async (req, res, ctx) => {
@@ -438,4 +444,8 @@ export const handlers = [
rest.get("/api/v2/workspaceagents/:agent/listening-ports", (_, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockListeningPortsResponse));
}),
rest.get("/api/v2/integrations/jfrog/xray-scan", (_, res, ctx) => {
return res(ctx.status(404));
}),
];