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");
+19 -7
View File
@@ -56,10 +56,11 @@
},
"dot": {
"running": "running now",
"ok": "last run OK",
"idle": "idle (no run recorded yet)",
"error": "last run failed",
"skipped": "skipped (sync paused)"
"ok": "OK",
"idle": "no run recorded yet",
"error": "failed",
"skipped": "skipped (sync paused)",
"interrupted": "interrupted (the server restarted)"
},
"jobDesc": {
"rss_poll": "Checks each channel's RSS feed for new uploads (free, no quota). If it stops, new videos only appear later when a backfill pass catches them, so your Feed lags behind YouTube.",
@@ -78,7 +79,8 @@
"audit_gc": "Prunes admin audit-log entries and YouTube quota history older than their retention windows (Configuration → Audit log, Quota). If it stops, both just keep growing — harmless but unbounded.",
"log_gc": "Deletes stored log events older than the retention window (Configuration → Diagnostics). Runs hourly so nothing outlives the promised limit by more than an hour.",
"tmdb_enrich": "Enriches the mirrored Plex catalogue from TMDB (keywords, crew, budget/revenue, TMDb score, recommendations, artwork), matched by each title's canonical id and served from the app's own database. Optional — a no-op until a TMDB API key is set. If it stops, new Plex titles just miss their TMDB extras until it runs again.",
"people_enrich": "Makes the people in the Plex library local: fetches each person's biography from TMDB and caches their portrait, resized through Plex, the most-credited people first. A person page and a cast strip then open from the app's own data. The TMDB half does nothing without a TMDB API key, the portrait half nothing without Plex. If it stops, a person page fetches its own bio when opened, a moment later."
"people_enrich": "Makes the people in the Plex library local: fetches each person's biography from TMDB and caches their portrait, resized through Plex, the most-credited people first. A person page and a cast strip then open from the app's own data. The TMDB half does nothing without a TMDB API key, the portrait half nothing without Plex. If it stops, a person page fetches its own bio when opened, a moment later.",
"channel_backfill": "Turning on full history for a channel fetches its whole back-catalogue at once, as its own run. The Backfill job finishes what this one could not (a very large channel, low quota, or two of these already running)."
},
"jobs": {
"rss_poll": "RSS poll (new uploads)",
@@ -97,7 +99,8 @@
"tmdb_enrich": "TMDB enrichment (Plex)",
"audit_gc": "Audit-log and quota-history cleanup",
"log_gc": "Log cleanup",
"people_enrich": "People details and portraits (Plex)"
"people_enrich": "People details and portraits (Plex)",
"channel_backfill": "A channel's full history"
},
"queue": {
"recentPending": "Channels to sync",
@@ -199,5 +202,14 @@
"transport": "unreachable",
"apply": "could not be saved",
"error": "unexpected error"
}
},
"dotRun": "{{state}} · {{time}} · {{trigger}}",
"trigger": {
"scheduled": "on schedule",
"manual": "started by an admin",
"ad_hoc": "started by a user"
},
"interrupted": "interrupted: the server restarted during the run",
"adHocTitle": "Ad-hoc runs",
"adHocStarted": "started by a user"
}
+19 -7
View File
@@ -56,10 +56,11 @@
},
"dot": {
"running": "most fut",
"ok": "utolsó futás OK",
"idle": "tétlen (még nincs rögzített futás)",
"error": "utolsó futás hibázott",
"skipped": "kihagyva (szinkron szüneteltetve)"
"ok": "rendben",
"idle": "még nincs rögzített futás",
"error": "hibával ért véget",
"skipped": "kihagyva (szinkron szüneteltetve)",
"interrupted": "megszakadt (újraindult a szerver)"
},
"jobDesc": {
"rss_poll": "Az egyes csatornák RSS-feedjét nézi új feltöltésekért (ingyenes, nem fogyaszt kvótát). Ha leáll, az új videók csak később jelennek meg, amikor a videók behúzása eléri őket — így a Feed lemarad a YouTube mögött.",
@@ -78,7 +79,8 @@
"audit_gc": "Törli a megőrzési időnél régebbi admin-naplóbejegyzéseket és YouTube-kvótaelőzményeket (Konfiguráció → Eseménynapló, Kvóta). Ha leáll, mindkettő csak nő — ártalmatlan, de korlátlanul.",
"log_gc": "Törli a megőrzési időnél (Konfiguráció → Diagnosztika) régebbi tárolt naplóeseményeket. Óránként fut, így semmi sem marad meg egy óránál tovább az ígért határidőn túl.",
"tmdb_enrich": "A tükrözött Plex-katalógust dúsítja a TMDB-ből (kulcsszavak, stáb, költségvetés/bevétel, TMDb-pontszám, ajánlások, artwork), a címek kanonikus azonosítója alapján párosítva, és az app saját adatbázisából szolgálja ki. Opcionális — TMDB API-kulcs nélkül nem csinál semmit. Ha leáll, az új Plex-címek csak a következő futásig maradnak TMDB-extrák nélkül.",
"people_enrich": "Helyivé teszi a Plex-könyvtár szereplőit: a TMDB-ből letölti az életrajzukat, a portréjukat pedig a Plexszel kicsinyítve elmenti, a legtöbbet szereplőkkel kezdve. A személyoldal és a szereplősáv így az app saját adataiból nyílik meg. A TMDB-rész TMDB API-kulcs nélkül nem csinál semmit, a portrérész Plex nélkül. Ha leáll, a személyoldal megnyitáskor maga kéri le az életrajzot, egy pillanattal később."
"people_enrich": "Helyivé teszi a Plex-könyvtár szereplőit: a TMDB-ből letölti az életrajzukat, a portréjukat pedig a Plexszel kicsinyítve elmenti, a legtöbbet szereplőkkel kezdve. A személyoldal és a szereplősáv így az app saját adataiból nyílik meg. A TMDB-rész TMDB API-kulcs nélkül nem csinál semmit, a portrérész Plex nélkül. Ha leáll, a személyoldal megnyitáskor maga kéri le az életrajzot, egy pillanattal később.",
"channel_backfill": "Ha egy csatornánál bekapcsolod a teljes előzményt, a teljes korábbi kínálata egyszerre, saját futásként töltődik le. Amit ez nem tud befejezni (egy nagyon nagy csatorna, kevés kvóta, vagy ha már két ilyen fut), azt a „Videók behúzása” feladat fejezi be."
},
"jobs": {
"rss_poll": "RSS-lekérdezés (új feltöltések)",
@@ -97,7 +99,8 @@
"tmdb_enrich": "TMDB-dúsítás (Plex)",
"audit_gc": "Eseménynapló- és kvótaelőzmény-takarítás",
"log_gc": "Naplótakarítás",
"people_enrich": "Személyadatok és portrék (Plex)"
"people_enrich": "Személyadatok és portrék (Plex)",
"channel_backfill": "Egy csatorna teljes előzménye"
},
"queue": {
"recentPending": "Szinkronra váró csatorna",
@@ -199,5 +202,14 @@
"transport": "nem érhető el",
"apply": "nem sikerült menteni",
"error": "váratlan hiba"
}
},
"dotRun": "{{state}} · {{time}} · {{trigger}}",
"trigger": {
"scheduled": "ütemezve",
"manual": "admin indította",
"ad_hoc": "felhasználó indította"
},
"interrupted": "megszakadt: a futás közben újraindult a szerver",
"adHocTitle": "Alkalmi futások",
"adHocStarted": "felhasználó indítja"
}