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): 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 (E30), 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[] { return [ { key: "when", header: t("audit.cols.when"), nowrap: true, sortable: true, sortValue: (r) => r.created_at ?? "", cardPrimary: true, render: (r) => ( ), }, { 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 ? ( {r.actor} ) : ( {t("audit.system")} ), }, { 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) => {actionLabel(t, r.action)}, }, { 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 (E31): 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.
{summary && (
{summary}
)} {change &&
{change}
} {r.runs && r.runs.length > 1 && ( localDetails(t, run, lang)?.sub ?? run.summary} /> )}
); }, }, ]; }