/** * 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 = async () => { // copy() falls back to a prompt and resolves false when the clipboard is // refused, so a success toast has to wait on the result. const copied = await copy(crashReport(failure, stats?.service.version), { fallbackToPrompt: true, }); if (copied) { toast.success(t("button.copiedToClipboard"), { position: "top-center" }); } else { toast.error(t("error.copyFailed"), { position: "top-center" }); } }; return (
{stale ? t("error.staleDesc") : t("error.desc")}
{messageOf(failure.error)}