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("—");
|
||||
});
|
||||
|
||||
// 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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
<span className="hidden tabular-nums text-muted sm:block">
|
||||
{secs ? formatRuntime(Math.max(1, secs), lang) : "—"}
|
||||
</span>
|
||||
<span className="hidden tabular-nums text-muted sm:block">
|
||||
{formatNumber(run.quota_units, lang)}
|
||||
{/* The spend carries its unit (a bare figure in a row has no meaning); none spent is blank. */}
|
||||
<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 className={`col-span-3 truncate sm:col-span-1 ${outcome.tone}`}>{outcome.text}</span>
|
||||
</>
|
||||
@@ -248,9 +253,14 @@ function RunDetail({
|
||||
</pre>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<Button variant="link" className={link} onClick={onLogs}>
|
||||
{t("activity.runs.logLines", { count: run.log_lines })}
|
||||
</Button>
|
||||
{/* Most runs store no line at all; a link there would only open an empty list. */}
|
||||
{run.log_lines > 0 ? (
|
||||
<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}>
|
||||
{t("activity.runs.jobRuns")}
|
||||
</Button>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user