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
+38 -6
View File
@@ -184,12 +184,23 @@ function logParams(query: LogQuery, extra: Record<string, string | number | null
export type JobResult = { [key: string]: number | string | JobResult };
export interface SchedulerJob {
id: string;
interval_minutes: number;
next_run: string | null;
running: boolean;
status: "ok" | "error" | "skipped" | null;
/** How a run ended — the server's `RunStatus` (app/runstatus.py) minus `running`. `interrupted`:
* the process stopped mid-run (a deploy, a crash), and its next start closed the row. */
export type RunOutcome = "ok" | "error" | "skipped" | "interrupted";
/** One past run, as the row's history dots show it. */
export interface RunBrief {
id: number;
status: RunOutcome | "running";
trigger: "scheduled" | "manual" | "ad_hoc";
started_at: string;
finished_at: string | null;
}
/** The fields every Scheduler row has from its runs: how the newest ended one went, and the few
* before it. */
export interface RunFields {
status: RunOutcome | null;
last_started: string | null;
last_finished: string | null;
last_result: string | null; // the English one-liner — the fallback for a job with no `result`
@@ -197,16 +208,37 @@ export interface SchedulerJob {
// named in the reader's language by lib/jobResult.
result: JobResult | null;
last_error: string | null;
last_trigger: RunBrief["trigger"] | null;
last_run_id: number | null;
quota_units: number | null;
/** The newest runs, newest first (at most three). */
recent: RunBrief[];
}
export interface SchedulerJob extends RunFields {
id: string;
interval_minutes: number;
next_run: string | null;
running: boolean;
// Live progress while running (null when idle or for jobs that don't report it).
// total is null for indeterminate progress (e.g. enrichment drains an unknown queue).
progress: { current: number; total: number | null; phase: string | null } | null;
}
/** Work a user starts on one target (a channel's full history): no interval, no "run now". */
export interface SchedulerAdHoc extends RunFields {
id: string;
running: boolean;
target: string | null;
target_title: string | null;
}
export interface SchedulerStatus {
running_here: boolean;
enabled: boolean;
paused: boolean;
jobs: SchedulerJob[];
ad_hoc: SchedulerAdHoc[];
queue: {
channels_recent_pending: number;
channels_deep_pending: number;