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, Pause, Pencil, Play, RadioTower, Trash2, X, } from "lucide-react"; import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { Button } from "./ui/Button"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { useConfirm } from "./confirmContext"; import { formatDateTime, formatNumber, formatUnit, relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./ui/Tooltip"; import { notifyActionError } from "../lib/actionErrors"; import { IconButton } from "./ui/IconButton"; import { Pressable } from "./ui/Pressable"; import { Input } from "./ui/Input"; import { SURFACE } from "./ui/surfaces"; import { SectionLabel } from "./ui/Headings"; import { useContextMenu } from "./ui/useContextMenu"; import { ContextMenu } from "./ui/ContextMenu"; import { MoreButton } from "./ui/MoreButton"; import { type ActionMenuItem } from "./ui/ActionMenu"; import { jobResultText } from "../lib/jobResult"; 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)}`; } const DOT_CLASS: Record = { running: "bg-accent animate-pulse", ok: "bg-success", error: "bg-danger", skipped: "bg-warn", idle: "bg-border", }; 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"; } function StatusDot({ k, withTooltip = true, }: { k: keyof typeof DOT_CLASS; withTooltip?: boolean; }) { const { t } = useTranslation(); const dot = ; return withTooltip ? {dot} : dot; } function StatusLegend() { const { t } = useTranslation(); return (
{(["running", "ok", "idle", "error", "skipped"] as const).map((k) => ( {t(`scheduler.dot.${k}`)} ))}
); } // 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 (
{phase} {p?.total != null ? `${formatNumber(p.current, lang)} / ${formatNumber(p.total, lang)}` : p ? formatNumber(p.current, lang) : ""}
{pct != null ? (
) : ( // Indeterminate: total unknown, so a slim moving sliver instead of a fill.
)}
); } 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(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 and the next Tab restarts at the // top of the page. Same pattern as IssueComposer's review buttons. const triggerRef = useRef(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 (
{t(`scheduler.jobs.${job.id}`, job.id)} {editing ? ( 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(); } }} /> {t("scheduler.minutes")} {!valid && ( {t("scheduler.range", { min: 1, max: 1440 })} )} ) : ( 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 })} )}
{job.running ? t("scheduler.runningNow") : job.last_finished ? t("scheduler.lastRun", { time: relativeTime(job.last_finished, lang) }) : t("scheduler.neverRun")} {job.last_error ? ( · {job.last_error} ) : job.result ? ( · {jobResultText(t, job.result, lang)} ) : job.last_result ? ( · {job.last_result} ) : null}
{job.running && }
{job.running ? ( {t("scheduler.active")} ) : next != null ? ( <>
{t("scheduler.nextRun")}
{fmtCountdown(t, next, lang)}
) : ( "—" )}
); } 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 E27 S2 — its old effect had no // `editing` guard at all. const rangeId = useId(); const [draft, setDraft] = useState(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 (
{t("scheduler.maintenance.batchLabel")} 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} />
{!valid && ( )}
{t("scheduler.maintenance.batchNote", { default: formatNumber(m.revalidate_batch_default, lang), })}
); } function Stat({ icon: Icon, label, value, hint, sub, }: { icon: typeof Database; label: string; value: string | number; hint?: string; sub?: string; }) { return (
{label}
{value}
{sub &&
{sub}
}
); } 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(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 | 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 "…" (E28 C1). 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 ( q.refetch()}> {t("common.loadError")} ); if (q.isLoading && !data) return ; if (!data) return
{t("common.somethingWrong")}
; 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: , 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 ? : , disabled: pauseResume.isPending, onSelect: () => pauseResume.mutate(paused), }, { id: "purge", label: t("scheduler.purgeDiscovery"), title: t("scheduler.purgeDiscoveryHint"), icon: , 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 (
{/* Status strip */}
{t("scheduler.title")}
{data.paused ? t("scheduler.paused") : data.running_here ? t("scheduler.running") : t("scheduler.notHere")} {q.isFetching && · {t("scheduler.updating")}}
{!data.running_here && (
{t("scheduler.notHereNote")}
)} {/* Jobs */}
{t("scheduler.jobsTitle")}
{data.jobs.map((job) => ( intervalMut.mutate({ jobId: job.id, minutes })} onRun={() => runMut.mutate(job.id)} paused={data.paused} runBusy={runAllMut.isPending} /> ))}
{/* Queue */}
{t("scheduler.queueTitle")}
{/* Quota */}
{t("scheduler.quotaTitle")}
{t("scheduler.quotaUsed", { used: formatNumber(data.quota.used_today, lang), budget: formatNumber(data.quota.daily_budget, lang), })} {t("scheduler.quotaRemaining", { count: data.quota.remaining_today, formatted: formatNumber(data.quota.remaining_today, lang), })}
90 ? "bg-danger" : "bg-accent"}`} style={{ width: `${quotaPct}%` }} />
{t("scheduler.quotaNote")}
{/* Maintenance settings */}
{t("scheduler.maintenance.title")} batchMut.mutate(batch)} saving={batchMut.isPending} />
); }