feat(admin): add production error center and refresh CMS dependencies

This commit is contained in:
Simo committed 2026-09-05 19:53:09 +02:00
1 parent c6b919c01d
commit 816e3875c2
47 files changed
+1545 -547

No files matched your search

@@ -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();
});
+181
View File
@@ -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>
);
}
+3
View File
@@ -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>
+3
View File
@@ -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>
+7 -4
View File
@@ -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>
+7 -4
View File
@@ -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}>