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
+84 -2
View File
@@ -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> = {}): SchedulerStatus => ({
@@ -34,9 +34,14 @@ const status = (over: Partial<SchedulerStatus> = {}): 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<HTMLElement>(".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();
});
});