feat(cms): improve catalog, editorial recovery and operations

This commit is contained in:
Simo committed 2026-09-09 19:36:15 +02:00
1 parent 2fead134e6
commit c389c3893d
122 files changed
+8137 -703

No files matched your search

+154 -14
View File
@@ -1,21 +1,35 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { summarizeErrorGroup } from "@/lib/error-groups";
import { ErrorStore } from "@/lib/error-monitor";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { resolveCmsError } from "./actions";
import { assignCmsError, 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 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"]>>;
try {
result = await new ErrorStore().read();
} catch {
@@ -25,28 +39,48 @@ export default async function CmsErrorsPage({
</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>();
for (const r of filtered) {
const list = groups.get(r.fingerprint) ?? [];
list.push(r);
groups.set(r.fingerprint, list);
const allGroups = new Map<string, typeof result.records>();
for (const record of result.records) {
const events = allGroups.get(record.fingerprint) ?? [];
events.push(record);
allGroups.set(record.fingerprint, events);
}
const groups = new Map(
[...allGroups.entries()].filter(([, events]) => {
const latest = summarizeErrorGroup(events).latest;
return (
latest &&
(!params.source || latest.source === params.source) &&
(params.status !== "open" || !latest.resolved) &&
(params.status !== "resolved" || latest.resolved) &&
(!query ||
events.some((record) =>
JSON.stringify(record).toLowerCase().includes(query),
))
);
}),
);
const filtered = [...groups.values()].flat();
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 ?? "", status: params.status ?? "", page: String(next) })}`;
return (
<div className="space-y-5">
<div>
@@ -104,9 +138,16 @@ export default async function CmsErrorsPage({
<div className="admin-card">{t("empty")}</div>
) : null}
{[...groups.entries()]
.slice((page - 1) * 25, page * 25)
.map(([fingerprint, events]) => {
const r = events[0];
const metrics = summarizeErrorGroup(events);
const r = metrics.latest;
if (!r) return null;
return (
<details key={fingerprint} className="admin-card overflow-hidden">
<summary className="cursor-pointer">
@@ -118,6 +159,105 @@ export default async function CmsErrorsPage({
<p className="text-sm break-words">{r.message}</p>
</summary>
<div className="mt-4 space-y-3 text-sm">
<dl className="grid gap-2 sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">{t("firstSeen")}</dt>
<dd>
<time dateTime={metrics.firstAt ?? ""}>
{metrics.firstAt}
</time>
</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("lastSeen")}</dt>
<dd>
<time dateTime={metrics.lastAt ?? ""}>
{metrics.lastAt}
</time>
</dd>
</div>
<div>
<dt className="text-muted-foreground">
{t("affectedUsers")}
</dt>
<dd>{metrics.affectedUsers}</dd>
</div>
<div>
<dt className="text-muted-foreground">
{t("unidentifiedEvents")}
</dt>
<dd>{metrics.unidentified}</dd>
</div>
</dl>
<p className="text-xs text-muted-foreground">
{t("metricsScope")}
</p>
<p>
{r.assignment
? t("assignedStaff", { id: r.assignment.userId })
: t("unassigned")}
</p>
{canAccess(
permissions,
PERMS.DEVOPS_EDIT,
session.user.rank,
) && (
<form
action={assignCmsError}
className="flex flex-wrap gap-2"
>
<input
type="hidden"
name="fingerprint"
value={fingerprint}
/>
{r.assignment?.userId !== session.user.id && (
<button
type="submit"
name="assignment"
value="self"
className="btn btn-outline"
>
{t("assignSelf")}
</button>
)}
{r.assignment && (
<button
type="submit"
name="assignment"
value="clear"
className="btn btn-outline"
>
{t("clearAssignment")}
</button>
)}
</form>
)}
{metrics.operationLink && (
<Link
href={metrics.operationLink}
className="btn btn-outline"
>
{t("openOperation")}
</Link>
)}
<details>
<summary className="cursor-pointer">
{t("releaseOccurrences")}
</summary>
<ul className="mt-2 space-y-2">
{metrics.releases.map((release) => (
<li key={release.release} className="break-all">
<code>{release.release}</code> ·{" "}
{t("occurrences", { count: release.count })} ·{" "}
{release.firstAt} — {release.lastAt}
</li>
))}
</ul>
<p className="text-xs text-muted-foreground">
{t("releaseScope")}
</p>
</details>
<p>
{t("reference")}: <code className="break-all">{r.id}</code>
</p>