Files
siftlode/frontend/src/lib/auditText.ts
T
peter 3021be5166 fix(activity): a run's quota reads with its unit, its outcome keeps room at a large text size, a token's audit rows read in Hungarian
The run list showed the quota spend as a bare figure and, at the largest text size on a 1024-wide
screen, its fixed columns left the outcome column no width at all. The outcome now keeps 10rem and
the trigger column gives way; the audit list's summary keeps the same floor. A run that stored no
log line says so instead of linking to an empty list. An API token revoke and a token's file work
rendered the server's English line under the Hungarian UI; both are rebuilt in the reader's
language, and the test holds the server's wording.
2026-10-18 04:22:52 +02:00

107 lines
4.5 KiB
TypeScript

import type { TFunction } from "i18next";
import type { AuditRow, JobResult } from "./api";
import { formatNumber } from "./format";
import { jobResultText } from "./jobResult";
/** How the server's summary says an API token did the work (`apitokens.FilesCaller.via`, held by
* the test): the token's prefix follows. */
const VIA_TOKEN = "api token ";
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 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 === "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 };
if (row.action === "api_token_revoke") return { head: null, sub: t("audit.tokenRevoked") };
if (row.summary?.startsWith(VIA_TOKEN))
return { head: t("audit.viaToken", { token: row.summary.slice(VIA_TOKEN.length) }), 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;
}
/** An audit row in the reader's language: `head` names what changed (a config field, a job; else
* the server's summary), `sub` the change itself (before → after, a count). Either may be null. */
export function auditText(
t: TFunction,
row: AuditRow,
lang: string,
): { head: string | null; sub: string | null } {
const local = localDetails(t, row, lang);
return local ?? { head: row.summary, sub: changeText(row) };
}
export function auditActionLabel(t: TFunction, action: string): string {
// Canonical key → localized label; falls back to the raw key for any unmapped action.
return t(`auditActions.${action}`, action);
}