The dashboard now renders a progress bar for any running job — determinate when counts are reported, an indeterminate "working" sliver otherwise — so a scheduled run is as visible as a manual one (progress was never manual-only; the wiring is shared, but only some jobs reported and the display gated on counts). Poll faster (1.5s) while any job runs, easing back to 4s when idle, derived from the freshest data by react-query's functional refetchInterval. The earlier React-state approach to this stalled the live updates (the row froze on the first sampled value); useLiveQuery now accepts a function of the data. Trilingual phase labels for the newly-reporting jobs.
515 lines
19 KiB
TypeScript
515 lines
19 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Activity,
|
|
AlertTriangle,
|
|
Check,
|
|
Clock,
|
|
Database,
|
|
Gauge,
|
|
Pause,
|
|
Pencil,
|
|
Play,
|
|
RadioTower,
|
|
X,
|
|
} from "lucide-react";
|
|
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
|
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
|
import { relativeTime } from "../lib/format";
|
|
import { notify } from "../lib/notifications";
|
|
import Tooltip from "./Tooltip";
|
|
|
|
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(s: number): string {
|
|
if (s <= 0) return "now";
|
|
if (s < 60) return `${s}s`;
|
|
const m = Math.floor(s / 60);
|
|
if (m < 60) return `${m}m ${s % 60}s`;
|
|
const h = Math.floor(m / 60);
|
|
return `${h}h ${m % 60}m`;
|
|
}
|
|
|
|
const DOT_CLASS: Record<string, string> = {
|
|
running: "bg-accent animate-pulse",
|
|
ok: "bg-emerald-500",
|
|
error: "bg-red-500",
|
|
skipped: "bg-amber-500",
|
|
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 = <span className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${DOT_CLASS[k]}`} />;
|
|
return withTooltip ? <Tooltip hint={t(`scheduler.dot.${k}`)}>{dot}</Tooltip> : dot;
|
|
}
|
|
|
|
function StatusLegend() {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap text-[11px] text-muted mb-2">
|
|
{(["running", "ok", "idle", "error", "skipped"] as const).map((k) => (
|
|
<span key={k} className="inline-flex items-center gap-1.5">
|
|
<StatusDot k={k} withTooltip={false} />
|
|
{t(`scheduler.dot.${k}`)}
|
|
</span>
|
|
))}
|
|
</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 } = useTranslation();
|
|
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-[11px] text-muted mb-0.5">
|
|
<span className="truncate">{phase}</span>
|
|
<span className="tabular-nums shrink-0">
|
|
{p?.total != null
|
|
? `${p.current.toLocaleString()} / ${p.total.toLocaleString()}`
|
|
: p
|
|
? p.current.toLocaleString()
|
|
: ""}
|
|
</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 animate-pulse" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function JobRow({
|
|
job,
|
|
onSave,
|
|
saving,
|
|
onRun,
|
|
runDisabled,
|
|
}: {
|
|
job: SchedulerJob;
|
|
onSave: (minutes: number) => void;
|
|
saving: boolean;
|
|
onRun: () => void;
|
|
runDisabled: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [editing, setEditing] = useState(false);
|
|
const [val, setVal] = useState(String(job.interval_minutes));
|
|
const next = secsUntil(job.next_run);
|
|
useEffect(() => {
|
|
if (!editing) setVal(String(job.interval_minutes));
|
|
}, [job.interval_minutes, editing]);
|
|
|
|
function save() {
|
|
const m = parseInt(val, 10);
|
|
if (Number.isFinite(m) && m >= 1 && m <= 1440 && m !== job.interval_minutes) onSave(m);
|
|
setEditing(false);
|
|
}
|
|
|
|
return (
|
|
<div className="glass-card rounded-xl p-3 flex items-center gap-3">
|
|
<StatusDot k={statusKey(job)} />
|
|
<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
|
|
type="number"
|
|
min={1}
|
|
max={1440}
|
|
value={val}
|
|
autoFocus
|
|
onChange={(e) => setVal(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") save();
|
|
if (e.key === "Escape") setEditing(false);
|
|
}}
|
|
className="w-16 bg-card border border-border rounded px-1.5 py-0.5 text-xs outline-none focus:border-accent"
|
|
/>
|
|
<span className="text-muted text-xs">{t("scheduler.minutes")}</span>
|
|
<button onClick={save} disabled={saving} className="text-emerald-400 hover:opacity-80 disabled:opacity-50" aria-label={t("common.save")}>
|
|
<Check className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button onClick={() => setEditing(false)} className="text-muted hover:text-fg" aria-label={t("common.cancel")}>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
</span>
|
|
) : (
|
|
<Tooltip hint={t("scheduler.editInterval")}>
|
|
<button
|
|
onClick={() => setEditing(true)}
|
|
className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition"
|
|
>
|
|
· {t("scheduler.everyMin", { count: job.interval_minutes })}
|
|
<Pencil className="w-3 h-3 opacity-0 group-hover:opacity-100 transition" />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
<div className="text-[11px] text-muted truncate">
|
|
{job.running
|
|
? t("scheduler.runningNow")
|
|
: job.last_finished
|
|
? t("scheduler.lastRun", { time: relativeTime(job.last_finished) })
|
|
: t("scheduler.neverRun")}
|
|
{job.last_error ? (
|
|
<span className="text-red-400"> · {job.last_error}</span>
|
|
) : job.last_result ? (
|
|
<span> · {job.last_result}</span>
|
|
) : null}
|
|
</div>
|
|
{job.running && <JobProgress p={job.progress} />}
|
|
</div>
|
|
<div className="shrink-0 text-right text-[11px] text-muted tabular-nums">
|
|
{job.running ? (
|
|
<span className="text-accent">{t("scheduler.active")}</span>
|
|
) : next != null ? (
|
|
<>
|
|
<div className="uppercase tracking-wide text-[10px]">{t("scheduler.nextRun")}</div>
|
|
<div>{fmtCountdown(next)}</div>
|
|
</>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</div>
|
|
<Tooltip hint={t("scheduler.runNow")}>
|
|
<button
|
|
onClick={onRun}
|
|
disabled={job.running || runDisabled}
|
|
aria-label={t("scheduler.runNow")}
|
|
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-accent hover:bg-card transition disabled:opacity-30 disabled:hover:text-muted disabled:hover:bg-transparent"
|
|
>
|
|
<Play className="w-4 h-4" />
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MaintenanceSettings({
|
|
m,
|
|
onSave,
|
|
saving,
|
|
}: {
|
|
m: SchedulerStatus["maintenance"];
|
|
onSave: (batch: number) => void;
|
|
saving: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [val, setVal] = useState(String(m.revalidate_batch));
|
|
useEffect(() => setVal(String(m.revalidate_batch)), [m.revalidate_batch]);
|
|
|
|
function save() {
|
|
const n = parseInt(val, 10);
|
|
if (Number.isFinite(n) && n >= m.min && n <= m.max && n !== m.revalidate_batch) onSave(n);
|
|
}
|
|
const dirty = String(m.revalidate_batch) !== val;
|
|
|
|
return (
|
|
<div className="glass-card rounded-xl 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
|
|
type="number"
|
|
min={m.min}
|
|
max={m.max}
|
|
step={1000}
|
|
value={val}
|
|
onChange={(e) => setVal(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && save()}
|
|
className="w-28 bg-card border border-border rounded px-2 py-1 text-sm tabular-nums outline-none focus:border-accent"
|
|
/>
|
|
<button
|
|
onClick={save}
|
|
disabled={!dirty || saving}
|
|
className="glass-card glass-hover px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
|
|
>
|
|
{t("common.save")}
|
|
</button>
|
|
</div>
|
|
<div className="text-[11px] text-muted mt-1.5">
|
|
{t("scheduler.maintenance.batchNote", { default: m.revalidate_batch_default.toLocaleString() })}
|
|
</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="glass-card rounded-xl 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-[11px] text-muted">{sub}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function Scheduler() {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
// 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>(["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.
|
|
const [, setTick] = useState(0);
|
|
useEffect(() => {
|
|
const id = setInterval(() => setTick((n) => n + 1), 1000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
const pauseResume = useMutation({
|
|
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }),
|
|
onError: () => notify({ level: "error", message: t("scheduler.toggleFailed") }),
|
|
});
|
|
|
|
const intervalMut = useMutation({
|
|
mutationFn: (v: { jobId: string; minutes: number }) =>
|
|
api.updateSchedulerJob(v.jobId, v.minutes),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }),
|
|
onError: () => notify({ level: "error", message: 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),
|
|
onSuccess: (_d, jobId) => {
|
|
notify({ level: "success", message: t("scheduler.triggered", { job: t(`scheduler.jobs.${jobId}`, jobId) }) });
|
|
qc.invalidateQueries({ queryKey: ["scheduler"] });
|
|
},
|
|
});
|
|
const runAllMut = useMutation({
|
|
mutationFn: () => api.runAllSchedulerJobs(),
|
|
onSuccess: (res) => {
|
|
notify({ level: "success", message: t("scheduler.triggeredAll", { count: res.started.length }) });
|
|
qc.invalidateQueries({ queryKey: ["scheduler"] });
|
|
},
|
|
});
|
|
const batchMut = useMutation({
|
|
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }),
|
|
});
|
|
|
|
if (q.isLoading && !data)
|
|
return <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
|
|
if (!data)
|
|
return <div className="p-8 text-muted">{t("common.somethingWrong")}</div>;
|
|
|
|
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="glass rounded-2xl 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-amber-500" : data.running_here ? "bg-emerald-500" : "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")}>
|
|
<button
|
|
onClick={() => runAllMut.mutate()}
|
|
disabled={runAllMut.isPending || data.paused}
|
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
|
>
|
|
<Play className="w-4 h-4" />
|
|
{t("scheduler.runAll")}
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip hint={t("scheduler.pauseHint")}>
|
|
<button
|
|
onClick={() => pauseResume.mutate(data.paused)}
|
|
disabled={pauseResume.isPending}
|
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
|
>
|
|
{data.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
|
{data.paused ? t("scheduler.resume") : t("scheduler.pause")}
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
{!data.running_here && (
|
|
<div className="flex items-center gap-2 text-sm bg-amber-500/15 border border-amber-500/30 rounded-xl px-3 py-2">
|
|
<AlertTriangle className="w-4 h-4 text-amber-500 shrink-0" />
|
|
<span className="text-muted">{t("scheduler.notHereNote")}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Jobs */}
|
|
<div>
|
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.jobsTitle")}</div>
|
|
<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)}
|
|
runDisabled={data.paused || runAllMut.isPending}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Queue */}
|
|
<div>
|
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.queueTitle")}</div>
|
|
<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={data.queue.channels_recent_pending.toLocaleString()}
|
|
/>
|
|
<Stat
|
|
icon={Database}
|
|
label={t("scheduler.queue.deepPending")}
|
|
hint={t("scheduler.queue.deepPendingHint")}
|
|
value={data.queue.channels_deep_pending.toLocaleString()}
|
|
/>
|
|
<Stat
|
|
icon={Clock}
|
|
label={t("scheduler.queue.enrichPending")}
|
|
hint={t("scheduler.queue.enrichPendingHint")}
|
|
value={data.queue.videos_pending_enrich.toLocaleString()}
|
|
/>
|
|
<Stat
|
|
icon={Clock}
|
|
label={t("scheduler.queue.shortsPending")}
|
|
hint={t("scheduler.queue.shortsPendingHint")}
|
|
value={data.queue.videos_pending_shorts.toLocaleString()}
|
|
/>
|
|
<Stat
|
|
icon={RadioTower}
|
|
label={t("scheduler.queue.liveRefresh")}
|
|
hint={t("scheduler.queue.liveRefreshHint")}
|
|
value={data.queue.videos_live_refresh.toLocaleString()}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quota */}
|
|
<div>
|
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.quotaTitle")}</div>
|
|
<div className="glass-card rounded-xl 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: data.quota.used_today.toLocaleString(),
|
|
budget: data.quota.daily_budget.toLocaleString(),
|
|
})}
|
|
</span>
|
|
<span className="flex-1" />
|
|
<span className="text-muted text-xs">
|
|
{t("scheduler.quotaRemaining", {
|
|
count: data.quota.remaining_today,
|
|
formatted: data.quota.remaining_today.toLocaleString(),
|
|
})}
|
|
</span>
|
|
</div>
|
|
<div className="mt-2 h-2 rounded-full bg-border overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full ${quotaPct > 90 ? "bg-red-500" : "bg-accent"}`}
|
|
style={{ width: `${quotaPct}%` }}
|
|
/>
|
|
</div>
|
|
<div className="text-[11px] text-muted mt-1.5">{t("scheduler.quotaNote")}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Maintenance settings */}
|
|
<div>
|
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.maintenance.title")}</div>
|
|
<MaintenanceSettings
|
|
m={data.maintenance}
|
|
onSave={(batch) => batchMut.mutate(batch)}
|
|
saving={batchMut.isPending}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|