feat(admin): reorganize housekeeping and strengthen shared workflows

This commit is contained in:
Simo committed 2026-09-06 17:17:30 +02:00
1 parent 35f66d879f
commit a070004e7b
35 files changed
+1667 -462

No files matched your search

+40 -41
View File
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { ErrorStore } from "@/lib/error-monitor";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
@@ -11,6 +12,7 @@ export default async function CmsErrorsPage({
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank))
redirect("/admin");
const t = await getTranslations("pages.admin.cmsErrors");
const params = await searchParams;
const query = (params.q ?? "").trim().slice(0, 200).toLowerCase();
let result: Awaited<ReturnType<ErrorStore["read"]>>;
@@ -19,14 +21,15 @@ export default async function CmsErrorsPage({
} catch {
return (
<div className="admin-card" role="alert">
Error storage could not be read. Check the CMS storage volume and
permissions.
{t("storageError")}
</div>
);
}
const filtered = result.records.filter(
(r) =>
(!params.source || r.source === params.source) &&
(params.status !== "open" || !r.resolved) &&
(params.status !== "resolved" || r.resolved) &&
(!query || JSON.stringify(r).toLowerCase().includes(query)),
);
const groups = new Map<string, typeof filtered>();
@@ -43,62 +46,62 @@ export default async function CmsErrorsPage({
),
);
const pageHref = (next: number) =>
`?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", page: String(next) })}`;
`?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", status: params.status ?? "", 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>
<h1 className="text-2xl font-semibold">{t("title")}</h1>
<p className="text-muted-foreground">{t("subtitle")}</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
{t("emulator")}
</Link>
<Link href="/admin/devops/cms-errors" className="btn btn-outline">
Refresh / clear filters
{t("refresh")}
</Link>
</div>
<form className="admin-card flex flex-wrap gap-3">
<input
aria-label="Search errors"
aria-label={t("search")}
name="q"
defaultValue={params.q}
placeholder="Reference, digest, message, route or release"
placeholder={t("searchHint")}
className="input min-w-0 flex-1"
/>
<select
name="source"
aria-label="Error source"
aria-label={t("source")}
defaultValue={params.source ?? ""}
className="input"
>
<option value="">All sources</option>
<option value="">{t("allSources")}</option>
<option value="server">Server</option>
<option value="browser">Browser</option>
</select>
<select
name="status"
aria-label={t("status")}
defaultValue={params.status ?? ""}
className="input"
>
<option value="">{t("allStatuses")}</option>
<option value="open">{t("open")}</option>
<option value="resolved">{t("resolved")}</option>
</select>
<button type="submit" className="btn btn-primary">
Search
{t("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.
{t("counts", { count: filtered.length, groups: groups.size })}{" "}
{result.truncated ? t("truncated") : ""} {t("retention")}
</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>
<div className="admin-card">{t("empty")}</div>
) : null}
{[...groups.entries()]
.slice((page - 1) * 25, page * 25)
@@ -108,25 +111,24 @@ export default async function CmsErrorsPage({
<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}
{t(r.resolved ? "resolved" : "open")} · {r.source} ·{" "}
{t("occurrences", { count: events.length })} · {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>
{t("reference")}: <code className="break-all">{r.id}</code>
</p>
<p>
Release: <code className="break-all">{r.release}</code>
{t("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."}
{r.stack || t("noStack")}
</pre>
{!r.resolved &&
canAccess(permissions, PERMS.DEVOPS_EDIT, session.user.rank) ? (
@@ -137,16 +139,16 @@ export default async function CmsErrorsPage({
value={r.fingerprint}
/>
<button type="submit" className="btn btn-outline">
Mark resolved
{t("resolve")}
</button>
<p className="text-muted-foreground text-xs">
A new occurrence reopens the group automatically.
{t("reopenHint")}
</p>
</form>
) : null}
<details>
<summary className="cursor-pointer">
Occurrence references
{t("references")}
</summary>
<ul>
{events.map((e) => (
@@ -156,23 +158,20 @@ export default async function CmsErrorsPage({
))}
</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>
<p className="text-muted-foreground">{t("stackHint")}</p>
</div>
</details>
);
})}
<nav aria-label="Error pages" className="flex gap-3">
<nav aria-label={t("pages")} className="flex gap-3">
{page > 1 ? (
<Link href={pageHref(page - 1)} className="btn btn-outline">
Previous
{t("previous")}
</Link>
) : null}
{page * 25 < groups.size ? (
<Link href={pageHref(page + 1)} className="btn btn-outline">
Next
{t("next")}
</Link>
) : null}
</nav>