refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/

A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
2026-10-22 23:12:45 +02:00
parent fdf8076013
commit 63a0fecc8b
356 changed files with 2433 additions and 2332 deletions
@@ -0,0 +1,400 @@
// @vitest-environment jsdom
//
// The scheduler's two number editors — a job's interval and the maintenance batch size. Both used to
// hold a MIRROR of the server value that an effect re-synced; both now show the server value and keep
// a draft only while the field is being edited. That matters here more than elsewhere
// 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, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t: (k: string) => k }) };
});
import Scheduler from "./Scheduler";
import { NavigationActionsContext } from "../../features/navigation/navigationContext";
import { api, type RunBrief, type SchedulerAdHoc, type SchedulerStatus } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
const status = (over: Partial<SchedulerStatus> = {}): SchedulerStatus => ({
running_here: true,
enabled: true,
paused: false,
jobs: [
{
id: "sync",
interval_minutes: 30,
next_run: null,
running: false,
status: "ok",
last_started: null,
last_finished: null,
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,
deep_eta_seconds: 0,
videos_pending_enrich: 0,
videos_pending_shorts: 0,
videos_live_refresh: 0,
},
quota: { used_today: 0, remaining_today: 100, daily_budget: 100, backfill_reserve: 0 },
maintenance: { revalidate_batch: 5000, revalidate_batch_default: 5000, min: 1000, max: 50000 },
...over,
});
let qc: QueryClient;
let current: SchedulerStatus;
async function mount(initial = status()) {
current = initial;
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<Scheduler />
</QueryClientProvider>,
);
await screen.findByText("scheduler.maintenance.batchLabel");
}
/** A poll landing, driven through the query the page actually makes. */
async function poll(next: SchedulerStatus) {
current = next;
await act(() => qc.refetchQueries({ queryKey: qk.scheduler() }));
}
beforeEach(() => {
vi.spyOn(api, "schedulerStatus").mockImplementation(() => Promise.resolve(current));
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const intervalBox = () => screen.queryByRole("spinbutton", { name: "" });
const batchBox = () => screen.getByLabelText("scheduler.maintenance.batchLabel");
// The FIRST FOUR tests below are CHARACTERIZATION, not regression guards for the draft sweep: the old
// JobRow's re-sync effect was already guarded by `if (!editing)` and the mirror only rendered while
// editing, so each of those four passes against the pre-sweep code too. They are here because the
// editor had no tests at all. The two after them — the range refusal and the focus return — ARE
// regression guards, for the silent-discard fix, and they fail against the code this file shipped
// with. The editor that genuinely lost the user's typing to a poll is the maintenance batch box
// below: its old effect had no guard, and that one has its own test.
describe("Scheduler · the job interval editor", () => {
it("opens on the current interval and commits a new one", async () => {
const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never);
await mount();
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
const box = intervalBox()!;
expect(box).toHaveProperty("value", "30");
fireEvent.change(box, { target: { value: "45" } });
fireEvent.keyDown(box, { key: "Enter" });
expect(intervalBox()).toBeNull(); // committing closes the editor at once
await waitFor(() => expect(update).toHaveBeenCalledWith("sync", 45));
});
it("drops what you typed on Escape and shows the server's value again", async () => {
const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never);
await mount();
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
fireEvent.change(intervalBox()!, { target: { value: "999" } });
fireEvent.keyDown(intervalBox()!, { key: "Escape" });
expect(update).not.toHaveBeenCalled();
expect(intervalBox()).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
expect(intervalBox()).toHaveProperty("value", "30");
});
it("keeps what you are typing when a poll lands underneath", async () => {
await mount();
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
fireEvent.change(intervalBox()!, { target: { value: "45" } });
await poll(status({ jobs: [{ ...status().jobs[0]!, interval_minutes: 30, running: true }] }));
expect(intervalBox()).toHaveProperty("value", "45");
});
it("follows the server while the editor is closed", async () => {
await mount();
await poll(status({ jobs: [{ ...status().jobs[0]!, interval_minutes: 90 }] }));
await waitFor(() =>
expect(screen.getByRole("button", { name: /scheduler.everyMin/ })).toBeTruthy(),
);
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
expect(intervalBox()).toHaveProperty("value", "90");
});
it("refuses a value outside the range instead of silently dropping it", async () => {
const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never);
await mount();
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
fireEvent.change(intervalBox()!, { target: { value: "99999" } });
fireEvent.keyDown(intervalBox()!, { key: "Enter" });
expect(update).not.toHaveBeenCalled();
// Still open, still showing what was typed, and saying why.
expect(intervalBox()).toHaveProperty("value", "99999");
expect(intervalBox()!.getAttribute("aria-invalid")).toBe("true");
expect(screen.getByRole("alert").textContent).toBe("scheduler.range");
});
it("hands focus back to the trigger when the editor closes", async () => {
await mount();
const trigger = screen.getByRole("button", { name: /scheduler.everyMin/ });
fireEvent.click(trigger);
expect(document.activeElement).toBe(intervalBox());
fireEvent.keyDown(intervalBox()!, { key: "Escape" });
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("button", { name: /scheduler.everyMin/ }),
),
);
});
// A real Enter re-opened the editor — the keystroke's own activation landed on the trigger
// the focus had just returned to. The closing keys cancel their default action.
it("closing by a key does not press the trigger again", async () => {
await mount();
for (const key of ["Enter", "Escape"]) {
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
expect(fireEvent.keyDown(intervalBox()!, { key })).toBe(false);
}
});
});
describe("Scheduler · the maintenance batch editor", () => {
it("keeps what you are typing when a poll changes the batch underneath", async () => {
// THE regression this editor needed: its old effect re-synced on every
// `revalidate_batch` change with no editing guard, so a poll landing mid-keystroke replaced
// what the admin had typed.
await mount();
fireEvent.change(batchBox(), { target: { value: "9000" } });
// The poll carries a new interval TOO, and the interval label follows the server unconditionally
// — so waiting for it is how this test knows the new status has actually been rendered. Without
// that anchor the assertion below passes on a page that simply has not polled yet.
await poll(
status({
jobs: [{ ...status().jobs[0]!, interval_minutes: 90 }],
maintenance: { ...status().maintenance, revalidate_batch: 12000 },
}),
);
await waitFor(() =>
expect(screen.getByRole("button", { name: /scheduler.everyMin/ })).toBeTruthy(),
);
expect(batchBox()).toHaveProperty("value", "9000");
});
it("refuses a value outside min..max instead of silently dropping it", async () => {
const save = vi.spyOn(api, "updateMaintenanceBatch").mockResolvedValue({
revalidate_batch: 5000,
});
await mount();
fireEvent.change(batchBox(), { target: { value: "10" } }); // below m.min = 1000
fireEvent.keyDown(batchBox(), { key: "Enter" });
expect(save).not.toHaveBeenCalled();
expect(batchBox()).toHaveProperty("value", "10");
expect(batchBox().getAttribute("aria-invalid")).toBe("true");
expect(screen.getByRole("alert").textContent).toBe("scheduler.range");
expect(screen.getByRole("button", { name: "common.save" })).toHaveProperty("disabled", true);
});
it("is not dirty until edited, and stops being dirty once saved", async () => {
const save = vi
.spyOn(api, "updateMaintenanceBatch")
.mockResolvedValue({ revalidate_batch: 9000 });
await mount();
const saveBtn = () => screen.getByRole("button", { name: "common.save" });
expect(saveBtn()).toHaveProperty("disabled", true);
fireEvent.change(batchBox(), { target: { value: "9000" } });
expect(saveBtn()).toHaveProperty("disabled", false);
fireEvent.keyDown(batchBox(), { key: "Enter" });
expect(saveBtn()).toHaveProperty("disabled", true); // the draft is dropped at once
await waitFor(() => expect(save).toHaveBeenCalledWith(9000));
});
it("follows a poll that changes the batch while nothing is being typed", async () => {
await mount();
expect(batchBox()).toHaveProperty("value", "5000");
await poll(status({ maintenance: { ...status().maintenance, revalidate_batch: 12000 } }));
await waitFor(() => expect(batchBox()).toHaveProperty("value", "12000"));
});
});
describe("Scheduler while paused", () => {
// Unavailable WITH a reason ("resume first"): the runs stay in the tab order, so a keyboard user
// reaches the hint, and they are inert. Natively disabled, the reason was mouse-only.
it("keeps Run all and a job's Run focusable and inert", async () => {
const runAll = vi.spyOn(api, "runAllSchedulerJobs").mockResolvedValue({ started: [] });
const runOne = vi.spyOn(api, "runSchedulerJob").mockResolvedValue({ started: true } as never);
await mount(status({ paused: true }));
for (const name of ["scheduler.runAll", "scheduler.runNow"]) {
const btn = screen.getByRole("button", { name });
expect(btn.hasAttribute("disabled")).toBe(false);
expect(btn.getAttribute("aria-disabled")).toBe("true");
fireEvent.click(btn);
}
expect(runAll).not.toHaveBeenCalled();
expect(runOne).not.toHaveBeenCalled();
});
});
describe("a job's last result", () => {
it("is named from its data, not the server's English one-liner", async () => {
const base = status();
await mount({
...base,
jobs: [
{
...base.jobs[0]!,
last_finished: new Date().toISOString(),
last_result: "new=3, failed=0",
result: { new: 3, failed: 0 },
},
],
});
// Key-echo i18n: the keys named, the counts shown.
expect(
await screen.findByText(/scheduler\.result\.new: 3 · scheduler\.result\.failed: 0/),
).toBeTruthy();
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();
});
});
describe("a row's history", () => {
// The dots show three runs; the rest is the activity page's run list, narrowed to the row's job.
it("opens the activity page's runs of that job", async () => {
const setPage = vi.fn((_page: string, then?: () => void) => then?.());
window.history.replaceState(null, "", "/?page=scheduler");
current = status();
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<NavigationActionsContext.Provider
value={{
setPage,
openChannel: () => {},
closeChannel: () => {},
enterYtSearch: () => {},
exitYtSearch: () => {},
}}
>
<QueryClientProvider client={qc}>
<Scheduler />
</QueryClientProvider>
</NavigationActionsContext.Provider>,
);
await screen.findByText("scheduler.maintenance.batchLabel");
const job = current.jobs[0]!.id;
const row = screen.getByText(`scheduler.jobs.${job}`).closest<HTMLElement>(".p-3")!;
fireEvent.click(within(row).getByRole("button", { name: "scheduler.history" }));
expect(setPage).toHaveBeenCalledWith("activity", expect.any(Function));
const url = new URLSearchParams(window.location.search);
expect([url.get("page"), url.get("rjob")]).toEqual(["activity", job]);
});
});
@@ -0,0 +1,131 @@
// @vitest-environment jsdom
//
// The Scheduler page: Run all and Pause stay in the status strip; the destructive Purge
// discovery left it for the strip's "…", which holds the page's whole list and asks before purging.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const h = vi.hoisted(() => ({ confirm: vi.fn(async () => true) }));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t: (k: string) => k }) };
});
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confirm }));
import Scheduler from "./Scheduler";
import { api, type SchedulerStatus } from "../../lib/api";
const status = (over: Partial<SchedulerStatus> = {}): SchedulerStatus => ({
running_here: true,
enabled: true,
paused: false,
jobs: [
{
id: "sync",
interval_minutes: 30,
next_run: null,
running: false,
status: "ok",
last_started: null,
last_finished: null,
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,
deep_eta_seconds: 0,
videos_pending_enrich: 0,
videos_pending_shorts: 0,
videos_live_refresh: 0,
},
quota: { used_today: 0, remaining_today: 100, daily_budget: 100, backfill_reserve: 0 },
maintenance: { revalidate_batch: 5000, revalidate_batch_default: 5000, min: 1000, max: 50000 },
...over,
});
let qc: QueryClient;
let current: SchedulerStatus;
async function mount(initial = status()) {
current = initial;
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<Scheduler />
</QueryClientProvider>,
);
await screen.findByText("scheduler.maintenance.batchLabel");
}
beforeEach(() => {
vi.spyOn(api, "schedulerStatus").mockImplementation(() => Promise.resolve(current));
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
h.confirm.mockClear();
});
const menuRows = () =>
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
async function openMenu() {
act(() => {
fireEvent.click(screen.getByRole("button", { name: "scheduler.menu" }));
});
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
);
}
const choose = (label: string) =>
fireEvent.click(
screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!,
);
describe("Scheduler · the status strip's '…'", () => {
it("keeps Run all and Pause in sight and Purge discovery only in the menu", async () => {
await mount();
expect(screen.getByRole("button", { name: /scheduler\.runAll$/ })).toBeTruthy();
expect(screen.getByRole("button", { name: /scheduler\.pause$/ })).toBeTruthy();
expect(screen.queryByRole("button", { name: /scheduler\.purgeDiscovery/ })).toBeNull();
await openMenu();
expect(menuRows()).toEqual(["scheduler.runAll", "scheduler.pause", "scheduler.purgeDiscovery"]);
});
it("purges only after the confirm says yes", async () => {
const purge = vi.spyOn(api, "purgeDiscovery").mockResolvedValue({
search_videos_deleted: 1,
videos_deleted: 0,
channels_deleted: 0,
} as never);
await mount();
h.confirm.mockResolvedValueOnce(false);
await openMenu();
choose("scheduler.purgeDiscovery");
await waitFor(() => expect(h.confirm).toHaveBeenCalledTimes(1));
expect(h.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true }));
expect(purge).not.toHaveBeenCalled();
await openMenu();
choose("scheduler.purgeDiscovery");
await waitFor(() => expect(purge).toHaveBeenCalledTimes(1));
});
it("says why Run all is off while the scheduler is paused", async () => {
await mount(status({ paused: true }));
await openMenu();
expect(menuRows()).toEqual([
"scheduler.runAllscheduler.runAllPausedHint",
"scheduler.resume",
"scheduler.purgeDiscovery",
]);
});
});
@@ -0,0 +1,830 @@
import { useEffect, useId, useRef, useState } from "react";
import { qk } from "../../lib/queryKeys";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Activity,
AlertTriangle,
Check,
Clock,
Database,
Gauge,
History,
Pause,
Pencil,
Play,
RadioTower,
Trash2,
X,
} from "lucide-react";
import {
api,
type RunBrief,
type RunFields,
type SchedulerAdHoc,
type SchedulerJob,
type SchedulerStatus,
} from "../../lib/api";
import { useLiveQuery } from "../../lib/useLiveQuery";
import { Button } from "../../components/ui/Button";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import { useConfirm } from "../../components/ui/confirmContext";
import { formatDateTime, formatNumber, formatUnit, relativeTime } from "../../lib/format";
import { notify } from "../../lib/notifications";
import Tooltip from "../../components/ui/Tooltip";
import { notifyActionError } from "../../lib/actionErrors";
import { IconButton } from "../../components/ui/IconButton";
import { Pressable } from "../../components/ui/Pressable";
import { Input } from "../../components/ui/Input";
import { SURFACE } from "../../components/ui/surfaces";
import { SectionLabel } from "../../components/ui/Headings";
import { useContextMenu } from "../../components/ui/useContextMenu";
import { ContextMenu } from "../../components/ui/ContextMenu";
import { MoreButton } from "../../components/ui/MoreButton";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { jobFailureText, jobResultText } from "../../lib/jobResult";
import { StatusDot, type DotKey } from "../activity/RunStatusDot";
import { useNavigationActions } from "../../features/navigation/navigationContext";
import { urlWithParams } from "../../lib/urlState";
const POLL_MS = 4000;
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
const FAST_POLL_MS = 1500;
// Seconds until an ISO instant (negative = past).
function secsUntil(iso: string | null): number | null {
if (!iso) return null;
return Math.round((new Date(iso).getTime() - Date.now()) / 1000);
}
function fmtCountdown(t: TFunction, s: number, lang: string): string {
if (s <= 0) return t("time.eta.now");
// The countdown speaks the UI language's units (DESIGN Copy), like every other duration.
if (s < 60) return formatUnit(s, "second", lang);
const m = Math.floor(s / 60);
if (m < 60) return `${formatUnit(m, "minute", lang)} ${formatUnit(s % 60, "second", lang)}`;
const h = Math.floor(m / 60);
return `${formatUnit(h, "hour", lang)} ${formatUnit(m % 60, "minute", lang)}`;
}
function StatusLegend() {
const { t } = useTranslation();
return (
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap text-2xs text-muted mb-2">
{(["running", "ok", "idle", "error", "skipped", "interrupted"] as const).map((k) => (
<span key={k} className="inline-flex items-center gap-1.5">
<StatusDot k={k} />
{t(`scheduler.dot.${k}`)}
</span>
))}
</div>
);
}
// 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 (
<span className="inline-flex items-center gap-1 w-10 shrink-0">
{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 (
<Tooltip key={run?.id ?? String(run)} hint={hint}>
<StatusDot k={k} label={hint} />
</Tooltip>
);
})}
</span>
);
}
/** "Last run 2 min ago · <what it did>", 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 (
<div
className="text-2xs text-muted truncate"
title={(!running && formatDateTime(row.last_finished, lang)) || undefined}
>
{running
? t("scheduler.runningNow")
: row.last_finished
? t("scheduler.lastRun", { time: relativeTime(row.last_finished, lang, t) })
: t("scheduler.neverRun")}
{target && <span> · {target}</span>}
{row.last_error ? (
<span className="text-danger">
{" · "}
{jobFailureText(t, row.result, lang) ?? row.last_error}
</span>
) : row.status === "interrupted" ? (
<span className="text-warn"> · {t("scheduler.interrupted")}</span>
) : row.result ? (
<span> · {jobResultText(t, row.result, lang)}</span>
) : row.last_result ? (
<span> · {row.last_result}</span>
) : null}
</div>
);
}
/** A row's way to its whole history: the activity page's run list, narrowed to this job. */
function HistoryButton({ job }: { job: string }) {
const { t } = useTranslation();
const { setPage } = useNavigationActions();
return (
<Tooltip hint={t("scheduler.history")}>
<IconButton
label={t("scheduler.history")}
noTitle
onClick={() =>
setPage("activity", () =>
window.history.replaceState(
window.history.state,
"",
urlWithParams({ page: "activity", rjob: job }),
),
)
}
>
<History />
</IconButton>
</Tooltip>
);
}
/** 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 (
<div className={`${SURFACE.card} p-3 flex items-center gap-3`}>
<RunDots runs={row.recent} running={row.running} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
<Tooltip hint={t(`scheduler.jobDesc.${row.id}`, "")}>
<span className="underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help">
{t(`scheduler.jobs.${row.id}`, row.id)}
</span>
</Tooltip>
</div>
<LastRun
row={row}
running={row.running}
target={row.target_title ?? row.target ?? undefined}
/>
</div>
<div className="shrink-0 text-right text-2xs text-muted">
{row.running ? (
<span className="text-accent">{t("scheduler.active")}</span>
) : (
t("scheduler.adHocStarted")
)}
</div>
<HistoryButton job={row.id} />
</div>
);
}
// 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
// that report numbers.
function JobProgress({ p }: { p: SchedulerJob["progress"] }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const phase = p?.phase ? t(`scheduler.phase.${p.phase}`, p.phase) : t("scheduler.phase.working");
const pct =
p?.total && p.total > 0 ? Math.min(100, Math.round((p.current / p.total) * 100)) : null;
return (
<div className="mt-1.5">
<div className="flex items-center justify-between text-2xs text-muted mb-0.5">
<span className="truncate">{phase}</span>
<span className="tabular-nums shrink-0">
{p?.total != null
? `${formatNumber(p.current, lang)} / ${formatNumber(p.total, lang)}`
: p
? formatNumber(p.current, lang)
: ""}
</span>
</div>
<div className="h-1.5 rounded-full bg-border overflow-hidden">
{pct != null ? (
<div
className="h-full rounded-full bg-accent transition-[width]"
style={{ width: `${pct}%` }}
/>
) : (
// Indeterminate: total unknown, so a slim moving sliver instead of a fill.
<div className="h-full w-1/3 rounded-full bg-accent motion-safe:animate-pulse" />
)}
</div>
</div>
);
}
function JobRow({
job,
onSave,
saving,
onRun,
paused,
runBusy,
}: {
job: SchedulerJob;
onSave: (minutes: number) => void;
saving: boolean;
onRun: () => void;
/** Unavailable with a reason (the hint says: resume first), so it stays focusable. */
paused: boolean;
/** A run-all in flight: transient, natively disabled. */
runBusy: boolean;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
// The box shows the server's value and holds a draft only while it is being edited, so there is
// nothing for an effect to re-sync: `null` means "follow the job". The poll behind this page
// refreshes every few seconds, which is exactly when a mirror-plus-effect would have shown the
// previous value for a frame.
const rangeId = useId();
const [draft, setDraft] = useState<string | null>(null);
const editing = draft !== null;
const val = draft ?? String(job.interval_minutes);
const next = secsUntil(job.next_run);
const m = parseInt(val, 10);
const valid = Number.isFinite(m) && m >= 1 && m <= 1440;
// Every way out of the editor unmounts the focused control, so hand focus back to the trigger that
// opened it — otherwise Escape drops a keyboard user onto <body> and the next Tab restarts at the
// top of the page. Same pattern as IssueComposer's review buttons.
const triggerRef = useRef<HTMLButtonElement | null>(null);
function close() {
setDraft(null);
queueMicrotask(() => triggerRef.current?.focus());
}
function save() {
// A value outside the range is REFUSED, not swallowed: the draft stays so the user can see and
// correct what they typed, and `aria-invalid` + the hint say why. Dropping it silently left the
// field showing the server's number with no clue whether the save had happened.
if (!valid) return;
if (m !== job.interval_minutes) onSave(m);
close();
}
return (
<div className={`${SURFACE.card} p-3 flex items-center gap-3`}>
<RunDots runs={job.recent} running={job.running} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium flex items-center gap-1.5 flex-wrap">
<Tooltip hint={t(`scheduler.jobDesc.${job.id}`, "")}>
<span className="underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help">
{t(`scheduler.jobs.${job.id}`, job.id)}
</span>
</Tooltip>
{editing ? (
<span className="inline-flex items-center gap-1">
<Input
className="w-16"
size="xs"
invalid={!valid}
type="number"
min={1}
max={1440}
value={val}
autoFocus
aria-invalid={!valid}
aria-errormessage={valid ? undefined : rangeId}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
// preventDefault: close() returns the focus to the trigger before this keystroke
// is done, and a real Enter's own activation would press it again (useReturnFocus).
if (e.key === "Enter") {
e.preventDefault();
save();
}
if (e.key === "Escape") {
e.preventDefault();
close();
}
}}
/>
<span className="text-muted text-xs">{t("scheduler.minutes")}</span>
<IconButton onClick={save} disabled={saving || !valid} label={t("common.save")}>
<Check className="text-success" />
</IconButton>
<IconButton onClick={close} label={t("common.cancel")}>
<X />
</IconButton>
{!valid && (
<span id={rangeId} role="alert" className="text-2xs text-danger">
{t("scheduler.range", { min: 1, max: 1440 })}
</span>
)}
</span>
) : (
<Tooltip hint={t("scheduler.editInterval")}>
<Pressable
ref={triggerRef}
onClick={() => setDraft(String(job.interval_minutes))}
className="group text-muted font-normal hover:text-accent inline-flex min-h-6 items-center gap-1 transition touch-target"
>
· {t("scheduler.everyMin", { count: job.interval_minutes })}
<Pencil className="w-3 h-3 reveal transition" />
</Pressable>
</Tooltip>
)}
</div>
<LastRun row={job} running={job.running} />
{job.running && <JobProgress p={job.progress} />}
</div>
<div className="shrink-0 text-right text-2xs text-muted tabular-nums">
{job.running ? (
<span className="text-accent">{t("scheduler.active")}</span>
) : next != null ? (
<>
<div className="uppercase tracking-wide text-3xs">{t("scheduler.nextRun")}</div>
<div>{fmtCountdown(t, next, lang)}</div>
</>
) : (
"—"
)}
</div>
<Tooltip
hint={paused ? t("scheduler.runAllPausedHint") : t("scheduler.runNow")}
always={paused}
>
<IconButton
onClick={onRun}
aria-disabled={paused}
disabled={job.running || runBusy}
label={t("scheduler.runNow")}
noTitle
>
<Play />
</IconButton>
</Tooltip>
<HistoryButton job={job.id} />
</div>
);
}
function MaintenanceSettings({
m,
onSave,
saving,
}: {
m: SchedulerStatus["maintenance"];
onSave: (batch: number) => void;
saving: boolean;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
// Same shape as the job row above: a draft while editing, the server's value otherwise. This is
// the editor a poll really could overwrite mid-keystroke before the draft sweep — its old effect had no
// `editing` guard at all.
const rangeId = useId();
const [draft, setDraft] = useState<string | null>(null);
const val = draft ?? String(m.revalidate_batch);
const n = parseInt(val, 10);
const valid = Number.isFinite(n) && n >= m.min && n <= m.max;
function save() {
// Out of range is REFUSED, not swallowed: keep the draft so the typed value stays visible and
// correctable, and say why. Dropping it left the field showing the server's number with nothing
// to tell the user whether the save had happened, been rejected, or been ignored.
if (!valid) return;
if (n !== m.revalidate_batch) onSave(n);
// Dropping the draft is what lets the box follow the server again once the save lands.
setDraft(null);
}
const dirty = String(m.revalidate_batch) !== val;
return (
<div className={`${SURFACE.card} p-4`}>
<div className="flex items-center gap-2 text-sm flex-wrap">
<Database className="w-4 h-4 text-muted shrink-0" />
<Tooltip hint={t("scheduler.maintenance.batchHint")}>
<span className="underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help">
{t("scheduler.maintenance.batchLabel")}
</span>
</Tooltip>
<span className="flex-1" />
<Input
className="w-28 tabular-nums"
size="sm"
invalid={!valid}
type="number"
min={m.min}
max={m.max}
step={1000}
value={val}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") save();
if (e.key === "Escape") setDraft(null);
}}
aria-label={t("scheduler.maintenance.batchLabel")}
aria-invalid={!valid}
aria-errormessage={valid ? undefined : rangeId}
/>
<Button variant="glass" size="sm" onClick={save} disabled={!dirty || saving || !valid}>
{t("common.save")}
</Button>
</div>
{!valid && (
<div id={rangeId} role="alert" className="text-2xs text-danger mt-1.5">
{t("scheduler.range", { min: m.min, max: m.max })}
</div>
)}
<div className="text-2xs text-muted mt-1.5">
{t("scheduler.maintenance.batchNote", {
default: formatNumber(m.revalidate_batch_default, lang),
})}
</div>
</div>
);
}
function Stat({
icon: Icon,
label,
value,
hint,
sub,
}: {
icon: typeof Database;
label: string;
value: string | number;
hint?: string;
sub?: string;
}) {
return (
<div className={`${SURFACE.card} p-3`}>
<div className="flex items-center gap-2 text-muted text-xs">
<Icon className="w-4 h-4 shrink-0" />
<Tooltip hint={hint ?? ""}>
<span
className={
hint
? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help"
: ""
}
>
{label}
</span>
</Tooltip>
</div>
<div className="text-xl font-semibold mt-1 tabular-nums">{value}</div>
{sub && <div className="text-2xs text-muted">{sub}</div>}
</div>
);
}
export default function Scheduler() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
const confirm = useConfirm();
// Poll faster while any job is running so live progress is smooth, then ease back when
// idle. Derived from the freshest data by react-query itself (see useLiveQuery).
const q = useLiveQuery<SchedulerStatus>(qk.scheduler(), api.schedulerStatus, {
intervalMs: (d) => (d?.jobs?.some((j) => j.running) ? FAST_POLL_MS : POLL_MS),
});
const data = q.data;
// Tick once a second so the per-job countdowns move between polls — but only while the tab is
// visible; a hidden tab doesn't need to re-render the whole page every second (SB3).
const [, setTick] = useState(0);
useEffect(() => {
let id: ReturnType<typeof setInterval> | undefined;
const start = () => {
if (id == null) id = setInterval(() => setTick((n) => n + 1), 1000);
};
const stop = () => {
if (id != null) {
clearInterval(id);
id = undefined;
}
};
const onVisibility = () => {
if (document.hidden) stop();
else {
setTick((n) => n + 1); // snap the countdown to the correct value on return
start();
}
};
onVisibility(); // honor the current visibility on mount
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
const pauseResume = useMutation({
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
onError: (e) => notifyActionError(e, t("scheduler.toggleFailed")),
});
const intervalMut = useMutation({
mutationFn: (v: { jobId: string; minutes: number }) =>
api.updateSchedulerJob(v.jobId, v.minutes),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
onError: (e) => notifyActionError(e, t("scheduler.intervalFailed")),
});
// Manual "run now" triggers. The wrapper runs in a background thread server-side; the
// live poll surfaces the "running" state within a tick, so we just nudge a refetch.
const runMut = useMutation({
mutationFn: (jobId: string) => api.runSchedulerJob(jobId),
meta: { failTitle: "errors.writeFailed.schedulerRun" },
onSuccess: (_d, jobId) => {
notify({
level: "success",
message: t("scheduler.triggered", { job: t(`scheduler.jobs.${jobId}`, jobId) }),
});
qc.invalidateQueries({ queryKey: qk.scheduler() });
},
});
const runAllMut = useMutation({
mutationFn: () => api.runAllSchedulerJobs(),
meta: { failTitle: "errors.writeFailed.schedulerRunAll" },
onSuccess: (res) => {
notify({
level: "success",
message: t("scheduler.triggeredAll", { count: res.started.length }),
});
qc.invalidateQueries({ queryKey: qk.scheduler() });
},
});
const batchMut = useMutation({
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
meta: { failTitle: "errors.writeFailed.maintenanceBatch" },
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
});
const purgeMut = useMutation({
mutationFn: () => api.purgeDiscovery(),
meta: { failTitle: "errors.writeFailed.purgeDiscovery" },
onSuccess: (res) => {
const removed =
(res.search_videos_deleted ?? 0) + (res.videos_deleted ?? 0) + (res.channels_deleted ?? 0);
notify({ level: "success", message: t("scheduler.purgedDiscovery", { count: removed }) });
qc.invalidateQueries({ queryKey: qk.scheduler() });
},
});
// The page's own menu: the status strip's "…". Run all and Pause stay in sight; the
// destructive Purge discovery lives only here, behind its confirm.
const pageMenu = useContextMenu<"scheduler">();
if (q.isError && !data)
return (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
);
if (q.isLoading && !data) return <LoadingState label={t("scheduler.loading")} />;
if (!data) return <div className="p-8 text-muted">{t("common.somethingWrong")}</div>;
async function purge() {
const ok = await confirm({
title: t("scheduler.purgeConfirmTitle"),
message: t("scheduler.purgeDiscoveryHint"),
confirmLabel: t("scheduler.purgeDiscovery"),
danger: true,
});
if (ok) purgeMut.mutate();
}
const paused = data.paused;
const pageItems: ActionMenuItem[] = [
{
id: "run-all",
label: t("scheduler.runAll"),
icon: <Play className="w-4 h-4" />,
disabled: paused || runAllMut.isPending,
note: paused ? t("scheduler.runAllPausedHint") : undefined,
onSelect: () => runAllMut.mutate(),
},
{
id: "pause",
label: paused ? t("scheduler.resume") : t("scheduler.pause"),
title: t("scheduler.pauseHint"),
icon: paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />,
disabled: pauseResume.isPending,
onSelect: () => pauseResume.mutate(paused),
},
{
id: "purge",
label: t("scheduler.purgeDiscovery"),
title: t("scheduler.purgeDiscoveryHint"),
icon: <Trash2 className="w-4 h-4" />,
danger: true,
separatorBefore: true,
disabled: purgeMut.isPending,
onSelect: () => void purge(),
},
];
const quotaPct = data.quota.daily_budget
? Math.min(100, Math.round((data.quota.used_today / data.quota.daily_budget) * 100))
: 0;
return (
<div className="p-4 max-w-4xl w-full mx-auto space-y-4">
{/* Status strip */}
<div className={`${SURFACE.panel} p-4 flex flex-wrap items-center gap-3`}>
<Activity className="w-5 h-5 text-accent shrink-0" />
<div className="min-w-0">
<div className="font-semibold">{t("scheduler.title")}</div>
<div className="text-xs text-muted flex items-center gap-2 flex-wrap">
<span className="inline-flex items-center gap-1">
<span
className={`w-2 h-2 rounded-full ${
data.paused ? "bg-warn" : data.running_here ? "bg-success" : "bg-border"
}`}
/>
{data.paused
? t("scheduler.paused")
: data.running_here
? t("scheduler.running")
: t("scheduler.notHere")}
</span>
{q.isFetching && <span className="text-accent">· {t("scheduler.updating")}</span>}
</div>
</div>
<div className="flex-1" />
<Tooltip
hint={data.paused ? t("scheduler.runAllPausedHint") : t("scheduler.runAllHint")}
always={data.paused}
>
<Button
variant="glass"
size="sm"
icon={<Play />}
onClick={() => runAllMut.mutate()}
aria-disabled={data.paused}
disabled={runAllMut.isPending}
>
{t("scheduler.runAll")}
</Button>
</Tooltip>
<Tooltip hint={t("scheduler.pauseHint")}>
<Button
variant="glass"
size="sm"
icon={data.paused ? <Play /> : <Pause />}
onClick={() => pauseResume.mutate(data.paused)}
disabled={pauseResume.isPending}
>
{data.paused ? t("scheduler.resume") : t("scheduler.pause")}
</Button>
</Tooltip>
<MoreButton
label={t("scheduler.menu")}
variant="glass"
expanded={pageMenu.open}
menuProps={pageMenu.buttonProps("scheduler")}
/>
<ContextMenu menu={pageMenu} ariaLabel={t("scheduler.menu")} items={pageItems} />
</div>
{!data.running_here && (
<div className="flex items-center gap-2 text-sm bg-warn/15 border border-warn/30 rounded-xl px-3 py-2">
<AlertTriangle className="w-4 h-4 text-warn shrink-0" />
<span className="text-muted">{t("scheduler.notHereNote")}</span>
</div>
)}
{/* Jobs */}
<div>
<SectionLabel className="mb-2">{t("scheduler.jobsTitle")}</SectionLabel>
<StatusLegend />
<div className="space-y-1.5">
{data.jobs.map((job) => (
<JobRow
key={job.id}
job={job}
saving={intervalMut.isPending}
onSave={(minutes) => intervalMut.mutate({ jobId: job.id, minutes })}
onRun={() => runMut.mutate(job.id)}
paused={data.paused}
runBusy={runAllMut.isPending}
/>
))}
</div>
</div>
{/* 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 && (
<div>
<SectionLabel className="mb-2">{t("scheduler.adHocTitle")}</SectionLabel>
<div className="space-y-1.5">
{data.ad_hoc.map((row) => (
<AdHocRow key={row.id} row={row} />
))}
</div>
</div>
)}
{/* Queue */}
<div>
<SectionLabel className="mb-2">{t("scheduler.queueTitle")}</SectionLabel>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<Stat
icon={Database}
label={t("scheduler.queue.recentPending")}
hint={t("scheduler.queue.recentPendingHint")}
value={formatNumber(data.queue.channels_recent_pending, lang)}
/>
<Stat
icon={Database}
label={t("scheduler.queue.deepPending")}
hint={t("scheduler.queue.deepPendingHint")}
value={formatNumber(data.queue.channels_deep_pending, lang)}
/>
<Stat
icon={Clock}
label={t("scheduler.queue.enrichPending")}
hint={t("scheduler.queue.enrichPendingHint")}
value={formatNumber(data.queue.videos_pending_enrich, lang)}
/>
<Stat
icon={Clock}
label={t("scheduler.queue.shortsPending")}
hint={t("scheduler.queue.shortsPendingHint")}
value={formatNumber(data.queue.videos_pending_shorts, lang)}
/>
<Stat
icon={RadioTower}
label={t("scheduler.queue.liveRefresh")}
hint={t("scheduler.queue.liveRefreshHint")}
value={formatNumber(data.queue.videos_live_refresh, lang)}
/>
</div>
</div>
{/* Quota */}
<div>
<SectionLabel className="mb-2">{t("scheduler.quotaTitle")}</SectionLabel>
<div className={`${SURFACE.card} p-4`}>
<div className="flex items-center gap-2 text-sm">
<Gauge className="w-4 h-4 text-muted shrink-0" />
<span>
{t("scheduler.quotaUsed", {
used: formatNumber(data.quota.used_today, lang),
budget: formatNumber(data.quota.daily_budget, lang),
})}
</span>
<span className="flex-1" />
<span className="text-muted text-xs">
{t("scheduler.quotaRemaining", {
count: data.quota.remaining_today,
formatted: formatNumber(data.quota.remaining_today, lang),
})}
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-border overflow-hidden">
<div
className={`h-full rounded-full ${quotaPct > 90 ? "bg-danger" : "bg-accent"}`}
style={{ width: `${quotaPct}%` }}
/>
</div>
<div className="text-2xs text-muted mt-1.5">{t("scheduler.quotaNote")}</div>
</div>
</div>
{/* Maintenance settings */}
<div>
<SectionLabel className="mb-2">{t("scheduler.maintenance.title")}</SectionLabel>
<MaintenanceSettings
m={data.maintenance}
onSave={(batch) => batchMut.mutate(batch)}
saving={batchMut.isPending}
/>
</div>
</div>
);
}