feat(scheduler): each row shows its last three runs, an interrupted run as such, and the ad-hoc runs beside the jobs

A row's one dot said how the LAST run went, and an interrupted run fell through to "idle" — a grey dot
that read as "no run recorded". The row now leads with its last three runs, newest first, each named
for a screen reader and in its tooltip (how it went, when, who started it); an interrupted run is a
ring, set apart from a skipped run and a failure, and its line says the server restarted under it.

A channel's full-history backfill gets its own "Ad-hoc runs" block: its runs, its channel and what
it fetched, no interval and no run button — it needs its target. AD_HOC_JOBS names every such job;
the backend-id contract holds their names in both languages, and run_ad_hoc refuses any other id.
This commit is contained in:
2026-10-18 01:20:53 +02:00
parent 27c2fcbd2e
commit 1b1e3cdcf2
9 changed files with 404 additions and 89 deletions
+12 -5
View File
@@ -10,7 +10,7 @@ import { vocabKey } from "../lib/vocab";
// the registries straight from the backend sources:
// - `sysconfig.SPECS` → `config.groups.<group>`, `config.fields.<key>.label` + `.hint`, and for a
// "choice" key `config.choices.<key>.<option>` — the options read from its Literal in config.py
// - `scheduler.JOB_INTERVALS` → `scheduler.jobs.<id>` + `scheduler.jobDesc.<id>`
// - `scheduler.JOB_INTERVALS` and `scheduler.AD_HOC_JOBS` → `scheduler.jobs.<id>` + `scheduler.jobDesc.<id>`
// - the `audit` action enum → `auditActions.<id>`; the `quota` action enum → `quotaActions.<id>`
// and the other way round: a name left behind for an id the backend dropped is dead weight that reads
// as a live setting to the next person (`plex_sync_interval_min` moved to the scheduler long ago).
@@ -56,11 +56,18 @@ const choicesOf = (attr: string): string[] => {
const choiceIds = specs
.filter((s) => s.type === "choice")
.flatMap((s) => choicesOf(s.attr).map((c) => `config.choices.${s.key}.${c}`));
const schedulerPy = backend("scheduler.py");
// The interval jobs, and the ad-hoc ones (`AD_HOC_JOBS`, a tuple of constants named beside it).
const jobs = [
...(backend("scheduler.py").split("JOB_INTERVALS")[1] ?? "")
.split("}")[0]!
.matchAll(/"(\w+)": settings\./g),
].map((m) => m[1]!);
...[
...(schedulerPy.split("JOB_INTERVALS")[1] ?? "")
.split("}")[0]!
.matchAll(/"(\w+)": settings\./g),
].map((m) => m[1]!),
...[...(/^AD_HOC_JOBS = \(([^)]*)\)/m.exec(schedulerPy)?.[1] ?? "").matchAll(/\b[A-Z_]+\b/g)].map(
(m) => new RegExp(`^${m[0]} = "(\\w+)"$`, "m").exec(schedulerPy)?.[1] ?? m[0],
),
];
const enumValues = (file: string) =>
[...backend(file).matchAll(/^\s+[A-Z_]+ = "(\w+)"$/gm)].map((m) => m[1]!);
const auditActions = enumValues("audit.py");