feat(admin): add production error center and refresh CMS dependencies
This commit is contained in:
1 parent
c6b919c01d
commit
816e3875c2
47 files changed
+1545
-547
No files matched your search
@@ -2,9 +2,10 @@
|
||||
|
||||
import { Home, RefreshCw } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ErrorScreen } from "@/components/error-screen";
|
||||
import Link from "@/components/link";
|
||||
import { reportBrowserError } from "@/lib/browser-errors";
|
||||
|
||||
/**
|
||||
* Site route-segment error boundary. Renders inside the site shell layout.
|
||||
@@ -19,8 +20,10 @@ export default function SiteErrorPage({
|
||||
}) {
|
||||
const t = useTranslations("pages.error");
|
||||
|
||||
const [reference, setReference] = useState(error.digest);
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
setReference(error.digest ?? reportBrowserError(error, "browser.boundary"));
|
||||
if (error.digest) reportBrowserError(error, "browser.boundary");
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
@@ -46,9 +49,9 @@ export default function SiteErrorPage({
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
error.digest ? (
|
||||
reference ? (
|
||||
<p className="error-screen-digest">
|
||||
{t("reference", { digest: error.digest })}
|
||||
{t("reference", { digest: reference })}
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
guard: vi.fn(),
|
||||
resolve: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: mocks.guard }));
|
||||
vi.mock("@/lib/error-monitor", () => ({
|
||||
ErrorStore: class {
|
||||
resolve = mocks.resolve;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { DEVOPS_EDIT: "admin.devops.edit" },
|
||||
}));
|
||||
vi.mock("@/lib/services/audit", () => ({ logAudit: mocks.audit }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
import { resolveCmsError } from "./actions";
|
||||
|
||||
it("requires edit permission before resolving or auditing", async () => {
|
||||
mocks.guard.mockRejectedValue(new Error("denied"));
|
||||
await expect(resolveCmsError(new FormData())).rejects.toThrow("denied");
|
||||
expect(mocks.resolve).not.toHaveBeenCalled();
|
||||
expect(mocks.audit).not.toHaveBeenCalled();
|
||||
});
|
||||
it("records who resolved the problem", async () => {
|
||||
mocks.guard.mockResolvedValue({ id: 42 });
|
||||
mocks.resolve.mockResolvedValue(undefined);
|
||||
mocks.audit.mockResolvedValue(undefined);
|
||||
const data = new FormData();
|
||||
data.set("fingerprint", "aabbccddeeff0011");
|
||||
await resolveCmsError(data);
|
||||
expect(mocks.guard).toHaveBeenCalledWith("admin.devops.edit");
|
||||
expect(mocks.audit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 42, action: "cms.error.resolve" }),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { ErrorStore } from "@/lib/error-monitor";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
export async function resolveCmsError(data: FormData) {
|
||||
const staff = await requirePermission(PERMS.DEVOPS_EDIT);
|
||||
await new ErrorStore().resolve(String(data.get("fingerprint") ?? ""));
|
||||
await logAudit({
|
||||
userId: staff.id,
|
||||
action: "cms.error.resolve",
|
||||
target: "cms-error",
|
||||
after: { fingerprint: String(data.get("fingerprint")) },
|
||||
});
|
||||
revalidatePath("/admin/devops/cms-errors");
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ read: vi.fn(), access: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
getAdminContext: async () => ({
|
||||
session: { user: { rank: 1 } },
|
||||
permissions: {},
|
||||
}),
|
||||
canAccess: mocks.access,
|
||||
PERMS: { DEVOPS_VIEW: "devops.view" },
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: () => {
|
||||
throw new Error("redirect");
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/error-monitor", () => ({
|
||||
ErrorStore: class {
|
||||
read = mocks.read;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/components/link", () => ({ default: () => null }));
|
||||
|
||||
import Page from "./page";
|
||||
|
||||
it("rejects access before reading diagnostic data", async () => {
|
||||
mocks.access.mockReturnValue(false);
|
||||
await expect(Page({ searchParams: Promise.resolve({}) })).rejects.toThrow(
|
||||
"redirect",
|
||||
);
|
||||
expect(mocks.read).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "@/components/link";
|
||||
import { ErrorStore } from "@/lib/error-monitor";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { resolveCmsError } from "./actions";
|
||||
export default async function CmsErrorsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank))
|
||||
redirect("/admin");
|
||||
const params = await searchParams;
|
||||
const query = (params.q ?? "").trim().slice(0, 200).toLowerCase();
|
||||
let result: Awaited<ReturnType<ErrorStore["read"]>>;
|
||||
try {
|
||||
result = await new ErrorStore().read();
|
||||
} catch {
|
||||
return (
|
||||
<div className="admin-card" role="alert">
|
||||
Error storage could not be read. Check the CMS storage volume and
|
||||
permissions.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const filtered = result.records.filter(
|
||||
(r) =>
|
||||
(!params.source || r.source === params.source) &&
|
||||
(!query || JSON.stringify(r).toLowerCase().includes(query)),
|
||||
);
|
||||
const groups = new Map<string, typeof filtered>();
|
||||
for (const r of filtered) {
|
||||
const list = groups.get(r.fingerprint) ?? [];
|
||||
list.push(r);
|
||||
groups.set(r.fingerprint, list);
|
||||
}
|
||||
const page = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
Number(params.page) || 1,
|
||||
Math.max(1, Math.ceil(groups.size / 25)),
|
||||
),
|
||||
);
|
||||
const pageHref = (next: number) =>
|
||||
`?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", page: String(next) })}`;
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">CMS error center</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Server and browser errors from the last 7 days. Browser reports are
|
||||
unverified client observations.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href="/admin/devops" className="btn btn-outline">
|
||||
DevOps
|
||||
</Link>
|
||||
<Link href="/admin/devops/errors" className="btn btn-outline">
|
||||
Emulator errors
|
||||
</Link>
|
||||
<Link href="/admin/devops/cms-errors" className="btn btn-outline">
|
||||
Refresh / clear filters
|
||||
</Link>
|
||||
</div>
|
||||
<form className="admin-card flex flex-wrap gap-3">
|
||||
<input
|
||||
aria-label="Search errors"
|
||||
name="q"
|
||||
defaultValue={params.q}
|
||||
placeholder="Reference, digest, message, route or release"
|
||||
className="input min-w-0 flex-1"
|
||||
/>
|
||||
<select
|
||||
name="source"
|
||||
aria-label="Error source"
|
||||
defaultValue={params.source ?? ""}
|
||||
className="input"
|
||||
>
|
||||
<option value="">All sources</option>
|
||||
<option value="server">Server</option>
|
||||
<option value="browser">Browser</option>
|
||||
</select>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filtered.length} occurrences · {groups.size} groups.{" "}
|
||||
{result.truncated
|
||||
? "Showing the latest 1,000 events. Narrowing filters searches this retained window."
|
||||
: ""}{" "}
|
||||
Storage is limited to 10 MB per day; excess events remain in server logs
|
||||
where available.
|
||||
</p>
|
||||
{groups.size === 0 ? (
|
||||
<div className="admin-card">
|
||||
No captured errors match these filters. Monitoring starts with this
|
||||
deployment; older console logs are not imported.
|
||||
</div>
|
||||
) : null}
|
||||
{[...groups.entries()]
|
||||
.slice((page - 1) * 25, page * 25)
|
||||
.map(([fingerprint, events]) => {
|
||||
const r = events[0];
|
||||
return (
|
||||
<details key={fingerprint} className="admin-card overflow-hidden">
|
||||
<summary className="cursor-pointer">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{r.resolved ? "Resolved" : "Open"} · {r.source} ·{" "}
|
||||
{events.length} occurrences · {r.at}
|
||||
</span>
|
||||
<p className="font-semibold break-words">{r.event}</p>
|
||||
<p className="text-sm break-words">{r.message}</p>
|
||||
</summary>
|
||||
<div className="mt-4 space-y-3 text-sm">
|
||||
<p>
|
||||
Reference: <code className="break-all">{r.id}</code>
|
||||
</p>
|
||||
<p>
|
||||
Release: <code className="break-all">{r.release}</code>
|
||||
</p>
|
||||
<pre className="whitespace-pre-wrap break-all rounded bg-muted p-3">
|
||||
{JSON.stringify(r.context, null, 2)}
|
||||
</pre>
|
||||
<pre className="max-h-96 overflow-auto whitespace-pre-wrap break-all rounded bg-muted p-3">
|
||||
{r.stack ||
|
||||
"No stack supplied. Use the reference or digest to correlate server logs."}
|
||||
</pre>
|
||||
{!r.resolved &&
|
||||
canAccess(permissions, PERMS.DEVOPS_EDIT, session.user.rank) ? (
|
||||
<form action={resolveCmsError}>
|
||||
<input
|
||||
type="hidden"
|
||||
name="fingerprint"
|
||||
value={r.fingerprint}
|
||||
/>
|
||||
<button type="submit" className="btn btn-outline">
|
||||
Mark resolved
|
||||
</button>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
A new occurrence reopens the group automatically.
|
||||
</p>
|
||||
</form>
|
||||
) : null}
|
||||
<details>
|
||||
<summary className="cursor-pointer">
|
||||
Occurrence references
|
||||
</summary>
|
||||
<ul>
|
||||
{events.map((e) => (
|
||||
<li key={e.id} className="break-all">
|
||||
{e.at} · {e.id}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
<p className="text-muted-foreground">
|
||||
Browser stack traces may reference compiled chunks. This view
|
||||
does not infer a root cause or include request bodies.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
<nav aria-label="Error pages" className="flex gap-3">
|
||||
{page > 1 ? (
|
||||
<Link href={pageHref(page - 1)} className="btn btn-outline">
|
||||
Previous
|
||||
</Link>
|
||||
) : null}
|
||||
{page * 25 < groups.size ? (
|
||||
<Link href={pageHref(page + 1)} className="btn btn-outline">
|
||||
Next
|
||||
</Link>
|
||||
) : null}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,9 @@ export default async function EmulatorErrorsPage({
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<a href="/admin/devops/cms-errors" className="btn btn-outline">
|
||||
CMS error center
|
||||
</a>
|
||||
<p className="text-muted-foreground">
|
||||
{total} total errors — showing page {page} of {lastPage}
|
||||
</p>
|
||||
|
||||
@@ -55,6 +55,9 @@ export default async function DevOpsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<a href="/admin/devops/cms-errors" className="btn btn-outline">
|
||||
CMS error center
|
||||
</a>
|
||||
{/* Status cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
<Card>
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { LayoutDashboard, RefreshCw } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { reportBrowserError } from "@/lib/browser-errors";
|
||||
|
||||
/**
|
||||
* Admin route-segment error boundary. Renders inside the admin layout shell.
|
||||
@@ -19,8 +20,10 @@ export default function AdminErrorPage({
|
||||
const t = useTranslations("pages.error");
|
||||
const tNav = useTranslations("admin.nav");
|
||||
|
||||
const [reference, setReference] = useState(error.digest);
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
setReference(error.digest ?? reportBrowserError(error, "browser.boundary"));
|
||||
if (error.digest) reportBrowserError(error, "browser.boundary");
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
@@ -63,12 +66,12 @@ export default function AdminErrorPage({
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{error.digest ? (
|
||||
{reference ? (
|
||||
<p
|
||||
className="mt-6 text-xs font-mono"
|
||||
style={{ color: "var(--admin-muted, #9ca3af)" }}
|
||||
>
|
||||
{t("reference", { digest: error.digest })}
|
||||
{t("reference", { digest: reference })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { reportBrowserError } from "@/lib/browser-errors";
|
||||
|
||||
export default function PermissionRankError({
|
||||
error,
|
||||
@@ -11,8 +12,10 @@ export default function PermissionRankError({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const [reference, setReference] = useState(error.digest);
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
setReference(error.digest ?? reportBrowserError(error, "browser.boundary"));
|
||||
if (error.digest) reportBrowserError(error, "browser.boundary");
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
@@ -28,9 +31,9 @@ export default function PermissionRankError({
|
||||
<pre className="max-h-80 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-3 text-xs">
|
||||
{error.stack ?? error.message}
|
||||
</pre>
|
||||
{error.digest ? (
|
||||
{reference ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Reference: {error.digest}
|
||||
Reference: {reference}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="button" onClick={reset}>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ append: vi.fn(), limit: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
clientIp: async () => "test",
|
||||
rateLimit: mocks.limit,
|
||||
}));
|
||||
vi.mock("@/lib/error-monitor", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/error-monitor")>(
|
||||
"@/lib/error-monitor",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ErrorStore: class {
|
||||
append = mocks.append;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { POST } from "./route";
|
||||
|
||||
afterEach(() => vi.resetAllMocks());
|
||||
const req = (body: string, origin = "https://cms.test") =>
|
||||
new Request("https://cms.test/api/diagnostics/errors", {
|
||||
method: "POST",
|
||||
headers: { origin, "Content-Type": "application/json" },
|
||||
body,
|
||||
});
|
||||
it("rejects cross-origin reports before touching storage", async () => {
|
||||
expect((await POST(req("{}", "https://other.test"))).status).toBe(403);
|
||||
expect(mocks.append).not.toHaveBeenCalled();
|
||||
});
|
||||
it("limits reports before reading their body", async () => {
|
||||
mocks.limit.mockResolvedValue({ ok: false });
|
||||
expect((await POST(req("{}"))).status).toBe(429);
|
||||
expect(mocks.append).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects oversized reports without trusting content-length", async () => {
|
||||
mocks.limit.mockResolvedValue({ ok: true });
|
||||
expect((await POST(req("a".repeat(17000)))).status).toBe(413);
|
||||
expect(mocks.append).not.toHaveBeenCalled();
|
||||
});
|
||||
it("stores only sanitized diagnostic fields and identifies browser claims", async () => {
|
||||
mocks.limit.mockResolvedValue({ ok: true });
|
||||
mocks.append.mockResolvedValue(undefined);
|
||||
const r = await POST(
|
||||
req(
|
||||
JSON.stringify({
|
||||
event: "browser.exception",
|
||||
message: "token=secret",
|
||||
path: "/news?token=hidden",
|
||||
body: "never store",
|
||||
release: "old-client",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(r.status).toBe(202);
|
||||
const saved = mocks.append.mock.calls[0][0];
|
||||
expect(saved.source).toBe("browser");
|
||||
expect(saved.context.clientRelease).toBe("old-client");
|
||||
expect(JSON.stringify(saved)).not.toMatch(/hidden|never store|token=secret/);
|
||||
});
|
||||
it("reports storage failure rather than claiming receipt", async () => {
|
||||
mocks.limit.mockResolvedValue({ ok: true });
|
||||
mocks.append.mockRejectedValue(new Error("disk"));
|
||||
expect(
|
||||
(
|
||||
await POST(
|
||||
req(
|
||||
JSON.stringify({
|
||||
event: "browser.exception",
|
||||
message: "failure",
|
||||
path: "/",
|
||||
}),
|
||||
),
|
||||
)
|
||||
).status,
|
||||
).toBe(503);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { createErrorRecord, ErrorStore } from "@/lib/error-monitor";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const schema = z.object({
|
||||
event: z.enum([
|
||||
"browser.exception",
|
||||
"browser.boundary",
|
||||
"browser.unhandled_rejection",
|
||||
]),
|
||||
clientReference: z.string().uuid().optional(),
|
||||
message: z.string().max(1000),
|
||||
stack: z.string().max(6000).optional(),
|
||||
path: z.string().startsWith("/").max(1000),
|
||||
digest: z.string().max(200).optional(),
|
||||
release: z.string().max(100).optional(),
|
||||
});
|
||||
export async function POST(request: Request) {
|
||||
if (
|
||||
request.headers.get("origin") !==
|
||||
new URL(
|
||||
process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL || request.url,
|
||||
).origin ||
|
||||
!request.headers.get("content-type")?.startsWith("application/json")
|
||||
)
|
||||
return new Response(null, { status: 403 });
|
||||
const limit = await rateLimit(`cms-error:${await clientIp()}`, 20, 60000);
|
||||
if (!limit.ok) return new Response(null, { status: 429 });
|
||||
const reader = request.body?.getReader();
|
||||
if (!reader) return new Response(null, { status: 400 });
|
||||
const chunks: Uint8Array[] = [];
|
||||
let length = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
length += value.byteLength;
|
||||
if (length > 16000) {
|
||||
await reader.cancel();
|
||||
return new Response(null, { status: 413 });
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
const parsed = schema.safeParse(
|
||||
JSON.parse(Buffer.concat(chunks).toString("utf8")),
|
||||
);
|
||||
if (!parsed.success) return new Response(null, { status: 400 });
|
||||
const data = parsed.data;
|
||||
const error = new Error(data.message);
|
||||
error.stack = data.stack ?? "";
|
||||
const record = createErrorRecord("browser", data.event, error, {
|
||||
clientReference: data.clientReference ?? null,
|
||||
path: data.path.split("?")[0],
|
||||
digest: data.digest ?? null,
|
||||
clientRelease: data.release ?? "unknown",
|
||||
});
|
||||
await new ErrorStore().append(record);
|
||||
return NextResponse.json(
|
||||
{ id: record.id },
|
||||
{ status: 202, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
} catch {
|
||||
return new Response(null, { status: 503 });
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -2,9 +2,10 @@
|
||||
|
||||
import { Home, RefreshCw } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ErrorScreen } from "@/components/error-screen";
|
||||
import Link from "@/components/link";
|
||||
import { reportBrowserError } from "@/lib/browser-errors";
|
||||
|
||||
/**
|
||||
* Route-segment error boundary. Renders inside the root layout (so the shell
|
||||
@@ -19,8 +20,10 @@ export default function ErrorPage({
|
||||
}) {
|
||||
const t = useTranslations("pages.error");
|
||||
|
||||
const [reference, setReference] = useState(error.digest);
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
setReference(error.digest ?? reportBrowserError(error, "browser.boundary"));
|
||||
if (error.digest) reportBrowserError(error, "browser.boundary");
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
@@ -46,9 +49,9 @@ export default function ErrorPage({
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
error.digest ? (
|
||||
reference ? (
|
||||
<p className="error-screen-digest">
|
||||
{t("reference", { digest: error.digest })}
|
||||
{t("reference", { digest: reference })}
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { reportBrowserError } from "@/lib/browser-errors";
|
||||
|
||||
/**
|
||||
* Last-resort boundary for errors thrown in the root layout itself. It replaces
|
||||
@@ -14,8 +15,10 @@ export default function GlobalError({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const [reference, setReference] = useState(error.digest);
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
setReference(error.digest ?? reportBrowserError(error, "browser.boundary"));
|
||||
if (error.digest) reportBrowserError(error, "browser.boundary");
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
@@ -107,8 +110,8 @@ export default function GlobalError({
|
||||
<button type="button" className="ge-btn" onClick={() => reset()}>
|
||||
Try again
|
||||
</button>
|
||||
{error.digest ? (
|
||||
<p className="ge-digest">Reference: {error.digest}</p>
|
||||
{reference ? (
|
||||
<p className="ge-digest">Reference: {reference}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { ReactNode } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { PwaRegister } from "@/components/pwa-register";
|
||||
import { ScopedThemeVars } from "@/components/scoped-theme-vars";
|
||||
import { SmoothScroll } from "@/components/smooth-scroll";
|
||||
import { ThemeScopeDetector } from "@/components/theme-scope-detector";
|
||||
import { ThemeVars } from "@/components/theme-vars";
|
||||
import { enforceSiteAccess } from "@/lib/access-guard";
|
||||
@@ -102,7 +101,6 @@ export default async function RootLayout({
|
||||
<ThemeScopeDetector />
|
||||
{children}
|
||||
<PwaRegister />
|
||||
<SmoothScroll />
|
||||
<Toaster
|
||||
position="top-right"
|
||||
richColors
|
||||
|
||||
Reference in new issue
Block a user