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;
}