diff --git a/frontend/src/components/Activity.dom.test.tsx b/frontend/src/components/Activity.dom.test.tsx index 4105bb05..d16478c4 100644 --- a/frontend/src/components/Activity.dom.test.tsx +++ b/frontend/src/components/Activity.dom.test.tsx @@ -214,6 +214,37 @@ describe("Activity", () => { 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 () => { window.history.replaceState(null, "", "/?page=activity"); runsMock.mockRejectedValue(new Error("502")); diff --git a/frontend/src/components/ActivityAudit.tsx b/frontend/src/components/ActivityAudit.tsx index aaa2af01..ff6d0ab9 100644 --- a/frontend/src/components/ActivityAudit.tsx +++ b/frontend/src/components/ActivityAudit.tsx @@ -24,7 +24,7 @@ import FilterSelect from "./ui/FilterSelect"; // leaving one row that says who cleared it. 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({ shared, diff --git a/frontend/src/components/ActivityRuns.tsx b/frontend/src/components/ActivityRuns.tsx index ce4477bf..777c45f1 100644 --- a/frontend/src/components/ActivityRuns.tsx +++ b/frontend/src/components/ActivityRuns.tsx @@ -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. 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. */ export interface Shared { @@ -191,8 +191,13 @@ function RunCells({ run, actor }: { run: RunRow; actor: string | null | undefine {secs ? formatRuntime(Math.max(1, secs), lang) : "—"} - - {formatNumber(run.quota_units, lang)} + {/* The spend carries its unit (a bare figure in a row has no meaning); none spent is blank. */} + + {run.quota_units > 0 && + t("activity.runs.units", { + count: run.quota_units, + units: formatNumber(run.quota_units, lang), + })} {outcome.text} @@ -248,9 +253,14 @@ function RunDetail({ )}
- + {/* Most runs store no line at all; a link there would only open an empty list. */} + {run.log_lines > 0 ? ( + + ) : ( + {t("activity.runs.noLogLines")} + )} diff --git a/frontend/src/i18n/locales/en/activity.json b/frontend/src/i18n/locales/en/activity.json index 8aeb5efb..dba2414c 100644 --- a/frontend/src/i18n/locales/en/activity.json +++ b/frontend/src/i18n/locales/en/activity.json @@ -40,6 +40,8 @@ "empty": "No runs match the filters.", "count_one": "{{count}} run shown · newest first", "count_other": "{{count}} runs shown · newest first", + "units_one": "{{units}} unit", + "units_other": "{{units}} units", "fields": { "started": "Started by", "when": "Start → end", @@ -48,6 +50,7 @@ }, "logLines_one": "Its {{count}} log line →", "logLines_other": "Its {{count}} log lines →", + "noLogLines": "It logged no lines", "jobRuns": "This job's runs →" }, "audit": { diff --git a/frontend/src/i18n/locales/en/audit.json b/frontend/src/i18n/locales/en/audit.json index b24741d8..7ef1f0e8 100644 --- a/frontend/src/i18n/locales/en/audit.json +++ b/frontend/src/i18n/locales/en/audit.json @@ -12,6 +12,8 @@ "default": "default", "secretUpdated": "secret updated (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": { "when": "When", "actor": "Who", diff --git a/frontend/src/i18n/locales/hu/activity.json b/frontend/src/i18n/locales/hu/activity.json index 8ca6cad2..33d26641 100644 --- a/frontend/src/i18n/locales/hu/activity.json +++ b/frontend/src/i18n/locales/hu/activity.json @@ -40,6 +40,8 @@ "empty": "Nincs a szűrőknek megfelelő futás.", "count_one": "{{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": { "started": "Indította", "when": "Kezdés → vége", @@ -48,6 +50,7 @@ }, "logLines_one": "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 →" }, "audit": { diff --git a/frontend/src/i18n/locales/hu/audit.json b/frontend/src/i18n/locales/hu/audit.json index 2c34e0d5..80662345 100644 --- a/frontend/src/i18n/locales/hu/audit.json +++ b/frontend/src/i18n/locales/hu/audit.json @@ -12,6 +12,8 @@ "default": "alapérték", "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)", + "tokenRevoked": "visszavonva, a fiók minden munkamenetével együtt", + "viaToken": "API-tokennel: {{token}}", "cols": { "when": "Mikor", "actor": "Ki", diff --git a/frontend/src/lib/auditText.dom.test.ts b/frontend/src/lib/auditText.dom.test.ts index a0906ad5..a1bee7e6 100644 --- a/frontend/src/lib/auditText.dom.test.ts +++ b/frontend/src/lib/auditText.dom.test.ts @@ -3,6 +3,8 @@ // 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 // 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 i18n, { setLanguage } from "../i18n"; 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(""); }); + 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", () => { expect(details(row({ action: "invite_add", summary: "someone@example.com" }))).toBe( "someone@example.com", diff --git a/frontend/src/lib/auditText.ts b/frontend/src/lib/auditText.ts index dd31a20c..7ebb10b5 100644 --- a/frontend/src/lib/auditText.ts +++ b/frontend/src/lib/auditText.ts @@ -3,6 +3,10 @@ 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); } @@ -63,6 +67,9 @@ function localDetails( 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; }