feat(cms): improve catalog, editorial recovery and operations
This commit is contained in:
1 parent
2fead134e6
commit
c389c3893d
122 files changed
+8137
-703
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user