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:
+51
-18
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user