diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index 5280f98b..987443c9 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -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. diff --git a/backend/tests/test_scheduler.py b/backend/tests/test_scheduler.py index afcaac84..9150a7a3 100644 --- a/backend/tests/test_scheduler.py +++ b/backend/tests/test_scheduler.py @@ -13,7 +13,7 @@ import pytest from sqlalchemy import select from app import jobruns, logs, logsink, quota, scheduler, state -from app.models import AuditLog, JobRun, Notification, QuotaEvent +from app.models import AuditLog, Channel, JobRun, Notification, QuotaEvent from app.runstatus import RunStatus, RunTrigger _JOB = "unit_test_claim" # not a real job id — exercises the primitive without touching live jobs @@ -335,10 +335,43 @@ def test_an_ad_hoc_run_is_the_users_run_of_its_target_with_its_spend_and_failure assert (failed.status, failed.error) == ("error", "quota exceeded") +def test_the_page_shows_the_ad_hoc_runs_beside_the_jobs_with_their_channel(db, make_user): + user = make_user("shown@example.com") + db.add(Channel(id="UC_shown", title="BBC Earth")) + db.commit() + scheduler._ad_hoc("channel_backfill", lambda: {"skipped": "at capacity"}, user.id, "UC_none") + scheduler._ad_hoc("channel_backfill", lambda: {"videos_added": 69}, user.id, "UC_shown") + + (row,) = scheduler.scheduler_snapshot(db)["ad_hoc"] + assert (row["id"], row["running"], row["target"], row["target_title"]) == ( + "channel_backfill", + False, + "UC_shown", + "BBC Earth", + ) + assert (row["status"], row["result"], row["last_trigger"]) == ( + "ok", + {"videos_added": 69}, + "ad_hoc", + ) + assert len(row["recent"]) == 2 + assert all(j["id"] != "channel_backfill" for j in scheduler.scheduler_snapshot(db)["jobs"]) + + jobruns.start("channel_backfill", RunTrigger.AD_HOC, user.id, "UC_shown") + assert scheduler.scheduler_snapshot(db)["ad_hoc"][0]["running"] is True + + def _finished(db, job) -> list[JobRun]: return [r for r in _runs(db, job) if r.status != "running"] +def test_an_ad_hoc_job_outside_the_named_list_is_refused(live_scheduler): + """The page names every ad-hoc job from AD_HOC_JOBS; one outside it would show as a bare id.""" + with pytest.raises(KeyError): + scheduler.run_ad_hoc("unnamed_job", dict, actor_id=1, target="x") + assert live_scheduler.get_jobs() == [] + + def test_a_second_ad_hoc_request_before_the_first_starts_is_that_same_run( db, make_user, live_scheduler ): diff --git a/frontend/src/components/Scheduler.dom.test.tsx b/frontend/src/components/Scheduler.dom.test.tsx index 88301ba4..89477800 100644 --- a/frontend/src/components/Scheduler.dom.test.tsx +++ b/frontend/src/components/Scheduler.dom.test.tsx @@ -6,7 +6,7 @@ // because this page polls: every few seconds a fresh status arrives, and a mirror is exactly the // thing a poll can overwrite at the wrong moment. import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; -import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent, cleanup, act, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("react-i18next", async (importOriginal) => { @@ -15,7 +15,7 @@ vi.mock("react-i18next", async (importOriginal) => { }); import Scheduler from "./Scheduler"; -import { api, type SchedulerStatus } from "../lib/api"; +import { api, type RunBrief, type SchedulerAdHoc, type SchedulerStatus } from "../lib/api"; import { qk } from "../lib/queryKeys"; const status = (over: Partial = {}): SchedulerStatus => ({ @@ -34,9 +34,14 @@ const status = (over: Partial = {}): SchedulerStatus => ({ last_result: null, result: null, last_error: null, + last_trigger: null, + last_run_id: null, + quota_units: null, + recent: [], progress: null, }, ], + ad_hoc: [], queue: { channels_recent_pending: 0, channels_deep_pending: 0, @@ -281,3 +286,80 @@ describe("a job's last result", () => { expect(screen.queryByText(/new=3/)).toBeNull(); }); }); + +describe("a row's run history", () => { + const at = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60_000).toISOString(); + const run = (id: number, status: RunBrief["status"], minutesAgo: number): RunBrief => ({ + id, + status, + trigger: "scheduled", + started_at: at(minutesAgo), + finished_at: status === "running" ? null : at(minutesAgo), + }); + const dots = (root: HTMLElement = document.body) => + within(root) + .getAllByRole("img") + .map((d) => d.getAttribute("data-dot")); + + // An interrupted run fell through to "idle" — a grey dot that said no run was ever recorded. + it("shows the last runs newest first, an interrupted one as such", async () => { + const base = status(); + await mount({ + ...base, + jobs: [ + { + ...base.jobs[0]!, + status: "interrupted", + last_finished: at(2), + recent: [run(3, "interrupted", 2), run(2, "error", 12), run(1, "ok", 22)], + }, + ], + }); + expect(dots()).toEqual(["interrupted", "error", "ok"]); + expect(screen.getByText(/scheduler\.interrupted/)).toBeTruthy(); + expect(screen.getByText("scheduler.dot.interrupted")).toBeTruthy(); // the legend names it + }); + + it("shows one idle dot for a job that never ran", async () => { + await mount(); + expect(dots()).toEqual(["idle"]); + }); + + it("gives a run the job has claimed, but not yet recorded, the newest dot", async () => { + const base = status(); + await mount({ + ...base, + jobs: [ + { + ...base.jobs[0]!, + running: true, + recent: [run(3, "ok", 10), run(2, "ok", 20), run(1, "error", 30)], + }, + ], + }); + expect(dots()).toEqual(["running", "ok", "ok"]); + }); + + // The one-channel full-history backfill ran unseen: a background task only the log heard of. + it("lists a user's ad-hoc run beside the jobs, with its channel and no run button", async () => { + const base = status(); + const adHoc: SchedulerAdHoc = { + ...base.jobs[0]!, + id: "channel_backfill", + running: false, + target: "UC_bbc", + target_title: "BBC Earth", + last_finished: at(60), + result: { videos_added: 69 }, + recent: [run(9, "ok", 60), run(8, "ok", 120)], + }; + await mount({ ...base, ad_hoc: [adHoc] }); + expect(screen.getByText("scheduler.adHocTitle")).toBeTruthy(); + const row = screen.getByText("scheduler.jobs.channel_backfill").closest(".p-3")!; + expect(dots(row)).toEqual(["ok", "ok"]); + expect(within(row).getByText(/BBC Earth/)).toBeTruthy(); + expect(within(row).getByText(/scheduler\.result\.videos_added: 69/)).toBeTruthy(); + expect(within(row).getByText("scheduler.adHocStarted")).toBeTruthy(); + expect(within(row).queryByRole("button", { name: "scheduler.runNow" })).toBeNull(); + }); +}); diff --git a/frontend/src/components/Scheduler.menu.dom.test.tsx b/frontend/src/components/Scheduler.menu.dom.test.tsx index c9322a1f..fbf38e8f 100644 --- a/frontend/src/components/Scheduler.menu.dom.test.tsx +++ b/frontend/src/components/Scheduler.menu.dom.test.tsx @@ -32,9 +32,14 @@ const status = (over: Partial = {}): SchedulerStatus => ({ last_result: null, result: null, last_error: null, + last_trigger: null, + last_run_id: null, + quota_units: null, + recent: [], progress: null, }, ], + ad_hoc: [], queue: { channels_recent_pending: 0, channels_deep_pending: 0, diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 29075f6b..91d8333f 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -17,7 +17,14 @@ import { Trash2, X, } from "lucide-react"; -import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api"; +import { + api, + type RunBrief, + type RunFields, + type SchedulerAdHoc, + type SchedulerJob, + type SchedulerStatus, +} from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { Button } from "./ui/Button"; import { LoadingState, StateMessage } from "./ui/QueryState"; @@ -57,41 +64,40 @@ function fmtCountdown(t: TFunction, s: number, lang: string): string { return `${formatUnit(h, "hour", lang)} ${formatUnit(m % 60, "minute", lang)}`; } -const DOT_CLASS: Record = { +// Interrupted is a ring, not a fill: the job did not fail, but its work did not finish either — set +// apart from a skipped run (the same colour, filled) and from a failure. +const DOT_CLASS = { running: "bg-accent animate-pulse", ok: "bg-success", error: "bg-danger", skipped: "bg-warn", + interrupted: "border-2 border-warn", idle: "bg-border", -}; +} as const; -function statusKey(job: SchedulerJob): keyof typeof DOT_CLASS { - if (job.running) return "running"; - if (job.status === "ok") return "ok"; - if (job.status === "error") return "error"; - if (job.status === "skipped") return "skipped"; - return "idle"; -} +type DotKey = keyof typeof DOT_CLASS; -function StatusDot({ - k, - withTooltip = true, -}: { - k: keyof typeof DOT_CLASS; - withTooltip?: boolean; -}) { - const { t } = useTranslation(); - const dot = ; - return withTooltip ? {dot} : dot; +/** A status dot. `label` names it for a screen reader (the tooltip shows only on hover, and only with + * hints on); a legend's dot has its words beside it and stays unnamed. */ +function StatusDot({ k, label }: { k: DotKey; label?: string }) { + return ( + + ); } function StatusLegend() { const { t } = useTranslation(); return (
- {(["running", "ok", "idle", "error", "skipped"] as const).map((k) => ( + {(["running", "ok", "idle", "error", "skipped", "interrupted"] as const).map((k) => ( - + {t(`scheduler.dot.${k}`)} ))} @@ -99,6 +105,106 @@ function StatusLegend() { ); } +// The server sends a row's newest runs, at most this many (RECENT_RUNS in app/scheduler.py). +const MAX_DOTS = 3; + +/** A row's last runs, newest first (leftmost), each saying in its tooltip how it went, when, and who + * started it. A row with no run yet shows one idle dot. While a run goes on the newest dot pulses: + * the job's own claim says so a moment before its run row exists, so that moment gets a dot too. */ +function RunDots({ runs, running }: { runs: RunBrief[]; running: boolean }) { + const { t, i18n } = useTranslation(); + const lang = i18n.language; + const claimed = running && runs[0]?.status !== "running"; + const shown: (RunBrief | null)[] = claimed ? [null, ...runs].slice(0, MAX_DOTS) : runs; + const dots = shown.length === 0 ? [undefined] : shown; + return ( + + {dots.map((run) => { + const hint = + run === undefined + ? t("scheduler.dot.idle") + : run === null + ? t("scheduler.dot.running") + : t("scheduler.dotRun", { + state: t(`scheduler.dot.${run.status}`), + time: relativeTime(run.started_at, lang, t), + trigger: t(`scheduler.trigger.${run.trigger}`), + }); + const k: DotKey = run === undefined ? "idle" : run === null ? "running" : run.status; + return ( + + + + ); + })} + + ); +} + +/** "Last run 2 min ago · ", the line under a row's name: how the newest ended run went + * — its failure, its result, or that the process stopped under it. */ +function LastRun({ row, running, target }: { row: RunFields; running: boolean; target?: string }) { + const { t, i18n } = useTranslation(); + const lang = i18n.language; + return ( +
+ {running + ? t("scheduler.runningNow") + : row.last_finished + ? t("scheduler.lastRun", { time: relativeTime(row.last_finished, lang, t) }) + : t("scheduler.neverRun")} + {target && · {target}} + {row.last_error ? ( + + {" · "} + {jobFailureText(t, row.result, lang) ?? row.last_error} + + ) : row.status === "interrupted" ? ( + · {t("scheduler.interrupted")} + ) : row.result ? ( + · {jobResultText(t, row.result, lang)} + ) : row.last_result ? ( + · {row.last_result} + ) : null} +
+ ); +} + +/** A user's run on one target (a channel's full history): its runs as a job's are shown, but it has + * no interval and no "run now" — it needs its target, so it is started where that is chosen. */ +function AdHocRow({ row }: { row: SchedulerAdHoc }) { + const { t } = useTranslation(); + return ( +
+ +
+
+ + + {t(`scheduler.jobs.${row.id}`, row.id)} + + +
+ +
+
+ {row.running ? ( + {t("scheduler.active")} + ) : ( + t("scheduler.adHocStarted") + )} +
+
+ ); +} + // Shown for ANY running job. With reported counts it's a determinate bar; for a job that // runs but doesn't report progress (or hasn't yet) it falls back to a generic "working" // label and an indeterminate sliver — so every active run is visible, not just the ones @@ -186,7 +292,7 @@ function JobRow({ return (
- +
@@ -247,26 +353,7 @@ function JobRow({ )}
-
- {job.running - ? t("scheduler.runningNow") - : job.last_finished - ? t("scheduler.lastRun", { time: relativeTime(job.last_finished, lang, t) }) - : t("scheduler.neverRun")} - {job.last_error ? ( - - {" · "} - {jobFailureText(t, job.result, lang) ?? job.last_error} - - ) : job.result ? ( - · {jobResultText(t, job.result, lang)} - ) : job.last_result ? ( - · {job.last_result} - ) : null} -
+ {job.running && }
@@ -646,6 +733,18 @@ export default function Scheduler() {
+ {/* Ad-hoc runs: a user's work on one target, shown beside the jobs because it spends the same quota */} + {data.ad_hoc.length > 0 && ( +
+ {t("scheduler.adHocTitle")} +
+ {data.ad_hoc.map((row) => ( + + ))} +
+
+ )} + {/* Queue */}
{t("scheduler.queueTitle")} diff --git a/frontend/src/i18n/backendIds.contract.test.ts b/frontend/src/i18n/backendIds.contract.test.ts index f2a3671d..7c6b8ba5 100644 --- a/frontend/src/i18n/backendIds.contract.test.ts +++ b/frontend/src/i18n/backendIds.contract.test.ts @@ -10,7 +10,7 @@ import { vocabKey } from "../lib/vocab"; // the registries straight from the backend sources: // - `sysconfig.SPECS` → `config.groups.`, `config.fields..label` + `.hint`, and for a // "choice" key `config.choices..