feat(audit): job runs and config changes read in the reader's language (E30 S5)
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.
This commit is contained in:
@@ -33,7 +33,7 @@ export default function AuditLog() {
|
|||||||
.map((a) => ({ value: a, label: t(`auditActions.${a}`, a) }))
|
.map((a) => ({ value: a, label: t(`auditActions.${a}`, a) }))
|
||||||
.sort((a, b) => compareText(a.label, b.label, lang));
|
.sort((a, b) => compareText(a.label, b.label, lang));
|
||||||
}, [rows, t, 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({
|
const clear = useMutation({
|
||||||
mutationFn: () => api.clearAudit(),
|
mutationFn: () => api.clearAudit(),
|
||||||
|
|||||||
@@ -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>): 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import type { AuditRow } from "../lib/api";
|
import type { AuditRow } from "../lib/api";
|
||||||
|
import { formatNumber } from "../lib/format";
|
||||||
|
import { jobResultText, parseSummary } from "../lib/jobResult";
|
||||||
import type { Column } from "./ui/DataTable";
|
import type { Column } from "./ui/DataTable";
|
||||||
import { RelativeTime } from "./ui/RelativeTime";
|
import { RelativeTime } from "./ui/RelativeTime";
|
||||||
|
|
||||||
@@ -12,6 +14,47 @@ function fmt(v: unknown): string {
|
|||||||
return v == null || v === "" ? "—" : String(v);
|
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". */
|
/** A compact before→after (or after-only) rendering of the JSON snapshots, e.g. "role: user → admin". */
|
||||||
function changeText(row: AuditRow): string | null {
|
function changeText(row: AuditRow): string | null {
|
||||||
const { before, after } = row;
|
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. */
|
* the data (built by the page) so the Action column's select filter only offers real values. */
|
||||||
export function auditColumns(
|
export function auditColumns(
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
|
lang: string,
|
||||||
actionOptions: { value: string; label: string }[],
|
actionOptions: { value: string; label: string }[],
|
||||||
): Column<AuditRow>[] {
|
): Column<AuditRow>[] {
|
||||||
return [
|
return [
|
||||||
@@ -72,13 +116,15 @@ export function auditColumns(
|
|||||||
key: "details",
|
key: "details",
|
||||||
header: t("audit.cols.details"),
|
header: t("audit.cols.details"),
|
||||||
render: (r) => {
|
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 (
|
return (
|
||||||
// wrap-break-word (wrap), NOT truncate: in the auto-layout table a `truncate` (nowrap) cell
|
// 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
|
// 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.
|
// card (the row rules bled into the page). Wrapping keeps every row inside the container.
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{r.summary && <div className="wrap-break-word">{r.summary}</div>}
|
{summary && <div className="wrap-break-word">{summary}</div>}
|
||||||
{change && <div className="text-2xs text-muted wrap-break-word">{change}</div>}
|
{change && <div className="text-2xs text-muted wrap-break-word">{change}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
"cleared": "Cleared {{count}} audit entries",
|
"cleared": "Cleared {{count}} audit entries",
|
||||||
"clearFailed": "Couldn't clear the audit log",
|
"clearFailed": "Couldn't clear the audit log",
|
||||||
"truncated": "Showing the {{returned}} most recent of {{total}} entries.",
|
"truncated": "Showing the {{returned}} most recent of {{total}} entries.",
|
||||||
|
"on": "On",
|
||||||
|
"off": "Off",
|
||||||
|
"default": "default",
|
||||||
|
"secretUpdated": "secret updated (the value is never logged)",
|
||||||
"cols": {
|
"cols": {
|
||||||
"when": "When",
|
"when": "When",
|
||||||
"actor": "Who",
|
"actor": "Who",
|
||||||
|
|||||||
@@ -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.",
|
"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.",
|
"empty": "Még nincs naplóbejegyzés.",
|
||||||
"system": "Rendszer",
|
"system": "Rendszer",
|
||||||
@@ -9,6 +9,10 @@
|
|||||||
"cleared": "{{count}} naplóbejegyzés törölve",
|
"cleared": "{{count}} naplóbejegyzés törölve",
|
||||||
"clearFailed": "Nem sikerült üríteni a naplót",
|
"clearFailed": "Nem sikerült üríteni a naplót",
|
||||||
"truncated": "A legutóbbi {{returned}} bejegyzés látszik a(z) {{total}}-ból.",
|
"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": {
|
"cols": {
|
||||||
"when": "Mikor",
|
"when": "Mikor",
|
||||||
"actor": "Ki",
|
"actor": "Ki",
|
||||||
|
|||||||
@@ -55,12 +55,13 @@ describe("an older notice's one-liner", () => {
|
|||||||
enriched: 0,
|
enriched: 0,
|
||||||
skipped: "tmdb_api_key not set",
|
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", () => {
|
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("recent={'channels_processed': 0}, deep={'videos_added': 1}")).toBeNull();
|
||||||
expect(parseSummary("sync paused")).toBeNull();
|
expect(parseSummary("sync paused")).toBeNull();
|
||||||
expect(parseSummary("42")).toBeNull();
|
|
||||||
expect(parseSummary(null)).toBeNull();
|
expect(parseSummary(null)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import type { JobResult } from "./api";
|
|||||||
import { formatNumber } from "./format";
|
import { formatNumber } from "./format";
|
||||||
import { vocabKey } from "./vocab";
|
import { vocabKey } from "./vocab";
|
||||||
|
|
||||||
/** A notice from before the result rode along as data holds only the server's one-liner: `_summarize`'s
|
/** A notice from before the result rode along as data — and every audit row — holds only the server's
|
||||||
* "new=3, failed=0" (backend/app/scheduler.py). A flat one reads back into a result; a nested phase
|
* one-liner: `_summarize`'s "new=3, failed=0" (backend/app/scheduler.py). A flat one reads back into a
|
||||||
* (a Python dict repr) or anything else does not, and stays as it was. */
|
* 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 {
|
export function parseSummary(summary: string | null | undefined): JobResult | null {
|
||||||
if (!summary || /[{}[\]]/.test(summary)) return null;
|
if (!summary || /[{}[\]]/.test(summary)) return null;
|
||||||
|
if (/^\d+$/.test(summary)) return { enriched: Number(summary) };
|
||||||
const out: JobResult = {};
|
const out: JobResult = {};
|
||||||
for (const part of summary.split(", ")) {
|
for (const part of summary.split(", ")) {
|
||||||
const m = /^(\w+)=(.+)$/.exec(part);
|
const m = /^(\w+)=(.+)$/.exec(part);
|
||||||
|
|||||||
Reference in New Issue
Block a user