mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-24 18:26:51 +03:00
Add error boundary to frontend (#24255)
* add error boundary on frontend errors, show a page-level recovery panel and a separate compact strip for the sidebar/status/bottombar so failures there don't take down the rest of the page * tweaks * fix test
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* App shell error boundaries.
|
||||
*
|
||||
* Both failures are forced through the mock layer rather than through test
|
||||
* hooks in the app. A non-array payload reaches a component that treats it as
|
||||
* a list and throws on the first render; aborting a page's asset request
|
||||
* reproduces what an open tab sees after Frigate is updated underneath it.
|
||||
*/
|
||||
|
||||
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
|
||||
import { grantClipboardPermissions, readClipboard } from "../helpers/clipboard";
|
||||
|
||||
/** Exports builds its list with `rawExports.filter(...)`. */
|
||||
async function breakExportsPage(app: FrigateApp) {
|
||||
await app.page.route("**/api/exports**", (route) =>
|
||||
route.fulfill({ json: { unexpected: true } }),
|
||||
);
|
||||
await app.goto("/export");
|
||||
await expect(app.page.getByTestId("error-panel")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `useStats` runs `Object.entries(stats.detectors)` and only the status bar
|
||||
* and bottom bar call it, so null detectors throw in one chrome component.
|
||||
*/
|
||||
async function breakStatusbar(app: FrigateApp) {
|
||||
await app.installDefaults({ stats: { detectors: null } });
|
||||
await app.goto("/");
|
||||
}
|
||||
|
||||
/** GeneralSettings and the status bar both read profiles, so both throw. */
|
||||
async function breakAllDesktopChrome(app: FrigateApp) {
|
||||
await app.page.route("**/api/profiles**", (route) =>
|
||||
route.fulfill({
|
||||
json: { profiles: { broken: true }, active_profile: "default" },
|
||||
}),
|
||||
);
|
||||
await app.goto("/");
|
||||
}
|
||||
|
||||
test.describe("Error boundaries - page failure @high", () => {
|
||||
// React mirrors the caught error to console.error, and the panel shows the
|
||||
// TypeError message on purpose.
|
||||
test.use({
|
||||
expectedErrors: [
|
||||
/is not a function|An error occurred in the|The above error occurred/,
|
||||
],
|
||||
});
|
||||
|
||||
test("a thrown page renders a recovery panel, not a blank screen", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await breakExportsPage(frigateApp);
|
||||
|
||||
const panel = frigateApp.page.getByTestId("error-panel");
|
||||
await expect(panel.getByText("This page stopped working")).toBeVisible();
|
||||
await expect(panel.getByRole("button", { name: "Reload" })).toBeVisible();
|
||||
await expect(panel.getByTestId("error-panel-message")).toContainText(
|
||||
"is not a function",
|
||||
);
|
||||
|
||||
// Contained to the route: the panel sits in the page container and the
|
||||
// chrome boundary never trips, so navigation stays usable.
|
||||
await expect(
|
||||
frigateApp.page.locator("#pageRoot [data-testid='error-panel']"),
|
||||
).toBeVisible();
|
||||
await expect(frigateApp.page.getByTestId("error-strip")).toHaveCount(0);
|
||||
await expect(frigateApp.page.locator('a[href="/"]').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("Copy details puts a triageable report on the clipboard", async ({
|
||||
frigateApp,
|
||||
context,
|
||||
}) => {
|
||||
await grantClipboardPermissions(context);
|
||||
await breakExportsPage(frigateApp);
|
||||
|
||||
await frigateApp.page
|
||||
.getByTestId("error-panel")
|
||||
.getByRole("button", { name: "Copy details" })
|
||||
.click();
|
||||
|
||||
await expect
|
||||
.poll(() => readClipboard(frigateApp.page), { timeout: 5_000 })
|
||||
.toContain("Frigate UI crash report");
|
||||
|
||||
const report = await readClipboard(frigateApp.page);
|
||||
expect(report).toContain("Version: 0.15.0-test");
|
||||
expect(report).toMatch(/^Page: http/m);
|
||||
expect(report).toContain("is not a function");
|
||||
});
|
||||
|
||||
test("navigating away drops the panel", async ({ frigateApp }) => {
|
||||
await breakExportsPage(frigateApp);
|
||||
|
||||
await frigateApp.page.locator('a[href="/"]').first().click();
|
||||
await expect(frigateApp.page).toHaveURL(/\/$/);
|
||||
await expect(frigateApp.page.getByTestId("error-panel")).toHaveCount(0);
|
||||
await expect(
|
||||
frigateApp.page.locator("[data-camera='front_door']"),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("@mobile the panel leaves the bottom bar reachable", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(!frigateApp.isMobile, "Mobile-only assertion");
|
||||
await breakExportsPage(frigateApp);
|
||||
|
||||
await expect(
|
||||
frigateApp.page
|
||||
.getByTestId("error-panel")
|
||||
.getByRole("button", { name: "Reload" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
frigateApp.page.locator('a[href="/review"]').first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Error boundaries - chrome failure @high", () => {
|
||||
test.use({
|
||||
expectedErrors: [
|
||||
/is not a function|An error occurred in the|The above error occurred/,
|
||||
],
|
||||
});
|
||||
|
||||
test("a thrown status bar leaves the sidebar usable", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "The status bar is desktop chrome");
|
||||
await breakStatusbar(frigateApp);
|
||||
|
||||
const strip = frigateApp.page.getByTestId("error-strip");
|
||||
await expect(strip).toBeVisible({ timeout: 10_000 });
|
||||
await expect(strip).toHaveCount(1);
|
||||
await expect(strip.getByRole("button", { name: "Reload" })).toBeVisible();
|
||||
|
||||
// Each chrome component owns a boundary, so the sidebar outlives the
|
||||
// status bar and the user can still navigate out.
|
||||
await expect(frigateApp.page.locator("aside")).toBeVisible();
|
||||
await expect(
|
||||
frigateApp.page.locator('a[href="/review"]').first(),
|
||||
).toBeVisible();
|
||||
await expect(frigateApp.page.getByTestId("error-panel")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("chrome failures never reach the page content", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop chrome");
|
||||
await breakAllDesktopChrome(frigateApp);
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByTestId("error-strip").first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(frigateApp.page.getByTestId("error-panel")).toHaveCount(0);
|
||||
await expect(
|
||||
frigateApp.page.locator("[data-camera='front_door']"),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Error boundaries - clipboard refused @high", () => {
|
||||
test.use({
|
||||
expectedErrors: [
|
||||
/is not a function|An error occurred in the|The above error occurred/,
|
||||
],
|
||||
});
|
||||
|
||||
test("a refused clipboard write reports the failure", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// copy-to-clipboard treats a false return from execCommand as a failure
|
||||
// and falls back to a prompt, which Playwright dismisses on its own.
|
||||
await frigateApp.page.addInitScript(() => {
|
||||
document.execCommand = () => false;
|
||||
});
|
||||
await breakExportsPage(frigateApp);
|
||||
|
||||
await frigateApp.page
|
||||
.getByTestId("error-panel")
|
||||
.getByRole("button", { name: "Copy details" })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByText("Could not copy details to clipboard"),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Error boundaries - stale assets @high", () => {
|
||||
test.use({
|
||||
expectedErrors: [
|
||||
/Failed to fetch dynamically imported module|Importing a module script failed|net::ERR_FAILED|An error occurred in the|The above error occurred/,
|
||||
],
|
||||
});
|
||||
|
||||
test("a missing page chunk asks for a reload", async ({ frigateApp }) => {
|
||||
await frigateApp.page.route(
|
||||
/\/assets\/Exports-[^/]+\.js(\?.*)?$/,
|
||||
(route) => route.abort("failed"),
|
||||
);
|
||||
await frigateApp.goto("/");
|
||||
await frigateApp.page.locator('a[href="/export"]').first().click();
|
||||
|
||||
const panel = frigateApp.page.getByTestId("error-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 10_000 });
|
||||
await expect(panel.getByText("Reload required")).toBeVisible();
|
||||
await expect(panel.getByRole("button", { name: "Reload" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -164,7 +164,8 @@
|
||||
"saveAll": "Save All",
|
||||
"savingAll": "Saving All…",
|
||||
"undoAll": "Undo All",
|
||||
"retry": "Retry"
|
||||
"retry": "Retry",
|
||||
"reload": "Reload"
|
||||
},
|
||||
"menu": {
|
||||
"system": "System",
|
||||
@@ -314,6 +315,15 @@
|
||||
"title": "404",
|
||||
"desc": "Page not found"
|
||||
},
|
||||
"error": {
|
||||
"title": "This page stopped working",
|
||||
"desc": "The page ran into an unexpected error. Reloading usually clears it. If it keeps happening, copy the details and attach them to a discussion on GitHub.",
|
||||
"staleTitle": "Reload required",
|
||||
"staleDesc": "Part of the interface could not be loaded, which usually means Frigate was updated while this tab was open. Reload to pick up the new version.",
|
||||
"partial": "Part of the interface stopped working.",
|
||||
"copyDetails": "Copy details",
|
||||
"copyFailed": "Could not copy details to clipboard"
|
||||
},
|
||||
"selectItem": "Select {{item}}",
|
||||
"readTheDocumentation": "Read the documentation",
|
||||
"information": {
|
||||
|
||||
+11
-10
@@ -6,7 +6,7 @@ import Sidebar from "@/components/navigation/Sidebar";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import Statusbar from "./components/Statusbar";
|
||||
import Bottombar from "./components/navigation/Bottombar";
|
||||
import { Suspense, lazy, useContext, useEffect, useState } from "react";
|
||||
import { lazy, useContext, useEffect, useState } from "react";
|
||||
import { Redirect } from "./components/navigation/Redirect";
|
||||
import { cn } from "./lib/utils";
|
||||
import { isPWA } from "./utils/isPWA";
|
||||
@@ -18,6 +18,7 @@ import { isRedirectingToLogin } from "@/api/auth-redirect";
|
||||
import { AuthContext } from "@/context/auth-context";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { isSetupDismissed } from "@/utils/setupWizard";
|
||||
import { ChromeErrorBoundary, LazyPage } from "@/components/ErrorBoundaries";
|
||||
|
||||
const Live = lazy(() => import("@/pages/Live"));
|
||||
const Events = lazy(() => import("@/pages/Events"));
|
||||
@@ -94,22 +95,22 @@ function DefaultAppView() {
|
||||
if (showWizard) {
|
||||
return (
|
||||
<div className="size-full overflow-hidden">
|
||||
<Suspense
|
||||
<LazyPage
|
||||
fallback={
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
}
|
||||
>
|
||||
<SetupWizard />
|
||||
</Suspense>
|
||||
</LazyPage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="size-full overflow-hidden">
|
||||
{isDesktop && <Sidebar />}
|
||||
{isDesktop && <Statusbar />}
|
||||
{isMobile && <Bottombar />}
|
||||
<ChromeErrorBoundary>{isDesktop && <Sidebar />}</ChromeErrorBoundary>
|
||||
<ChromeErrorBoundary>{isDesktop && <Statusbar />}</ChromeErrorBoundary>
|
||||
<ChromeErrorBoundary>{isMobile && <Bottombar />}</ChromeErrorBoundary>
|
||||
<div
|
||||
id="pageRoot"
|
||||
className={cn(
|
||||
@@ -121,7 +122,7 @@ function DefaultAppView() {
|
||||
: "bottom-8 left-[52px]",
|
||||
)}
|
||||
>
|
||||
<Suspense
|
||||
<LazyPage
|
||||
fallback={
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
}
|
||||
@@ -147,7 +148,7 @@ function DefaultAppView() {
|
||||
<Route path="/unauthorized" element={<AccessDenied />} />
|
||||
<Route path="*" element={<Redirect to="/" />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</LazyPage>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -160,9 +161,9 @@ function SafeAppView() {
|
||||
id="pageRoot"
|
||||
className={cn("absolute bottom-0 left-0 right-0 top-0 overflow-hidden")}
|
||||
>
|
||||
<Suspense>
|
||||
<LazyPage>
|
||||
<ConfigEditor />
|
||||
</Suspense>
|
||||
</LazyPage>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Error boundaries for the app shell.
|
||||
*
|
||||
* A throw during render normally unmounts the whole tree and leaves a blank
|
||||
* screen behind. These boundaries stop that at the page container and at the
|
||||
* navigation chrome, so one broken view cannot take the rest of the UI with
|
||||
* it. Only render, commit, and lifecycle throws land here; event handlers,
|
||||
* timers, and rejected fetches are still on their own.
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
Suspense,
|
||||
type ComponentType,
|
||||
type ErrorInfo,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { toast } from "sonner";
|
||||
import { FaExclamationTriangle } from "react-icons/fa";
|
||||
import { LuCopy, LuRefreshCw } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { useAutoFrigateStats } from "@/hooks/use-stats";
|
||||
|
||||
type Failure = {
|
||||
error: unknown;
|
||||
componentStack?: string;
|
||||
};
|
||||
|
||||
type PanelProps = {
|
||||
failure: Failure;
|
||||
};
|
||||
|
||||
// A new build replaces the hashed asset files, so a tab left open across an
|
||||
// update asks for files that are gone. Browsers word that failure their own
|
||||
// way, which leaves these fragments as the only common signal.
|
||||
const STALE_ASSET_HINTS = [
|
||||
"chunkloaderror",
|
||||
"loading chunk",
|
||||
"loading css chunk",
|
||||
"dynamically imported module",
|
||||
"module script failed",
|
||||
];
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message || error.name;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function isStaleAsset({ error }: Failure): boolean {
|
||||
const name = error instanceof Error ? error.name : "";
|
||||
const text = `${name} ${messageOf(error)}`.toLowerCase();
|
||||
|
||||
return STALE_ASSET_HINTS.some((hint) => text.includes(hint));
|
||||
}
|
||||
|
||||
/** Version goes first so a pasted report is triageable on its own. */
|
||||
function crashReport({ error, componentStack }: Failure, version?: string) {
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
|
||||
return [
|
||||
"Frigate UI crash report",
|
||||
`Version: ${version || "unknown"}`,
|
||||
`Page: ${window.location.href}`,
|
||||
`Browser: ${navigator.userAgent}`,
|
||||
`When: ${new Date().toISOString()}`,
|
||||
`Error: ${messageOf(error)}`,
|
||||
stack && `\nStack:\n${stack}`,
|
||||
componentStack && `\nComponents:${componentStack.trimEnd()}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function reloadPage() {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function PagePanel({ failure }: PanelProps) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
// service.version carries the release and the build commit, and the status
|
||||
// bar already keeps it warm. config.version is the config schema version.
|
||||
const stats = useAutoFrigateStats();
|
||||
|
||||
const stale = isStaleAsset(failure);
|
||||
|
||||
const onCopy = () => {
|
||||
// copy() falls back to a prompt and returns false when the clipboard is
|
||||
// refused, so a success toast has to wait on the result.
|
||||
const copied = copy(crashReport(failure, stats?.service.version));
|
||||
|
||||
if (copied) {
|
||||
toast.success(t("button.copiedToClipboard"), { position: "top-center" });
|
||||
} else {
|
||||
toast.error(t("error.copyFailed"), { position: "top-center" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="error-panel"
|
||||
className="flex size-full flex-col items-center justify-center overflow-auto p-4 text-center"
|
||||
>
|
||||
<FaExclamationTriangle className="mb-4 size-8 text-danger" />
|
||||
<Heading as="h2" className="mb-2">
|
||||
{stale ? t("error.staleTitle") : t("error.title")}
|
||||
</Heading>
|
||||
<p className="max-w-md text-primary-variant">
|
||||
{stale ? t("error.staleDesc") : t("error.desc")}
|
||||
</p>
|
||||
<code
|
||||
data-testid="error-panel-message"
|
||||
className="my-4 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-md bg-secondary px-3 py-2 text-left text-xs text-primary"
|
||||
>
|
||||
{messageOf(failure.error)}
|
||||
</code>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="select"
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("button.reload")}
|
||||
onClick={reloadPage}
|
||||
>
|
||||
<LuRefreshCw className="size-4" />
|
||||
{t("button.reload")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("error.copyDetails")}
|
||||
onClick={onCopy}
|
||||
>
|
||||
<LuCopy className="size-4" />
|
||||
{t("error.copyDetails")}
|
||||
</Button>
|
||||
</div>
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Corner strip, so failed chrome never sits on top of the page content. */
|
||||
function ChromeNotice() {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="error-strip"
|
||||
className="absolute bottom-0 left-0 z-50 flex max-w-full items-center gap-2 rounded-tr-md bg-secondary px-3 py-1.5 text-xs text-primary shadow-md"
|
||||
>
|
||||
<FaExclamationTriangle className="size-4 shrink-0 text-danger" />
|
||||
<div className="truncate">{t("error.partial")}</div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 text-xs"
|
||||
aria-label={t("button.reload")}
|
||||
onClick={reloadPage}
|
||||
>
|
||||
{t("button.reload")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TrapProps = {
|
||||
children?: ReactNode;
|
||||
panel: ComponentType<PanelProps>;
|
||||
resetToken: string;
|
||||
};
|
||||
|
||||
type TrapState = {
|
||||
token: string;
|
||||
failure?: Failure;
|
||||
};
|
||||
|
||||
class ErrorTrap extends Component<TrapProps, TrapState> {
|
||||
state: TrapState = { token: this.props.resetToken };
|
||||
|
||||
static getDerivedStateFromError(error: unknown): Partial<TrapState> {
|
||||
return { failure: { error } };
|
||||
}
|
||||
|
||||
// Dropping the failure here beats both alternatives: keying the boundary on
|
||||
// the route would remount the whole tree on every navigation, and resetting
|
||||
// from componentDidUpdate would paint the dead panel one more time first.
|
||||
static getDerivedStateFromProps(props: TrapProps, state: TrapState) {
|
||||
if (props.resetToken === state.token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { token: props.resetToken, failure: undefined };
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown, info: ErrorInfo) {
|
||||
// React logs the error on its own. The component stack is the part worth
|
||||
// holding on to, since it names the subtree that threw.
|
||||
this.setState({
|
||||
failure: { error, componentStack: info.componentStack ?? undefined },
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { failure } = this.state;
|
||||
|
||||
if (!failure) {
|
||||
return this.props.children;
|
||||
}
|
||||
|
||||
const Panel = this.props.panel;
|
||||
|
||||
return <Panel failure={failure} />;
|
||||
}
|
||||
}
|
||||
|
||||
type ChromeErrorBoundaryProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/** Guards the sidebar, status bar, and bottom bar as one unit. */
|
||||
export function ChromeErrorBoundary({ children }: ChromeErrorBoundaryProps) {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
return (
|
||||
<ErrorTrap panel={ChromeNotice} resetToken={pathname}>
|
||||
{children}
|
||||
</ErrorTrap>
|
||||
);
|
||||
}
|
||||
|
||||
type LazyPageProps = {
|
||||
children?: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Suspense for a lazily loaded page, plus the recovery panel it needs when
|
||||
* the chunk fails to arrive or the page throws on its first render.
|
||||
*/
|
||||
export function LazyPage({ children, fallback }: LazyPageProps) {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
return (
|
||||
<ErrorTrap panel={PagePanel} resetToken={pathname}>
|
||||
<Suspense fallback={fallback}>{children}</Suspense>
|
||||
</ErrorTrap>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user