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
+51 -18
View File
@@ -15,7 +15,7 @@ from app import audit, debugsession, jobruns, logs, logsink, progress, quota
from app.config import settings
from app.db import SessionLocal
from app.downloads.gc import run_download_gc
from app.models import SchedulerSetting
from app.models import Channel, SchedulerSetting
from app.plex.sync import sync as run_plex_sync
from app.plex.tmdb import run_enrich as run_tmdb_enrich
from app.plex.tmdb import run_people_enrich
@@ -253,38 +253,67 @@ def scheduler_snapshot(db) -> dict:
jobs = []
for job_id, default_interval in JOB_INTERVALS.items():
a = acts.get(job_id, {})
runs = recent.get(job_id, [])
run = runs[0] if runs else None
# A run still going has no outcome yet: only an ended run says how it went.
ended = run if run is not None and run.status != RunStatus.RUNNING else None
jobs.append(
{
"id": job_id,
"interval_minutes": live_intervals.get(job_id, default_interval),
"next_run": next_runs.get(job_id),
"running": a.get("running", False),
"status": ended.status if ended else None,
"last_started": _iso(run.started_at) if run else None,
"last_finished": _iso(run.finished_at) if run else None,
"last_result": ended.summary if ended else None,
# The outcome as data: the result, or on a failed run the failure the job named.
"result": _outcome_data(ended),
"last_error": ended.error if ended else None,
"last_trigger": run.trigger if run else None,
"last_run_id": run.id if run else None,
"quota_units": ended.quota_units if ended else None,
"progress": a.get("progress"),
# The last few runs, newest first, for the row's history dots.
"recent": [_run_brief(r) for r in runs],
**_last_runs(recent.get(job_id, [])),
}
)
return {"running_here": running_here, "enabled": settings.scheduler_enabled, "jobs": jobs}
# Work a user starts on one target: no interval, no "run now" (it needs its target), but its
# runs are shown beside the jobs' — it spends the same quota. Its one in-memory guard is the
# target's own (sync/runner), so "running" is what its newest row says.
backfills = recent.get(CHANNEL_BACKFILL, [])
newest = backfills[0] if backfills else None
ad_hoc = [
{
"id": CHANNEL_BACKFILL,
"running": newest is not None and newest.status == RunStatus.RUNNING,
"target": newest.target if newest else None,
"target_title": (
db.scalar(select(Channel.title).where(Channel.id == newest.target))
if newest is not None and newest.target
else None
),
**_last_runs(backfills),
}
]
return {
"running_here": running_here,
"enabled": settings.scheduler_enabled,
"jobs": jobs,
"ad_hoc": ad_hoc,
}
# How many past runs a Scheduler row shows as dots; the full history is the run list.
RECENT_RUNS = 3
def _last_runs(runs: list) -> dict:
"""A row's run fields from its newest runs (newest first): how the last one went, and the few
before it for the history dots."""
run = runs[0] if runs else None
# A run still going has no outcome yet: only an ended run says how it went.
ended = run if run is not None and run.status != RunStatus.RUNNING else None
return {
"status": ended.status if ended else None,
"last_started": _iso(run.started_at) if run else None,
"last_finished": _iso(run.finished_at) if run else None,
"last_result": ended.summary if ended else None,
# The outcome as data: the result, or on a failed run the failure the job named.
"result": _outcome_data(ended),
"last_error": ended.error if ended else None,
"last_trigger": run.trigger if run else None,
"last_run_id": run.id if run else None,
"quota_units": ended.quota_units if ended else None,
"recent": [_run_brief(r) for r in runs],
}
def _run_brief(run) -> dict:
return {
"id": run.id,
@@ -539,12 +568,16 @@ def trigger_all(actor_id: int | None = None) -> list[str]:
# one channel. It runs on the same scheduler as a recorded run (trigger `ad_hoc`), so what it did and
# spent is in `job_runs` like any job's, not a background task only the log ever heard of.
CHANNEL_BACKFILL = "channel_backfill"
# Every ad-hoc job id: the page names each one (a frontend contract test reads this tuple).
AD_HOC_JOBS = (CHANNEL_BACKFILL,)
def run_ad_hoc(job: str, fn: Callable[[], dict], *, actor_id: int, target: str) -> None:
"""Run `fn` now as `actor_id`'s run of `job` on `target`, a one-shot job on the scheduler. Its
own exclusion (if any) lives in `fn`, which says so in its result; one id per job and target, so
a second request before the first has even started is that same run."""
if job not in AD_HOC_JOBS:
raise KeyError(job) # an unnamed job would show on the page as its bare id
sched = _scheduler
if sched is None:
# Only between shutdown and exit: start_scheduler runs at every API startup.