Files
siftlode/frontend/src/components/auditColumns.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
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.
2026-10-05 00:19:39 +02:00

172 lines
6.9 KiB
TypeScript

import type { TFunction } from "i18next";
import clsx from "clsx";
import type { AuditRow } from "../lib/api";
import type { AuditListRow } from "../lib/auditGroups";
import AuditEarlierRuns from "./AuditEarlierRuns";
import { formatNumber } from "../lib/format";
import type { JobResult } from "../lib/api";
import { jobResultText, jobRunText } from "../lib/jobResult";
import type { Column } from "./ui/DataTable";
import { RelativeTime } from "./ui/RelativeTime";
function actionLabel(t: TFunction, action: string): string {
// Canonical key → localized label; falls back to the raw key for any unmapped action.
return t(`auditActions.${action}`, action);
}
function fmt(v: unknown): string {
return v == null || v === "" ? "—" : String(v);
}
/** A logged config value in the reader's language: a switch reads on/off, a count is grouped, a
* choice is named as the Configuration page names it. */
function configValue(t: TFunction, key: string, v: unknown, lang: string): string {
if (typeof v === "boolean") return t(v ? "audit.on" : "audit.off");
if (typeof v === "number") return formatNumber(v, lang);
if (typeof v === "string" && v) return t(`config.choices.${key}.${v}`, { defaultValue: v });
return fmt(v);
}
/** A row's `after` as a job-style result (counts), or null when it holds none. */
function counts(after: Record<string, unknown>): JobResult | null {
const out: JobResult = {};
for (const [k, v] of Object.entries(after)) if (typeof v === "number") out[k] = v;
return Object.keys(out).length ? out : null;
}
/** The details of the rows whose server `summary` is a fixed English line, rebuilt from the row's
* data in the reader's language: a job run names the job and its result (as the inbox does), a config
* change names the field and its values, an interval or batch change its new value, a count-bearing
* action its counts; a pause/resume says nothing the action has not. `undefined` = not one of these (or
* an older row without the data); the generic summary + change rendering applies. */
function localDetails(
t: TFunction,
row: AuditRow,
lang: string,
): { head: string | null; sub: string | null } | undefined {
const after = row.after ?? {};
const before = row.before ?? {};
if (row.action === "job_run" && row.target_type === "job" && row.target_id) {
const run = jobRunText(t, { job_id: row.target_id, ...after }, lang);
return { head: run.title, sub: run.body };
}
if ((row.action === "config_set" || row.action === "config_reset") && row.target_id) {
const key = row.target_id;
const head = t(`config.fields.${key}.label`, key);
const reset = row.action === "config_reset";
// A secret's value is never logged: there is no before/after to show.
if (!row.before && !row.after)
return { head, sub: t(reset ? "audit.secretReset" : "audit.secretUpdated") };
const to = reset ? t("audit.default") : configValue(t, key, after.value, lang);
return { head, sub: `${configValue(t, key, before.value, lang)} → ${to}` };
}
if (
row.action === "scheduler_interval" &&
row.target_id &&
typeof after.interval_minutes === "number"
)
return {
head: t(`scheduler.jobs.${row.target_id}`, row.target_id),
sub: t("scheduler.everyMin", { count: after.interval_minutes }),
};
if (row.action === "maintenance_tune" && typeof after.revalidate_batch === "number")
return {
head: t("scheduler.maintenance.batchLabel"),
sub: formatNumber(after.revalidate_batch, lang),
};
if (row.action === "audit_clear" || row.action === "discovery_purge") {
const result = counts(after);
return result ? { head: null, sub: jobResultText(t, result, lang) } : undefined;
}
if (row.action === "sync_pause" || row.action === "sync_resume") return { head: null, sub: null };
return undefined;
}
/** A compact before→after (or after-only) rendering of the JSON snapshots, e.g. "role: user → admin". */
function changeText(row: AuditRow): string | null {
const { before, after } = row;
if (before && after) {
return Object.keys(after)
.map((k) => `${k}: ${fmt(before[k])} → ${fmt(after[k])}`)
.join(", ");
}
if (after) {
return Object.entries(after)
.map(([k, v]) => `${k}: ${fmt(v)}`)
.join(", ");
}
return null;
}
/** Columns for the admin audit-log DataTable. `actionOptions` are the distinct actions present in
* the data (built by the page) so the Action column's select filter only offers real values. */
export function auditColumns(
t: TFunction,
lang: string,
actionOptions: { value: string; label: string }[],
): Column<AuditListRow>[] {
return [
{
key: "when",
header: t("audit.cols.when"),
nowrap: true,
sortable: true,
sortValue: (r) => r.created_at ?? "",
cardPrimary: true,
render: (r) => (
<RelativeTime at={r.created_at} seconds empty="—" className="text-muted tabular-nums" />
),
},
{
key: "actor",
header: t("audit.cols.actor"),
sortable: true,
sortValue: (r) => r.actor ?? "",
filter: { kind: "text", get: (r) => r.actor ?? t("audit.system") },
render: (r) =>
r.actor ? (
<span className="truncate">{r.actor}</span>
) : (
<span className="text-muted italic">{t("audit.system")}</span>
),
},
{
key: "action",
header: t("audit.cols.action"),
nowrap: true,
sortable: true,
sortValue: (r) => actionLabel(t, r.action),
filter: { kind: "select", options: actionOptions, test: (r, v) => r.action === v },
render: (r) => <span className="font-medium">{actionLabel(t, r.action)}</span>,
},
{
key: "details",
header: t("audit.cols.details"),
render: (r) => {
const local = localDetails(t, r, lang);
const summary = local ? local.head : r.summary;
const change = local ? local.sub : changeText(r);
// A failed run is the row to see in a page of routine ones: its line in the danger colour.
const failed = r.action === "job_run" && r.after?.status === "error";
return (
// wrap-break-word (wrap), NOT truncate: in the auto-layout table a `truncate` (nowrap) cell
// sizes to its longest row, so one long summary/change stretched the whole table past the
// card (the row rules bled into the page). Wrapping keeps every row inside the container.
<div className="min-w-0">
{summary && (
<div className={clsx("wrap-break-word", failed && "text-danger")}>{summary}</div>
)}
{change && <div className="text-2xs text-muted wrap-break-word">{change}</div>}
{r.runs && r.runs.length > 1 && (
<AuditEarlierRuns
runs={r.runs}
detailOf={(run) => localDetails(t, run, lang)?.sub ?? run.summary}
/>
)}
</div>
);
},
},
];
}