The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
108 lines
4.0 KiB
TypeScript
108 lines
4.0 KiB
TypeScript
import { useMemo } from "react";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Trash2 } from "lucide-react";
|
|
import { api } from "../lib/api";
|
|
import { notify } from "../lib/notifications";
|
|
import { LS } from "../lib/storage";
|
|
import { Button } from "./ui/Button";
|
|
import { Section } from "./ui/form";
|
|
import { useConfirm } from "./confirmContext";
|
|
import DataTable from "./ui/DataTable";
|
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
|
import { auditColumns } from "./auditColumns";
|
|
import { notifyActionError } from "../lib/actionErrors";
|
|
import { compareText } from "../lib/collate";
|
|
import { foldJobRuns } from "../lib/auditGroups";
|
|
|
|
// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run
|
|
// summaries (never per-row). Read-only apart from Clear. Client-side sort/filter/paginate via
|
|
// DataTable; the API returns the most recent window (capped) — the log stays small by design.
|
|
export default function AuditLog() {
|
|
const { t, i18n } = useTranslation();
|
|
const lang = i18n.language;
|
|
const qc = useQueryClient();
|
|
const confirm = useConfirm();
|
|
const q = useQuery({ queryKey: qk.adminAudit(), queryFn: () => api.adminAudit() });
|
|
const data = q.data;
|
|
// Back-to-back successful runs of one job fold into one row (lib/auditGroups).
|
|
const rows = useMemo(() => foldJobRuns(data?.items ?? []), [data]);
|
|
|
|
const actionOptions = useMemo(() => {
|
|
// By what the reader sees — the named action, in their alphabet — not by the code.
|
|
return Array.from(new Set(rows.map((r) => r.action)))
|
|
.map((a) => ({ value: a, label: t(`auditActions.${a}`, a) }))
|
|
.sort((a, b) => compareText(a.label, b.label, lang));
|
|
}, [rows, t, lang]);
|
|
const columns = useMemo(() => auditColumns(t, lang, actionOptions), [t, lang, actionOptions]);
|
|
|
|
const clear = useMutation({
|
|
mutationFn: () => api.clearAudit(),
|
|
onSuccess: (r) => {
|
|
qc.invalidateQueries({ queryKey: qk.adminAudit() });
|
|
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
|
|
},
|
|
onError: (e) => notifyActionError(e, t("audit.clearFailed")),
|
|
});
|
|
const onClear = async () => {
|
|
if (
|
|
await confirm({
|
|
title: t("audit.clearTitle"),
|
|
message: t("audit.clearConfirm"),
|
|
confirmLabel: t("audit.clear"),
|
|
danger: true,
|
|
})
|
|
)
|
|
clear.mutate();
|
|
};
|
|
|
|
const truncated = !!data && data.total > data.returned;
|
|
|
|
return (
|
|
<div className="p-4 max-w-5xl w-full mx-auto">
|
|
<Section card title={t("audit.title")}>
|
|
<p className="text-xs text-muted leading-relaxed mb-3">{t("audit.intro")}</p>
|
|
{q.isError && !q.data ? (
|
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
|
{t("common.loadError")}
|
|
</StateMessage>
|
|
) : q.isLoading ? (
|
|
<LoadingState shape="table" />
|
|
) : rows.length === 0 ? (
|
|
<p className="text-sm text-muted">{t("audit.empty")}</p>
|
|
) : (
|
|
<>
|
|
<DataTable
|
|
rows={rows}
|
|
columns={columns}
|
|
rowKey={(r) => String(r.id)}
|
|
persistKey={LS.auditTable}
|
|
controlsPosition="top"
|
|
controlsInBand
|
|
controlsBandClassName="px-4 max-w-5xl mx-auto"
|
|
controlsLeading={
|
|
<Button
|
|
variant="outline-danger"
|
|
size="xs"
|
|
onClick={onClear}
|
|
disabled={clear.isPending}
|
|
icon={<Trash2 className="w-3.5 h-3.5" />}
|
|
>
|
|
{t("audit.clear")}
|
|
</Button>
|
|
}
|
|
emptyText={t("audit.empty")}
|
|
/>
|
|
{truncated && (
|
|
<p className="text-2xs text-muted mt-2">
|
|
{t("audit.truncated", { returned: data!.returned, total: data!.total })}
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</Section>
|
|
</div>
|
|
);
|
|
}
|