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([