mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -37,7 +37,7 @@ export const SignInForm: FC<SignInFormProps> = ({
|
||||
|
||||
{Boolean(error) && (
|
||||
<div className="mb-8">
|
||||
<ErrorAlert error={error} />
|
||||
<ErrorAlert error={error} showDebugDetail={false} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user