diff --git a/site/src/components/FullPageLayout/Sidebar.tsx b/site/src/components/FullPageLayout/Sidebar.tsx index a1e9817250775..93f782cd20770 100644 --- a/site/src/components/FullPageLayout/Sidebar.tsx +++ b/site/src/components/FullPageLayout/Sidebar.tsx @@ -74,14 +74,17 @@ interface SidebarIconButton extends ComponentProps { isActive: boolean; } -export const SidebarIconButton: FC = (props) => { +export const SidebarIconButton: FC = ({ + isActive, + ...buttonProps +}) => { return ( ); }; diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index 236933d017961..011b7329fd4eb 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -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 = ({ buildInfo, sshPrefix, template, - canRetryDebugMode, - handleBuildRetry, - handleBuildRetryDebug, + canDebugMode, + handleRetry, + handleDebug, buildLogs, latestVersion, permissions, @@ -129,12 +128,12 @@ export const Workspace: FC = ({ 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 = ({ )} {workspace.latest_build.job.error && ( - - Retry{canRetryDebugMode && " in debug mode"} - - } - > + Workspace build failed {workspace.latest_build.job.error} diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/BuildParametersPopover.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/BuildParametersPopover.tsx index 905898023cb22..1cad2f77aebbb 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/BuildParametersPopover.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/BuildParametersPopover.tsx @@ -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 = ({ workspace, disabled, + label, onSubmit, }) => { const { data: parameters } = useQuery({ @@ -62,6 +65,7 @@ export const BuildParametersPopover: FC = ({ css={{ paddingLeft: 0, paddingRight: 0, minWidth: "28px !important" }} > + {label} void; disabled?: boolean; @@ -84,6 +82,7 @@ export const StartButton: FC = ({ {loading ? <>Starting… : "Start"} = ({ {loading ? <>Restarting… : <>Restart…} = ({ label }) => { ); }; -type RetryButtonProps = Omit & { - debug?: boolean; -}; - -export const RetryButton: FC = ({ - handleAction, - debug = false, -}) => { - return ( - : } - onClick={() => handleAction()} - > - Retry{debug && " (Debug)"} - - ); -}; - interface FavoriteButtonProps { onToggle: (workspaceID: string) => void; workspaceID: string; diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/DebugButton.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/DebugButton.stories.tsx new file mode 100644 index 0000000000000..870f1bb97bca7 --- /dev/null +++ b/site/src/pages/WorkspacePage/WorkspaceActions/DebugButton.stories.tsx @@ -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 = { + title: "pages/WorkspacePage/DebugButton", + component: DebugButton, +}; + +export default meta; +type Story = StoryObj; + +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(), + ); + }); + }, +}; diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/DebugButton.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/DebugButton.tsx new file mode 100644 index 0000000000000..d93d7cefc38d8 --- /dev/null +++ b/site/src/pages/WorkspacePage/WorkspaceActions/DebugButton.tsx @@ -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 & { + workspace: Workspace; + enableBuildParameters: boolean; +}; + +export const DebugButton: FC = ({ + handleAction, + workspace, + enableBuildParameters, +}) => { + const mainAction = ( + } onClick={() => handleAction()}> + Debug + + ); + + if (!enableBuildParameters) { + return mainAction; + } + + return ( + button:hover + button": { + borderLeft: "1px solid #FFF", + }, + }} + > + {mainAction} + + + ); +}; diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/RetryButton.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/RetryButton.stories.tsx new file mode 100644 index 0000000000000..25fc7567f104e --- /dev/null +++ b/site/src/pages/WorkspacePage/WorkspaceActions/RetryButton.stories.tsx @@ -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 = { + title: "pages/WorkspacePage/RetryButton", + component: RetryButton, +}; + +export default meta; +type Story = StoryObj; + +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(), + ); + }); + }, +}; diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/RetryButton.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/RetryButton.tsx new file mode 100644 index 0000000000000..768b50501883f --- /dev/null +++ b/site/src/pages/WorkspacePage/WorkspaceActions/RetryButton.tsx @@ -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 & { + enableBuildParameters: boolean; + workspace: Workspace; +}; + +export const RetryButton: FC = ({ + handleAction, + workspace, + enableBuildParameters, +}) => { + const mainAction = ( + } onClick={() => handleAction()}> + Retry + + ); + + if (!enableBuildParameters) { + return mainAction; + } + + return ( + button:hover + button": { + borderLeft: "1px solid #FFF", + }, + }} + > + {mainAction} + + + ); +}; diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx index f56c0456a46e9..d14e26a58570b 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx @@ -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, diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx index fb6536b2a08f1..7d573d9600170 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx @@ -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 = ({ handleCancel, handleSettings, handleRetry, - handleRetryDebug, + handleDebug, handleChangeVersion, handleDormantActivate, isUpdating, isRestarting, canChangeVersions, - canRetryDebug, + canDebug, isOwner, }) => { const { duplicateWorkspace, isDuplicationReady } = @@ -76,7 +77,7 @@ export const WorkspaceActions: FC = ({ const { actions, canCancel, canAcceptJobs } = abilitiesByWorkspaceStatus( workspace, - canRetryDebug, + canDebug, ); const showCancel = canCancel && @@ -132,8 +133,20 @@ export const WorkspaceActions: FC = ({ pending: , activate: , activating: , - retry: , - retryDebug: , + retry: ( + + ), + debug: ( + + ), toggleFavorite: ( { 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"], }; } diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index d2482b8494cb4..1b262907bf39e 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -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" }], + ); + }); + }); }); diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index e8621990333bf..37eee8b99190a 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -153,12 +153,18 @@ export const WorkspaceReadyPage: FC = ({ // 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 = ({ } }; + const handleRetry = ( + buildParameters?: TypesGen.WorkspaceBuildParameter[], + ) => { + runLastBuild(buildParameters, false); + }; + + const handleDebug = ( + buildParameters?: TypesGen.WorkspaceBuildParameter[], + ) => { + runLastBuild(buildParameters, true); + }; + return ( <> @@ -207,9 +225,9 @@ export const WorkspaceReadyPage: FC = ({ }} 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={() => { diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx index 982f3dd8f6577..2298b5d73a8ae 100644 --- a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx @@ -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 = ({ isRestarting, canUpdateWorkspace, canChangeVersions, - canRetryDebugMode, - handleBuildRetry, - handleBuildRetryDebug, + canDebugMode, + handleRetry, + handleDebug, isOwner, template, latestVersion, @@ -266,12 +266,12 @@ export const WorkspaceTopbar: FC = ({ 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} diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 604695977d7b0..77ab2ec61fd84 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -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)); + }), ];