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.
This commit is contained in:
2026-10-18 04:22:52 +02:00
parent c288ba147b
commit 3021be5166
9 changed files with 91 additions and 7 deletions
@@ -214,6 +214,37 @@ describe("Activity", () => {
expect(cells("timed")).not.toContain("—"); expect(cells("timed")).not.toContain("—");
}); });
// A bare "212" in a row reads as nothing in particular: the spend carries its unit, and a run that
// spent none says nothing rather than "0".
it("a run's quota spend reads with its unit, and none spent leaves the cell blank", async () => {
window.history.replaceState(null, "", "/?page=activity");
runsMock.mockResolvedValue(
page([
{ ...RUN, id: 1, status: "ok", error: null, summary: "spent" },
{ ...RUN, id: 2, status: "ok", error: null, summary: "free", quota_units: 0 },
]),
);
renderPage();
await waitFor(() => expect(screen.getByText("spent")).toBeTruthy());
const cells = (text: string) =>
[...rowButton(new RegExp(text)).children].map((c) => c.textContent);
expect(cells("spent")).toContain("activity.runs.units");
expect(cells("spent")).not.toContain("212");
expect(cells("free")).not.toContain("0");
expect(cells("free")).not.toContain("activity.runs.units");
});
// Most runs store no line at all: a link there would only lead to an empty list.
it("a run that logged nothing says so instead of linking to an empty list", async () => {
window.history.replaceState(null, "", "/?page=activity");
runsMock.mockResolvedValue(page([{ ...RUN, log_lines: 0 }]));
renderPage();
await waitFor(() => expect(screen.getByText("quotaExceeded")).toBeTruthy());
fireEvent.click(rowButton(/quotaExceeded/));
expect(screen.getByText("activity.runs.noLogLines")).toBeTruthy();
expect(screen.queryByRole("button", { name: "activity.runs.logLines" })).toBeNull();
});
it("a failed read is an error with a retry, not an empty list", async () => { it("a failed read is an error with a retry, not an empty list", async () => {
window.history.replaceState(null, "", "/?page=activity"); window.history.replaceState(null, "", "/?page=activity");
runsMock.mockRejectedValue(new Error("502")); runsMock.mockRejectedValue(new Error("502"));
+1 -1
View File
@@ -24,7 +24,7 @@ import FilterSelect from "./ui/FilterSelect";
// leaving one row that says who cleared it. // leaving one row that says who cleared it.
const GRID = const GRID =
"grid-cols-[minmax(0,1fr)_auto] sm:grid-cols-[minmax(0,12rem)_minmax(0,12rem)_8rem_minmax(0,1fr)]"; "grid-cols-[minmax(0,1fr)_auto] sm:grid-cols-[minmax(0,12rem)_minmax(0,12rem)_8rem_minmax(10rem,1fr)]";
export default function ActivityAudit({ export default function ActivityAudit({
shared, shared,
+16 -6
View File
@@ -26,7 +26,7 @@ import FilterSelect from "./ui/FilterSelect";
// failed, and two ways on: the lines the run logged, and the other runs of its job. // failed, and two ways on: the lines the run logged, and the other runs of its job.
const GRID = const GRID =
"grid-cols-[0.75rem_minmax(0,1fr)_auto] sm:grid-cols-[0.75rem_minmax(0,12rem)_8rem_8rem_4rem_3.5rem_minmax(0,1fr)]"; "grid-cols-[0.75rem_minmax(0,1fr)_auto] sm:grid-cols-[0.75rem_minmax(8rem,12rem)_minmax(0,8rem)_5.5rem_4rem_5.5rem_minmax(10rem,1fr)]";
/** The shared filters, as the page passes them down. */ /** The shared filters, as the page passes them down. */
export interface Shared { export interface Shared {
@@ -191,8 +191,13 @@ function RunCells({ run, actor }: { run: RunRow; actor: string | null | undefine
<span className="hidden tabular-nums text-muted sm:block"> <span className="hidden tabular-nums text-muted sm:block">
{secs ? formatRuntime(Math.max(1, secs), lang) : "—"} {secs ? formatRuntime(Math.max(1, secs), lang) : "—"}
</span> </span>
<span className="hidden tabular-nums text-muted sm:block"> {/* The spend carries its unit (a bare figure in a row has no meaning); none spent is blank. */}
{formatNumber(run.quota_units, lang)} <span className="hidden truncate tabular-nums text-muted sm:block">
{run.quota_units > 0 &&
t("activity.runs.units", {
count: run.quota_units,
units: formatNumber(run.quota_units, lang),
})}
</span> </span>
<span className={`col-span-3 truncate sm:col-span-1 ${outcome.tone}`}>{outcome.text}</span> <span className={`col-span-3 truncate sm:col-span-1 ${outcome.tone}`}>{outcome.text}</span>
</> </>
@@ -248,9 +253,14 @@ function RunDetail({
</pre> </pre>
)} )}
<div className="flex flex-wrap gap-x-4 gap-y-1"> <div className="flex flex-wrap gap-x-4 gap-y-1">
<Button variant="link" className={link} onClick={onLogs}> {/* Most runs store no line at all; a link there would only open an empty list. */}
{t("activity.runs.logLines", { count: run.log_lines })} {run.log_lines > 0 ? (
</Button> <Button variant="link" className={link} onClick={onLogs}>
{t("activity.runs.logLines", { count: run.log_lines })}
</Button>
) : (
<span>{t("activity.runs.noLogLines")}</span>
)}
<Button variant="link" className={link} onClick={onJob}> <Button variant="link" className={link} onClick={onJob}>
{t("activity.runs.jobRuns")} {t("activity.runs.jobRuns")}
</Button> </Button>
@@ -40,6 +40,8 @@
"empty": "No runs match the filters.", "empty": "No runs match the filters.",
"count_one": "{{count}} run shown · newest first", "count_one": "{{count}} run shown · newest first",
"count_other": "{{count}} runs shown · newest first", "count_other": "{{count}} runs shown · newest first",
"units_one": "{{units}} unit",
"units_other": "{{units}} units",
"fields": { "fields": {
"started": "Started by", "started": "Started by",
"when": "Start → end", "when": "Start → end",
@@ -48,6 +50,7 @@
}, },
"logLines_one": "Its {{count}} log line →", "logLines_one": "Its {{count}} log line →",
"logLines_other": "Its {{count}} log lines →", "logLines_other": "Its {{count}} log lines →",
"noLogLines": "It logged no lines",
"jobRuns": "This job's runs →" "jobRuns": "This job's runs →"
}, },
"audit": { "audit": {
+2
View File
@@ -12,6 +12,8 @@
"default": "default", "default": "default",
"secretUpdated": "secret updated (the value is never logged)", "secretUpdated": "secret updated (the value is never logged)",
"secretReset": "secret reset to its default (the value is never logged)", "secretReset": "secret reset to its default (the value is never logged)",
"tokenRevoked": "revoked, with every session of its account",
"viaToken": "By API token {{token}}",
"cols": { "cols": {
"when": "When", "when": "When",
"actor": "Who", "actor": "Who",
@@ -40,6 +40,8 @@
"empty": "Nincs a szűrőknek megfelelő futás.", "empty": "Nincs a szűrőknek megfelelő futás.",
"count_one": "{{count}} futás látszik · a legújabb elöl", "count_one": "{{count}} futás látszik · a legújabb elöl",
"count_other": "{{count}} futás látszik · a legújabb elöl", "count_other": "{{count}} futás látszik · a legújabb elöl",
"units_one": "{{units}} egység",
"units_other": "{{units}} egység",
"fields": { "fields": {
"started": "Indította", "started": "Indította",
"when": "Kezdés → vége", "when": "Kezdés → vége",
@@ -48,6 +50,7 @@
}, },
"logLines_one": "A futás {{count}} naplósora →", "logLines_one": "A futás {{count}} naplósora →",
"logLines_other": "A futás {{count}} naplósora →", "logLines_other": "A futás {{count}} naplósora →",
"noLogLines": "Nem naplózott sort",
"jobRuns": "Ennek a feladatnak a futásai →" "jobRuns": "Ennek a feladatnak a futásai →"
}, },
"audit": { "audit": {
+2
View File
@@ -12,6 +12,8 @@
"default": "alapérték", "default": "alapérték",
"secretUpdated": "titok frissítve (az értéke sosem kerül a naplóba)", "secretUpdated": "titok frissítve (az értéke sosem kerül a naplóba)",
"secretReset": "titok visszaállítva az alapértékre (az értéke sosem kerül a naplóba)", "secretReset": "titok visszaállítva az alapértékre (az értéke sosem kerül a naplóba)",
"tokenRevoked": "visszavonva, a fiók minden munkamenetével együtt",
"viaToken": "API-tokennel: {{token}}",
"cols": { "cols": {
"when": "Mikor", "when": "Mikor",
"actor": "Ki", "actor": "Ki",
+26
View File
@@ -3,6 +3,8 @@
// An audit row whose server summary is a fixed English line ("log_level = DEBUG", "rss_poll → 15 // An audit row whose server summary is a fixed English line ("log_level = DEBUG", "rss_poll → 15
// min/run", "Background sync paused") reads in the reader's language, rebuilt from the row's // min/run", "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. // data through the REAL catalog. A row whose summary is data (an e-mail, a URL) keeps it.
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import i18n, { setLanguage } from "../i18n"; import i18n, { setLanguage } from "../i18n";
import type { AuditRow } from "./api"; import type { AuditRow } from "./api";
@@ -106,6 +108,30 @@ describe("an audit row's details", () => {
expect(details(row({ action: "sync_pause", summary: "Background sync paused" }))).toBe(""); expect(details(row({ action: "sync_pause", summary: "Background sync paused" }))).toBe("");
}); });
it("say a token revoke and a token's file work in the reader's language", () => {
const revoke = details(
row({
action: "api_token_revoke",
target_type: "api_token",
target_id: "11",
summary: "revoked with every session of the account",
}),
);
expect(revoke).not.toMatch(/revoked/);
expect(revoke).toMatch(/munkamenet/);
const upload = details(
row({ action: "file_upload", target_type: "file", summary: "api token sfl_Q3qXFfcv…" }),
);
expect(upload).not.toMatch(/api token/);
expect(upload).toContain("sfl_Q3qXFfcv…");
// The line read above is the one the server writes.
const apitokens = readFileSync(
resolve(__dirname, "../../../backend/app/apitokens.py"),
"utf-8",
);
expect(apitokens).toContain('f"api token {self.token_prefix}…"');
});
it("keep a summary that is data", () => { it("keep a summary that is data", () => {
expect(details(row({ action: "invite_add", summary: "someone@example.com" }))).toBe( expect(details(row({ action: "invite_add", summary: "someone@example.com" }))).toBe(
"someone@example.com", "someone@example.com",
+7
View File
@@ -3,6 +3,10 @@ import type { AuditRow, JobResult } from "./api";
import { formatNumber } from "./format"; import { formatNumber } from "./format";
import { jobResultText } from "./jobResult"; 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 { function fmt(v: unknown): string {
return v == null || v === "" ? "—" : String(v); return v == null || v === "" ? "—" : String(v);
} }
@@ -63,6 +67,9 @@ function localDetails(
return result ? { head: null, sub: jobResultText(t, result, lang) } : undefined; 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 === "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; return undefined;
} }