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:
@@ -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"));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user