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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user