From dba688662cb3ff939397ce0ccbc9fc4298d64913 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Fri, 6 Mar 2026 03:48:01 +1100 Subject: [PATCH] feat: add `` debug details (#22462) Closes #22140 Short simple and sweet PR to add a bunch of details to our `` stacks. This means we aren't simply asking the user to read the developer console and surface things easier. - Implement `Response data` and `Stack trace` `
` - Fix overflow in `ErrorAlert` debug accordions so long `Response data` and `Stack Trace` content stays inside the alert. - Add horizontal scroll wrappers around both `
` blocks used in
debug details.
- Update `Alert` layout with `min-w-0` on flex containers so nested
content can shrink correctly and internal scrolling works as intended.

preview-validation
---
 .../workspaces/autoCreateWorkspace.spec.ts    |  6 +-
 site/src/components/Alert/Alert.tsx           | 10 +--
 site/src/components/Alert/ErrorAlert.tsx      | 82 +++++++++++++------
 .../GitDeviceAuth/GitDeviceAuth.tsx           |  4 +-
 .../modules/provisioners/ProvisionerAlert.tsx |  6 +-
 .../resources/WildcardHostnameWarning.tsx     |  6 +-
 .../tasks/TaskPrompt/TaskPrompt.stories.tsx   |  5 +-
 .../RequestLogsPage/RequestLogsPageView.tsx   |  6 +-
 .../ChatModelAdminPanel.tsx                   | 10 ++-
 .../ChatModelAdminPanel/ProviderForm.tsx      |  6 +-
 .../CreateWorkspacePage.jest.tsx              | 10 ++-
 .../AIGovernanceSettingsPageView.tsx          |  6 +-
 site/src/pages/LoginPage/SignInForm.tsx       |  2 +-
 site/src/pages/SetupPage/SetupPageView.tsx    |  6 +-
 .../TaskPage/ModifyPromptDialog.stories.tsx   |  8 +-
 site/src/pages/WorkspacePage/Workspace.tsx    | 10 ++-
 16 files changed, 117 insertions(+), 66 deletions(-)

diff --git a/site/e2e/tests/workspaces/autoCreateWorkspace.spec.ts b/site/e2e/tests/workspaces/autoCreateWorkspace.spec.ts
index 1fa02d5720..b0425fb04b 100644
--- a/site/e2e/tests/workspaces/autoCreateWorkspace.spec.ts
+++ b/site/e2e/tests/workspaces/autoCreateWorkspace.spec.ts
@@ -69,5 +69,9 @@ test("show error if `match` parameter is invalid", async ({ page }) => {
 		},
 	);
 	await page.getByRole("button", { name: /confirm and create/i }).click();
-	await expect(page.getByText("Invalid match value")).toBeVisible();
+	await expect(
+		page.getByRole("alert").getByRole("heading", {
+			name: "Invalid match value",
+		}),
+	).toBeVisible();
 });
diff --git a/site/src/components/Alert/Alert.tsx b/site/src/components/Alert/Alert.tsx
index 0e4dee8c53..faf16ddc66 100644
--- a/site/src/components/Alert/Alert.tsx
+++ b/site/src/components/Alert/Alert.tsx
@@ -97,9 +97,9 @@ export const Alert: FC = ({
 			{...props}
 		>
 			
-
+
-
{children}
+
{children}
{actions} @@ -125,7 +125,7 @@ export const Alert: FC = ({ ); }; -export const AlertDetail: React.FC = ({ +export const AlertDescription: React.FC = ({ children, }) => { return ( @@ -139,7 +139,5 @@ export const AlertTitle: React.FC> = ({ className, ...props }) => { - return ( -

- ); + return

; }; diff --git a/site/src/components/Alert/ErrorAlert.tsx b/site/src/components/Alert/ErrorAlert.tsx index 04da586f82..0ca883b985 100644 --- a/site/src/components/Alert/ErrorAlert.tsx +++ b/site/src/components/Alert/ErrorAlert.tsx @@ -1,45 +1,73 @@ import { getErrorDetail, getErrorMessage, getErrorStatus } from "api/errors"; +import { isAxiosError } from "axios"; import type { FC } from "react"; import { Link } from "../Link/Link"; -import { Alert, AlertDetail, type AlertProps, AlertTitle } from "./Alert"; +import { Alert, AlertDescription, type AlertProps, AlertTitle } from "./Alert"; type ErrorAlertProps = Readonly< - Omit & { error: unknown } + Omit & { + error: unknown; + showDebugDetail?: boolean; + } >; -export const ErrorAlert: FC = ({ error, ...alertProps }) => { +export const ErrorAlert: FC = ({ + error, + showDebugDetail = true, + ...alertProps +}) => { const message = getErrorMessage(error, "Something went wrong."); const detail = getErrorDetail(error); const status = getErrorStatus(error); // For some reason, the message and detail can be the same on the BE, but does - // not make sense in the FE to showing them duplicated - const shouldDisplayDetail = message !== detail; + // not make sense in the FE to showing them duplicated. However, we should always + // display the detail if its a 403 Forbidden response. + const shouldDisplayDetail = status === 403 || message !== detail; + const shouldDisplayResponseData = isAxiosError(error) && error.response?.data; + const shouldDisplayStackTrace = error instanceof Error; return ( - { - // When the error is a Forbidden response we include a link for the user to - // go back to a known viewable page. - status === 403 ? ( - <> - {message} - - {detail}{" "} - - Go to workspaces - - - - ) : detail ? ( - <> - {message} - {shouldDisplayDetail && {detail}} - - ) : ( - message - ) - } + {message} + + {shouldDisplayDetail && detail} + {status === 403 && ( + // When the error is a Forbidden response we include a link for the user to + // go back to a known viewable page. + + Go to workspaces + + )} + + {(shouldDisplayResponseData || shouldDisplayStackTrace) && + showDebugDetail && ( +
+ {shouldDisplayResponseData && ( +
+ Response data +
+
+										{JSON.stringify(error.response?.data, null, 2)}
+									
+
+
+ )} + {/* + * Error.isError() is not reliably available in all browsers + * so we fallback to `instanceof Error`. In future we should use + * it is more reliable. + */} + {shouldDisplayStackTrace && ( +
+ Stack Trace +
+
{error.stack}
+
+
+ )} +
+ )}
); }; diff --git a/site/src/components/GitDeviceAuth/GitDeviceAuth.tsx b/site/src/components/GitDeviceAuth/GitDeviceAuth.tsx index 6fca3c52d5..6516f64a8c 100644 --- a/site/src/components/GitDeviceAuth/GitDeviceAuth.tsx +++ b/site/src/components/GitDeviceAuth/GitDeviceAuth.tsx @@ -4,7 +4,7 @@ import Link from "@mui/material/Link"; import type { ApiErrorResponse } from "api/errors"; import type { ExternalAuthDevice } from "api/typesGenerated"; import { isAxiosError } from "axios"; -import { Alert, AlertDetail, AlertTitle } from "components/Alert/Alert"; +import { Alert, AlertDescription, AlertTitle } from "components/Alert/Alert"; import { CopyButton } from "components/CopyButton/CopyButton"; import { ExternalLinkIcon } from "lucide-react"; import type { FC } from "react"; @@ -111,7 +111,7 @@ export const GitDeviceAuth: FC = ({ {deviceExchangeError.message} {deviceExchangeError.detail && ( - {deviceExchangeError.detail} + {deviceExchangeError.detail} )} ); diff --git a/site/src/modules/provisioners/ProvisionerAlert.tsx b/site/src/modules/provisioners/ProvisionerAlert.tsx index 298b785887..a09707fcac 100644 --- a/site/src/modules/provisioners/ProvisionerAlert.tsx +++ b/site/src/modules/provisioners/ProvisionerAlert.tsx @@ -1,7 +1,7 @@ import { Alert, type AlertColor, - AlertDetail, + AlertDescription, AlertTitle, } from "components/Alert/Alert"; import { ProvisionerTag } from "modules/provisioners/ProvisionerTag"; @@ -53,7 +53,7 @@ export const ProvisionerAlert: FC = ({ return ( {title} - +
{detail}
{Object.entries(tags ?? {}) @@ -62,7 +62,7 @@ export const ProvisionerAlert: FC = ({ ))}
-
+
); }; diff --git a/site/src/modules/resources/WildcardHostnameWarning.tsx b/site/src/modules/resources/WildcardHostnameWarning.tsx index 8312606430..b6edd6bce8 100644 --- a/site/src/modules/resources/WildcardHostnameWarning.tsx +++ b/site/src/modules/resources/WildcardHostnameWarning.tsx @@ -1,5 +1,5 @@ import type { WorkspaceResource } from "api/typesGenerated"; -import { Alert, AlertDetail, AlertTitle } from "components/Alert/Alert"; +import { Alert, AlertDescription, AlertTitle } from "components/Alert/Alert"; import { Link } from "components/Link/Link"; import { useProxy } from "contexts/ProxyContext"; import { useAuthenticated } from "hooks/useAuthenticated"; @@ -47,7 +47,7 @@ export const WildcardHostnameWarning: FC = ({ } > Some workspace applications will not work - +
{hasResources ? "This template contains coder_app resources with" @@ -78,7 +78,7 @@ export const WildcardHostnameWarning: FC = ({
-
+ ); }; diff --git a/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx b/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx index 3eca101e70..e60d298965 100644 --- a/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx +++ b/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx @@ -409,7 +409,10 @@ export const ExternalAuthError: Story = { }); await step("Renders error", async () => { - await canvas.findByText(/failed to load external auth/i); + const alert = await canvas.findByRole("alert"); + await within(alert).findByRole("heading", { + name: /failed to load external auth/i, + }); }); }, }; diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.tsx index e0fadbba47..3b298f768a 100644 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.tsx +++ b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.tsx @@ -1,5 +1,5 @@ import type { AIBridgeInterception } from "api/typesGenerated"; -import { Alert, AlertDetail, AlertTitle } from "components/Alert/Alert"; +import { Alert, AlertDescription, AlertTitle } from "components/Alert/Alert"; import { Link } from "components/Link/Link"; import { PaginationContainer, @@ -47,14 +47,14 @@ export const RequestLogsPageView: FC = ({ AI Bridge is included in your license, but not set up yet. - + You have access to AI Governance, but it still needs to be setup. Check out the{" "} AI Bridge {" "} documentation to get started. - + ); } diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.tsx index 0897ec13f9..3b17878af1 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.tsx @@ -10,7 +10,7 @@ import { updateChatProviderConfig as updateChatProviderConfigMutation, } from "api/queries/chats"; import type * as TypesGen from "api/typesGenerated"; -import { Alert, AlertDetail, AlertTitle } from "components/Alert/Alert"; +import { Alert, AlertDescription, AlertTitle } from "components/Alert/Alert"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Loader2Icon } from "lucide-react"; import { type FC, useMemo, useState } from "react"; @@ -366,7 +366,9 @@ export const ChatModelAdminPanel: FC = ({ Chat provider admin API is unavailable on this deployment. - /api/v2/chats/providers is missing. + + /api/v2/chats/providers is missing. + )} @@ -375,7 +377,9 @@ export const ChatModelAdminPanel: FC = ({ Chat model admin API is unavailable on this deployment. - /api/v2/chats/model-configs is missing. + + /api/v2/chats/model-configs is missing. + )}

diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ProviderForm.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ProviderForm.tsx index 437a3cd66b..68c2ccb58f 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ProviderForm.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ProviderForm.tsx @@ -1,5 +1,5 @@ import type * as TypesGen from "api/typesGenerated"; -import { Alert, AlertDetail, AlertTitle } from "components/Alert/Alert"; +import { Alert, AlertDescription, AlertTitle } from "components/Alert/Alert"; import { Button } from "components/Button/Button"; import { Input } from "components/Input/Input"; import { @@ -190,10 +190,10 @@ export const ProviderForm: FC = ({ {isAPIKeyEnvManaged ? ( API key managed by environment variable - + This provider key is configured from deployment environment settings and cannot be edited in this UI. - + ) : (
{ await waitFor(() => { expect(mockPublisher).toBeDefined(); mockPublisher.publishError(new Event("Connection failed")); - expect(screen.getByText(/connection failed/i)).toBeInTheDocument(); + const alert = screen.getByRole("alert"); + expect( + within(alert).getByRole("heading", { name: /connection failed/i }), + ).toBeInTheDocument(); }); }); @@ -210,8 +213,11 @@ describe("CreateWorkspacePage", () => { await waitFor(() => { expect(mockPublisher).toBeDefined(); mockPublisher.publishClose(new Event("close") as CloseEvent); + const alert = screen.getByRole("alert"); expect( - screen.getByText(/websocket connection.*unexpectedly closed/i), + within(alert).getByRole("heading", { + name: /websocket connection.*unexpectedly closed/i, + }), ).toBeInTheDocument(); }); }); diff --git a/site/src/pages/DeploymentSettingsPage/AIGovernanceSettingsPage/AIGovernanceSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/AIGovernanceSettingsPage/AIGovernanceSettingsPageView.tsx index 4d5eb0410f..c565d25c9e 100644 --- a/site/src/pages/DeploymentSettingsPage/AIGovernanceSettingsPage/AIGovernanceSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/AIGovernanceSettingsPage/AIGovernanceSettingsPageView.tsx @@ -1,5 +1,5 @@ import type { SerpentOption } from "api/typesGenerated"; -import { Alert, AlertDetail, AlertTitle } from "components/Alert/Alert"; +import { Alert, AlertDescription, AlertTitle } from "components/Alert/Alert"; import { Link } from "components/Link/Link"; import { PaywallAIGovernance } from "components/Paywall/PaywallAIGovernance"; import { @@ -50,14 +50,14 @@ export const AIGovernanceSettingsPageView: FC< AI Bridge is included in your license, but not set up yet. - + You have access to AI Governance, but it still needs to be setup. Check out the{" "} AI Bridge {" "} documentation to get started. - + )} = ({ {Boolean(error) && (
- +
)} diff --git a/site/src/pages/SetupPage/SetupPageView.tsx b/site/src/pages/SetupPage/SetupPageView.tsx index 754345c180..426ddb1f0c 100644 --- a/site/src/pages/SetupPage/SetupPageView.tsx +++ b/site/src/pages/SetupPage/SetupPageView.tsx @@ -6,7 +6,7 @@ import TextField from "@mui/material/TextField"; import { countries } from "api/countriesGenerated"; import type * as TypesGen from "api/typesGenerated"; import { isAxiosError } from "axios"; -import { Alert, AlertDetail, AlertTitle } from "components/Alert/Alert"; +import { Alert, AlertDescription, AlertTitle } from "components/Alert/Alert"; import { Button } from "components/Button/Button"; import { ExternalImage } from "components/ExternalImage/ExternalImage"; import { FormFields, VerticalForm } from "components/Form/Form"; @@ -354,13 +354,13 @@ export const SetupPageView: FC = ({ {error.response.data.message} {error.response.data.detail && ( - + {error.response.data.detail}
Contact Sales -
+ )}
)} diff --git a/site/src/pages/TaskPage/ModifyPromptDialog.stories.tsx b/site/src/pages/TaskPage/ModifyPromptDialog.stories.tsx index bde17d64f3..9b528219a6 100644 --- a/site/src/pages/TaskPage/ModifyPromptDialog.stories.tsx +++ b/site/src/pages/TaskPage/ModifyPromptDialog.stories.tsx @@ -129,6 +129,9 @@ export const Submitting: Story = { const submitButton = body.getByRole("button", { name: /update and restart build/i, }); + await waitFor(() => { + expect(submitButton).not.toBeDisabled(); + }); await userEvent.click(submitButton); }); @@ -248,7 +251,10 @@ export const Failure: Story = { }); await step("Shows error message", async () => { - await body.findByText(/Failed to update task prompt/i); + const alert = await body.findByRole("alert"); + await within(alert).findByRole("heading", { + name: /Failed to update task prompt/i, + }); }); }, }; diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index 353a5bf847..e488c31365 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -1,6 +1,6 @@ import type * as TypesGen from "api/typesGenerated"; import type { WorkspaceAgentStatus } from "api/typesGenerated"; -import { Alert, AlertDetail, AlertTitle } from "components/Alert/Alert"; +import { Alert, AlertDescription, AlertTitle } from "components/Alert/Alert"; import { SidebarIconButton } from "components/FullPageLayout/Sidebar"; import { Link } from "components/Link/Link"; import { useSearchParamsKey } from "hooks/useSearchParamsKey"; @@ -186,7 +186,9 @@ export const Workspace: FC = ({ {workspace.latest_build.job.error && ( Workspace build failed - {workspace.latest_build.job.error} + + {workspace.latest_build.job.error} + )} @@ -292,7 +294,7 @@ const UnhealthyWorkspaceAlert: FC = ({ return ( {title} - +

Your workspace is running but{" "} {failingAgentCount > 1 @@ -308,7 +310,7 @@ const UnhealthyWorkspaceAlert: FC = ({ )}

-
+
); };