fix(activity): runs keep their true outcome and spend, the page reaches every kept row, a closing dialog leaves the server log alone

Backend
- A run's outcome write sits outside the job's own try: a database blip after a run that
  succeeded no longer records it as failed (the row waits for the next start's sweep).
- An interrupted run keeps what it spent (the sweep sums its quota rows, as finish does).
- The Scheduler's ad-hoc row says running while any backfill runs, not only the newest.
- recent_runs reads each job's last runs off its index instead of ranking the whole table.
- An interval change is stored with its audit row in one transaction, then rescheduled.
- One API instance per database is the stated contract (.env.example, jobruns).
- Turning full history on for a channel already complete records no run.
- Migration 0109 round-trips interrupted and ad-hoc runs; the run words' DB CHECKs are
  test-held to the enums; the account filter is one helper for the four activity reads.
- An audit row says how many of its request's log lines are still kept.

Frontend
- An "All" period reaches every row still kept (runs and admin actions outlive 90 days).
- Back/Forward is followed in place by the server log: a closing dialog no longer turns
  Live off; a pivot still opens a fresh log.
- Runs moved over from the audit log read their one-liner in the reader's language.
- Filter choices or accounts that failed to load say so beside the filters, with a retry.
- An old server-log link keeps its account, text and range; a remembered retired page
  opens its tab.
- A request whose lines are gone says so instead of linking to an empty list.
- The log lines and the history rows share one row frame, and the server log one list
  frame with the other tabs; an empty list is announced; each day's quota is readable.
This commit is contained in:
2026-10-18 15:38:32 +02:00
parent 3021be5166
commit fad656988a
44 changed files with 1101 additions and 426 deletions
@@ -1,5 +1,7 @@
import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { RUN_STATUSES } from "../lib/activityUrlState";
import { vocabKey } from "../lib/vocab";
// Every id the BACKEND owns and the UI names must have its name in both languages. The UI looks these
@@ -72,6 +74,15 @@ const jobs = [
const enumValues = (file: string) =>
[...backend(file).matchAll(/^\s+[A-Z_]+ = "(\w+)"$/gm)].map((m) => m[1]!);
const auditActions = enumValues("audit.py");
// A background run's words (backend/app/runstatus.py): its status and what started it.
const runEnum = (name: string) =>
[
...(
backend("runstatus.py").split(`class ${name}(StrEnum):`)[1]?.split("\nclass ")[0] ?? ""
).matchAll(/^\s+[A-Z_]+ = "(\w+)"$/gm),
].map((m) => m[1]!);
const runStatuses = runEnum("RunStatus");
const runTriggers = runEnum("RunTrigger");
const quotaActions = enumValues("quota.py");
// The admin actions' refusal reasons → `errors.admin.<reason>`. The codes are read from
// `localizeDetail`'s own ADMIN_REFUSALS, so a code added there is scanned here too; a detail dict
@@ -260,6 +271,8 @@ describe("backend ids have their names in both languages", () => {
expect(jobs).toEqual(expect.arrayContaining(["rss_poll", "audit_gc"]));
expect(auditActions).toEqual(expect.arrayContaining(["config_set", "audit_clear"]));
expect(quotaActions.length).toBeGreaterThan(0);
expect(runStatuses).toEqual(expect.arrayContaining(["running", "interrupted"]));
expect(runTriggers).toEqual(expect.arrayContaining(["scheduled", "ad_hoc"]));
expect(adminCodes).toEqual(expect.arrayContaining(["activate", "resend"]));
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
@@ -288,10 +301,23 @@ describe("backend ids have their names in both languages", () => {
expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]);
});
it("the run list's status chips are the server's run statuses", () => {
expect([...RUN_STATUSES].sort()).toEqual([...runStatuses].sort());
});
for (const lang of ["en", "hu"]) {
const keys = keysOf(lang);
const missing = (wanted: string[]) => wanted.filter((k) => !keys.has(k));
it(`${lang}: every run status and trigger`, () => {
expect(
missing([
...runStatuses.flatMap((s) => [`activity.runs.status.${s}`, `scheduler.dot.${s}`]),
...runTriggers.map((s) => `scheduler.trigger.${s}`),
]),
).toEqual([]);
});
it(`${lang}: every config key and group`, () => {
expect(
missing([
+10 -3
View File
@@ -18,7 +18,8 @@
"24h": "24 hours",
"7d": "7 days",
"30d": "30 days",
"90d": "90 days"
"90d": "90 days",
"all": "All"
},
"account": "Account",
"any": "Any",
@@ -26,7 +27,8 @@
"older": "Load older",
"stale": "Couldn't refresh — what you see may be out of date",
"olderFailed": "Couldn't load the older rows",
"countsFailed": "Couldn't load the filter counts",
"filtersFailed": "Couldn't load the filter choices: the menus may be missing some.",
"accountsFailed": "Couldn't load the accounts for the account filter.",
"runs": {
"job": "Job",
"statusLabel": "Filter by status",
@@ -57,10 +59,15 @@
"action": "Action",
"target": "Target",
"requestLines": "Its request's log lines →",
"noRequestLines": "Its request's log lines are no longer kept",
"count_one": "{{count}} action shown · newest first",
"count_other": "{{count}} actions shown · newest first"
},
"quota": {
"total": "Spend over {{window}}: {{units}} units"
"daily": "Spend per day",
"total_one": "Spend over {{window}}: {{units}} unit",
"total_other": "Spend over {{window}}: {{units}} units",
"totalAll_one": "Spend over all kept history: {{units}} unit",
"totalAll_other": "Spend over all kept history: {{units}} units"
}
}
+3 -2
View File
@@ -4,7 +4,6 @@
"introAfter": " is shared background work (scheduled backfill, enrichment, resync) that isn't tied to one person.",
"loading": "Loading…",
"noUsageInRange": "No usage in this range.",
"dailyTooltip": "{{day}}: {{units}} units",
"system": "System (background)",
"sync": {
"myStatus": "My sync status",
@@ -41,5 +40,7 @@
"fullHistoryRequested_one": "Full history requested for {{count}} channel",
"fullHistoryRequested_other": "Full history requested for {{count}} channels",
"fullHistoryFailed": "Couldn't request full history"
}
},
"dailyTooltip_one": "{{day}}: {{units}} unit",
"dailyTooltip_other": "{{day}}: {{units}} units"
}
+10 -3
View File
@@ -18,7 +18,8 @@
"24h": "24 óra",
"7d": "7 nap",
"30d": "30 nap",
"90d": "90 nap"
"90d": "90 nap",
"all": "Mind"
},
"account": "Fiók",
"any": "Bármelyik",
@@ -26,7 +27,8 @@
"older": "Régebbiek",
"stale": "Nem sikerült frissíteni — amit látsz, elavult lehet",
"olderFailed": "Nem sikerült betölteni a régebbi sorokat",
"countsFailed": "Nem sikerült betölteni a szűrők darabszámait",
"filtersFailed": "Nem sikerült betölteni a szűrők választékát: a menükből hiányozhatnak elemek.",
"accountsFailed": "Nem sikerült betölteni a fiókokat a fiókszűrőhöz.",
"runs": {
"job": "Feladat",
"statusLabel": "Szűrés állapotra",
@@ -57,10 +59,15 @@
"action": "Művelet",
"target": "Cél",
"requestLines": "A kérés naplósorai →",
"noRequestLines": "A kérés naplósorai már nincsenek meg",
"count_one": "{{count}} művelet látszik · a legújabb elöl",
"count_other": "{{count}} művelet látszik · a legújabb elöl"
},
"quota": {
"total": "Felhasználás ({{window}}): {{units}} egység"
"daily": "Felhasználás naponta",
"total_one": "Felhasználás ({{window}}): {{units}} egység",
"total_other": "Felhasználás ({{window}}): {{units}} egység",
"totalAll_one": "Felhasználás (a teljes megőrzött idő): {{units}} egység",
"totalAll_other": "Felhasználás (a teljes megőrzött idő): {{units}} egység"
}
}
+3 -2
View File
@@ -4,7 +4,6 @@
"introAfter": " a megosztott háttérmunka (ütemezett behúzás, dúsítás, újraszinkronizálás), amely nem köthető egy személyhez.",
"loading": "Betöltés…",
"noUsageInRange": "Nincs használat ebben az időszakban.",
"dailyTooltip": "{{day}}: {{units}} egység",
"system": "Rendszer (háttér)",
"sync": {
"myStatus": "Saját szinkronizálási állapot",
@@ -41,5 +40,7 @@
"fullHistoryRequested_one": "Teljes előzmény kérve {{count}} csatornához",
"fullHistoryRequested_other": "Teljes előzmény kérve {{count}} csatornához",
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni"
}
},
"dailyTooltip_one": "{{day}}: {{units}} egység",
"dailyTooltip_other": "{{day}}: {{units}} egység"
}