feat: add <ErrorAlert /> debug details (#22462)

Closes #22140

Short simple and sweet PR to add a bunch of details to our `<Alert />`
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` `<details />`
- Fix overflow in `ErrorAlert` debug accordions so long `Response data`
and `Stack Trace` content stays inside the alert.
- Add horizontal scroll wrappers around both `<pre>` 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.

<img width="739" height="550" alt="preview-validation"
src="https://github.com/user-attachments/assets/a6f890d3-8f1f-4fd6-b9d0-882838db04a4"
/>
This commit is contained in:
Jake Howell
2026-03-06 03:48:01 +11:00
committed by GitHub
parent 0ec27e3d48
commit dba688662c
16 changed files with 117 additions and 66 deletions
@@ -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();
});
+4 -6
View File
@@ -97,9 +97,9 @@ export const Alert: FC<AlertProps> = ({
{...props}
>
<div className="flex items-center justify-between gap-4 text-sm">
<div className="flex flex-row items-start gap-3">
<div className="flex min-w-0 flex-1 flex-row items-start gap-3">
<Icon className={cn("size-icon-sm mt-[3px]", iconClassName)} />
<div className="flex-1">{children}</div>
<div className="min-w-0 flex-1">{children}</div>
</div>
<div className="flex items-center gap-2">
{actions}
@@ -125,7 +125,7 @@ export const Alert: FC<AlertProps> = ({
);
};
export const AlertDetail: React.FC<React.PropsWithChildren> = ({
export const AlertDescription: React.FC<React.PropsWithChildren> = ({
children,
}) => {
return (
@@ -139,7 +139,5 @@ export const AlertTitle: React.FC<React.ComponentPropsWithRef<"h1">> = ({
className,
...props
}) => {
return (
<h1 className={cn("m-0 mb-1 text-sm font-medium", className)} {...props} />
);
return <h1 className={cn("m-0 text-sm font-medium", className)} {...props} />;
};
+55 -27
View File
@@ -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<AlertProps, "severity" | "children"> & { error: unknown }
Omit<AlertProps, "severity" | "children"> & {
error: unknown;
showDebugDetail?: boolean;
}
>;
export const ErrorAlert: FC<ErrorAlertProps> = ({ error, ...alertProps }) => {
export const ErrorAlert: FC<ErrorAlertProps> = ({
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 (
<Alert severity="error" prominent {...alertProps}>
{
// When the error is a Forbidden response we include a link for the user to
// go back to a known viewable page.
status === 403 ? (
<>
<AlertTitle>{message}</AlertTitle>
<AlertDetail>
{detail}{" "}
<Link href="/workspaces" className="w-fit">
Go to workspaces
</Link>
</AlertDetail>
</>
) : detail ? (
<>
<AlertTitle>{message}</AlertTitle>
{shouldDisplayDetail && <AlertDetail>{detail}</AlertDetail>}
</>
) : (
message
)
}
<AlertTitle>{message}</AlertTitle>
<AlertDescription>
{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.
<Link href="/workspaces" className="w-fit">
Go to workspaces
</Link>
)}
</AlertDescription>
{(shouldDisplayResponseData || shouldDisplayStackTrace) &&
showDebugDetail && (
<div className="mt-2 min-w-0">
{shouldDisplayResponseData && (
<details className="max-w-full">
<summary>Response data</summary>
<div className="mt-2 max-w-full overflow-x-auto">
<pre className="m-0 w-max min-w-full">
{JSON.stringify(error.response?.data, null, 2)}
</pre>
</div>
</details>
)}
{/*
* 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 && (
<details className="max-w-full">
<summary>Stack Trace</summary>
<div className="mt-2 max-w-full overflow-x-auto">
<pre className="m-0 w-max min-w-full">{error.stack}</pre>
</div>
</details>
)}
</div>
)}
</Alert>
);
};
@@ -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<GitDeviceAuthProps> = ({
<Alert severity="error">
<AlertTitle>{deviceExchangeError.message}</AlertTitle>
{deviceExchangeError.detail && (
<AlertDetail>{deviceExchangeError.detail}</AlertDetail>
<AlertDescription>{deviceExchangeError.detail}</AlertDescription>
)}
</Alert>
);
@@ -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<ProvisionerAlertProps> = ({
return (
<Alert severity={severity} className={getAlertClassName(variant, severity)}>
<AlertTitle>{title}</AlertTitle>
<AlertDetail>
<AlertDescription>
<div>{detail}</div>
<div className="flex items-center gap-2 flex-wrap mt-2">
{Object.entries(tags ?? {})
@@ -62,7 +62,7 @@ export const ProvisionerAlert: FC<ProvisionerAlertProps> = ({
<ProvisionerTag key={key} tagName={key} tagValue={value} />
))}
</div>
</AlertDetail>
</AlertDescription>
</Alert>
);
};
@@ -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<WildcardHostnameWarningProps> = ({
}
>
<AlertTitle>Some workspace applications will not work</AlertTitle>
<AlertDetail>
<AlertDescription>
<div>
{hasResources
? "This template contains coder_app resources with"
@@ -78,7 +78,7 @@ export const WildcardHostnameWarning: FC<WildcardHostnameWarningProps> = ({
</span>
</Link>
</div>
</AlertDetail>
</AlertDescription>
</Alert>
);
};
@@ -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,
});
});
},
};
@@ -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<RequestLogsPageViewProps> = ({
<AlertTitle>
AI Bridge is included in your license, but not set up yet.
</AlertTitle>
<AlertDetail>
<AlertDescription>
You have access to AI Governance, but it still needs to be setup.
Check out the{" "}
<Link href={docs("/ai-coder/ai-bridge")} target="_blank">
AI Bridge
</Link>{" "}
documentation to get started.
</AlertDetail>
</AlertDescription>
</Alert>
);
}
@@ -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<ChatModelAdminPanelProps> = ({
<AlertTitle>
Chat provider admin API is unavailable on this deployment.
</AlertTitle>
<AlertDetail>/api/v2/chats/providers is missing.</AlertDetail>
<AlertDescription>
/api/v2/chats/providers is missing.
</AlertDescription>
</Alert>
)}
@@ -375,7 +377,9 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
<AlertTitle>
Chat model admin API is unavailable on this deployment.
</AlertTitle>
<AlertDetail>/api/v2/chats/model-configs is missing.</AlertDetail>
<AlertDescription>
/api/v2/chats/model-configs is missing.
</AlertDescription>
</Alert>
)}
</div>
@@ -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<ProviderFormProps> = ({
{isAPIKeyEnvManaged ? (
<Alert severity="info">
<AlertTitle>API key managed by environment variable</AlertTitle>
<AlertDetail>
<AlertDescription>
This provider key is configured from deployment environment settings
and cannot be edited in this UI.
</AlertDetail>
</AlertDescription>
</Alert>
) : (
<form
@@ -188,7 +188,10 @@ describe("CreateWorkspacePage", () => {
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();
});
});
@@ -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<
<AlertTitle>
AI Bridge is included in your license, but not set up yet.
</AlertTitle>
<AlertDetail>
<AlertDescription>
You have access to AI Governance, but it still needs to be
setup. Check out the{" "}
<Link href={docs("/ai-coder/ai-bridge")} target="_blank">
AI Bridge
</Link>{" "}
documentation to get started.
</AlertDetail>
</AlertDescription>
</Alert>
)}
<OptionsTable
+1 -1
View File
@@ -37,7 +37,7 @@ export const SignInForm: FC<SignInFormProps> = ({
{Boolean(error) && (
<div className="mb-8">
<ErrorAlert error={error} />
<ErrorAlert error={error} showDebugDetail={false} />
</div>
)}
+3 -3
View File
@@ -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<SetupPageViewProps> = ({
<Alert severity="error" prominent>
<AlertTitle>{error.response.data.message}</AlertTitle>
{error.response.data.detail && (
<AlertDetail>
<AlertDescription>
{error.response.data.detail}
<br />
<Link target="_blank" href="https://coder.com/contact/sales">
Contact Sales
</Link>
</AlertDetail>
</AlertDescription>
)}
</Alert>
)}
@@ -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,
});
});
},
};
+6 -4
View File
@@ -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<WorkspaceProps> = ({
{workspace.latest_build.job.error && (
<Alert severity="error" prominent>
<AlertTitle>Workspace build failed</AlertTitle>
<AlertDetail>{workspace.latest_build.job.error}</AlertDetail>
<AlertDescription>
{workspace.latest_build.job.error}
</AlertDescription>
</Alert>
)}
@@ -292,7 +294,7 @@ const UnhealthyWorkspaceAlert: FC<UnhealthyWorkspaceAlertProps> = ({
return (
<Alert severity="warning" prominent>
<AlertTitle>{title}</AlertTitle>
<AlertDetail>
<AlertDescription>
<p>
Your workspace is running but{" "}
{failingAgentCount > 1
@@ -308,7 +310,7 @@ const UnhealthyWorkspaceAlert: FC<UnhealthyWorkspaceAlertProps> = ({
</Link>
)}
</p>
</AlertDetail>
</AlertDescription>
</Alert>
);
};