From 2819049133bc6b539f7c0fa836eb428bec33013a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 30 Sep 2026 01:08:29 +0200 Subject: [PATCH] feat(audit): job runs and config changes read in the reader's language (E30 S5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit log showed the server's fixed English lines ("rss_poll: ok — new=5, failed=0", "log_level = DEBUG", "Background sync paused") to a Hungarian reader. The details are rebuilt from the row's data on the client: a job run names the job and its result as the inbox does, a config change names the field and its values (on/off, grouped counts, "default"), a pause/resume adds nothing the action does not say. A failed run keeps its error text (diagnostic). parseSummary reads enrich's bare count ("50") back as `enriched`, as the server's `_result_data` names it — the audit and old notices keep only the one-liner. No backend change; old rows render too. --- frontend/src/components/AuditLog.tsx | 2 +- .../src/components/auditColumns.dom.test.tsx | 94 +++++++++++++++++++ frontend/src/components/auditColumns.tsx | 50 +++++++++- frontend/src/i18n/locales/en/audit.json | 4 + frontend/src/i18n/locales/hu/audit.json | 6 +- frontend/src/lib/jobResult.dom.test.ts | 3 +- frontend/src/lib/jobResult.ts | 8 +- 7 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/auditColumns.dom.test.tsx diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx index fda79334..b034905b 100644 --- a/frontend/src/components/AuditLog.tsx +++ b/frontend/src/components/AuditLog.tsx @@ -33,7 +33,7 @@ export default function AuditLog() { .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, actionOptions), [t, actionOptions]); + const columns = useMemo(() => auditColumns(t, lang, actionOptions), [t, lang, actionOptions]); const clear = useMutation({ mutationFn: () => api.clearAudit(), diff --git a/frontend/src/components/auditColumns.dom.test.tsx b/frontend/src/components/auditColumns.dom.test.tsx new file mode 100644 index 00000000..e9d4bde6 --- /dev/null +++ b/frontend/src/components/auditColumns.dom.test.tsx @@ -0,0 +1,94 @@ +// @vitest-environment jsdom +// +// E30: an audit row whose server summary is a fixed English line ("rss_poll: ok — new=5, failed=0", +// "log_level = DEBUG", "Background sync paused") reads in the reader's language, rebuilt from the row's +// data through the REAL catalog. A row whose summary is data (an e-mail, a URL) keeps it. +import { afterEach, describe, expect, it } from "vitest"; +import { render } from "@testing-library/react"; +import i18n, { setLanguage } from "../i18n"; +import type { AuditRow } from "../lib/api"; +import { auditColumns } from "./auditColumns"; + +afterEach(() => setLanguage("en")); + +const row = (over: Partial): AuditRow => ({ + id: 1, + created_at: null, + actor: null, + action: "job_run", + target_type: null, + target_id: null, + summary: null, + before: null, + after: null, + ...over, +}); + +function details(r: AuditRow): string { + setLanguage("hu"); + const t = i18n.getFixedT("hu"); + const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!; + const { container } = render(<>{col.render!(r)}); + return [...container.querySelectorAll(".wrap-break-word")].map((e) => e.textContent).join(" | "); +} + +describe("an audit row's details", () => { + it("name a job run and its result in the reader's language", () => { + const r = row({ + target_type: "job", + target_id: "rss_poll", + summary: "rss_poll: ok — new=5, failed=0", + after: { status: "ok", summary: "new=5, failed=0" }, + }); + expect(details(r)).toBe("RSS-lekérdezés (új feltöltések) kész | új videó: 5 · sikertelen: 0"); + }); + + it("keep a failed run's error text, which is diagnostic", () => { + const r = row({ + target_type: "job", + target_id: "enrich", + after: { status: "error", summary: "HTTP 403: quotaExceeded" }, + }); + expect(details(r)).toBe( + "Nem sikerült lefuttatni: Adatdúsítás + élő frissítés | HTTP 403: quotaExceeded", + ); + }); + + it("name a config change's field and values", () => { + const set = row({ + action: "config_set", + target_type: "config", + target_id: "plex_enabled", + summary: "plex_enabled = True", + before: { value: false }, + after: { value: true }, + }); + expect(details(set)).toMatch(/ \| Ki → Be$/); + expect(details(set)).not.toContain("plex_enabled"); + const reset = row({ + action: "config_reset", + target_type: "config", + target_id: "download_upload_max_bytes", + summary: "download_upload_max_bytes → default", + before: { value: 5242880 }, + }); + expect(details(reset)).toMatch(/ \| 5\s242\s880 → alapérték$/); + const secret = row({ + action: "config_set", + target_type: "config", + target_id: "youtube_api_key", + summary: "youtube_api_key = (secret updated)", + }); + expect(details(secret)).toMatch(/ \| titok frissítve/); + }); + + it("add nothing to a pause, whose action already says it", () => { + expect(details(row({ action: "sync_pause", summary: "Background sync paused" }))).toBe(""); + }); + + it("keep a summary that is data", () => { + expect(details(row({ action: "invite_add", summary: "someone@example.com" }))).toBe( + "someone@example.com", + ); + }); +}); diff --git a/frontend/src/components/auditColumns.tsx b/frontend/src/components/auditColumns.tsx index be92a2da..8fc643e9 100644 --- a/frontend/src/components/auditColumns.tsx +++ b/frontend/src/components/auditColumns.tsx @@ -1,5 +1,7 @@ import type { TFunction } from "i18next"; import type { AuditRow } from "../lib/api"; +import { formatNumber } from "../lib/format"; +import { jobResultText, parseSummary } from "../lib/jobResult"; import type { Column } from "./ui/DataTable"; import { RelativeTime } from "./ui/RelativeTime"; @@ -12,6 +14,47 @@ 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. */ +function configValue(t: TFunction, v: unknown, lang: string): string { + if (typeof v === "boolean") return t(v ? "audit.on" : "audit.off"); + if (typeof v === "number") return formatNumber(v, lang); + return fmt(v); +} + +/** 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, a pause/resume says nothing the action has not. `undefined` = + * not one of these; 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 job = t(`scheduler.jobs.${row.target_id}`, row.target_id); + const failed = after.status === "error"; + const summary = typeof after.summary === "string" ? after.summary : null; + // A nested phase (a Python dict repr) or an error text does not parse, and shows as it was. + const result = failed ? null : parseSummary(summary); + return { + head: t(failed ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", { job }), + sub: result ? jobResultText(t, result, lang) : summary, + }; + } + if ((row.action === "config_set" || row.action === "config_reset") && row.target_id) { + const head = t(`config.fields.${row.target_id}.label`, row.target_id); + // A secret's value is never logged: there is no before/after to show. + if (!row.before && !row.after) return { head, sub: t("audit.secretUpdated") }; + const to = + row.action === "config_reset" ? t("audit.default") : configValue(t, after.value, lang); + return { head, sub: `${configValue(t, before.value, lang)} → ${to}` }; + } + 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; @@ -32,6 +75,7 @@ function changeText(row: AuditRow): string | null { * 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 [ @@ -72,13 +116,15 @@ export function auditColumns( key: "details", header: t("audit.cols.details"), render: (r) => { - const change = changeText(r); + const local = localDetails(t, r, lang); + const summary = local ? local.head : r.summary; + const change = local ? local.sub : changeText(r); 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.
- {r.summary &&
{r.summary}
} + {summary &&
{summary}
} {change &&
{change}
}
); diff --git a/frontend/src/i18n/locales/en/audit.json b/frontend/src/i18n/locales/en/audit.json index 1d599c19..ca9c367d 100644 --- a/frontend/src/i18n/locales/en/audit.json +++ b/frontend/src/i18n/locales/en/audit.json @@ -9,6 +9,10 @@ "cleared": "Cleared {{count}} audit entries", "clearFailed": "Couldn't clear the audit log", "truncated": "Showing the {{returned}} most recent of {{total}} entries.", + "on": "On", + "off": "Off", + "default": "default", + "secretUpdated": "secret updated (the value is never logged)", "cols": { "when": "When", "actor": "Who", diff --git a/frontend/src/i18n/locales/hu/audit.json b/frontend/src/i18n/locales/hu/audit.json index 18d00352..7785dd19 100644 --- a/frontend/src/i18n/locales/hu/audit.json +++ b/frontend/src/i18n/locales/hu/audit.json @@ -1,5 +1,5 @@ { - "title": "Auditnapló", + "title": "Eseménynapló", "intro": "Az admin-műveletek és az ütemező futás-összegzéseinek csak-hozzáfűző naplója — ki, mit, mikor. Művelet-szinten naplózva, soha nem elemenként.", "empty": "Még nincs naplóbejegyzés.", "system": "Rendszer", @@ -9,6 +9,10 @@ "cleared": "{{count}} naplóbejegyzés törölve", "clearFailed": "Nem sikerült üríteni a naplót", "truncated": "A legutóbbi {{returned}} bejegyzés látszik a(z) {{total}}-ból.", + "on": "Be", + "off": "Ki", + "default": "alapérték", + "secretUpdated": "titok frissítve (az értéke sosem kerül a naplóba)", "cols": { "when": "Mikor", "actor": "Ki", diff --git a/frontend/src/lib/jobResult.dom.test.ts b/frontend/src/lib/jobResult.dom.test.ts index 2ac733b3..948c6967 100644 --- a/frontend/src/lib/jobResult.dom.test.ts +++ b/frontend/src/lib/jobResult.dom.test.ts @@ -55,12 +55,13 @@ describe("an older notice's one-liner", () => { enriched: 0, skipped: "tmdb_api_key not set", }); + // enrich's bare count, as the server's `_result_data` names it + expect(parseSummary("42")).toEqual({ enriched: 42 }); }); it("does not when a phase is nested, or it is no summary at all", () => { expect(parseSummary("recent={'channels_processed': 0}, deep={'videos_added': 1}")).toBeNull(); expect(parseSummary("sync paused")).toBeNull(); - expect(parseSummary("42")).toBeNull(); expect(parseSummary(null)).toBeNull(); }); }); diff --git a/frontend/src/lib/jobResult.ts b/frontend/src/lib/jobResult.ts index f06959b8..0854e8c4 100644 --- a/frontend/src/lib/jobResult.ts +++ b/frontend/src/lib/jobResult.ts @@ -3,11 +3,13 @@ import type { JobResult } from "./api"; import { formatNumber } from "./format"; import { vocabKey } from "./vocab"; -/** A notice from before the result rode along as data holds only the server's one-liner: `_summarize`'s - * "new=3, failed=0" (backend/app/scheduler.py). A flat one reads back into a result; a nested phase - * (a Python dict repr) or anything else does not, and stays as it was. */ +/** A notice from before the result rode along as data — and every audit row — holds only the server's + * one-liner: `_summarize`'s "new=3, failed=0" (backend/app/scheduler.py). A flat one reads back into a + * result, and `enrich`'s bare count ("50") into `{enriched: 50}` as `_result_data` names it; a nested + * phase (a Python dict repr) or anything else does not, and stays as it was. */ export function parseSummary(summary: string | null | undefined): JobResult | null { if (!summary || /[{}[\]]/.test(summary)) return null; + if (/^\d+$/.test(summary)) return { enriched: Number(summary) }; const out: JobResult = {}; for (const part of summary.split(", ")) { const m = /^(\w+)=(.+)$/.exec(part);