665 lines
23 KiB
TypeScript
665 lines
23 KiB
TypeScript
import { useEffect, useId, useRef, useState } from "react";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { useTranslation } from "react-i18next";
|
|
import i18n from "../i18n";
|
|
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 { formatNumber, 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";
|
|
|
|
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 i18n.t("time.eta.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-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 = <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-2xs 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-2xs text-muted mb-0.5">
|
|
<span className="truncate">{phase}</span>
|
|
<span className="tabular-nums shrink-0">
|
|
{p?.total != null
|
|
? `${formatNumber(p.current)} / ${formatNumber(p.total)}`
|
|
: p
|
|
? formatNumber(p.current)
|
|
: ""}
|
|
</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();
|
|
// 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`}>
|
|
<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
|
|
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) => {
|
|
if (e.key === "Enter") save();
|
|
if (e.key === "Escape") 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 items-center gap-1 transition"
|
|
>
|
|
· {t("scheduler.everyMin", { count: job.interval_minutes })}
|
|
<Pencil className="w-3 h-3 reveal transition" />
|
|
</Pressable>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
<div className="text-2xs 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-danger"> · {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-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(next)}</div>
|
|
</>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</div>
|
|
<Tooltip hint={t("scheduler.runNow")}>
|
|
<IconButton
|
|
onClick={onRun}
|
|
disabled={job.running || runDisabled}
|
|
label={t("scheduler.runNow")}
|
|
noTitle
|
|
>
|
|
<Play />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MaintenanceSettings({
|
|
m,
|
|
onSave,
|
|
saving,
|
|
}: {
|
|
m: SchedulerStatus["maintenance"];
|
|
onSave: (batch: number) => void;
|
|
saving: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
// 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<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),
|
|
})}
|
|
</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 } = useTranslation();
|
|
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() });
|
|
},
|
|
});
|
|
|
|
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>;
|
|
|
|
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")}>
|
|
<Button
|
|
variant="glass"
|
|
size="sm"
|
|
icon={<Play />}
|
|
onClick={() => runAllMut.mutate()}
|
|
disabled={runAllMut.isPending || data.paused}
|
|
>
|
|
{t("scheduler.runAll")}
|
|
</Button>
|
|
</Tooltip>
|
|
<Tooltip hint={t("scheduler.purgeDiscoveryHint")}>
|
|
<Button
|
|
variant="glass"
|
|
size="sm"
|
|
onClick={async () => {
|
|
const ok = await confirm({
|
|
title: t("scheduler.purgeConfirmTitle"),
|
|
message: t("scheduler.purgeDiscoveryHint"),
|
|
confirmLabel: t("scheduler.purgeDiscovery"),
|
|
danger: true,
|
|
});
|
|
if (ok) purgeMut.mutate();
|
|
}}
|
|
disabled={purgeMut.isPending}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
{t("scheduler.purgeDiscovery")}
|
|
</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>
|
|
</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>
|
|
<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={formatNumber(data.queue.channels_recent_pending)}
|
|
/>
|
|
<Stat
|
|
icon={Database}
|
|
label={t("scheduler.queue.deepPending")}
|
|
hint={t("scheduler.queue.deepPendingHint")}
|
|
value={formatNumber(data.queue.channels_deep_pending)}
|
|
/>
|
|
<Stat
|
|
icon={Clock}
|
|
label={t("scheduler.queue.enrichPending")}
|
|
hint={t("scheduler.queue.enrichPendingHint")}
|
|
value={formatNumber(data.queue.videos_pending_enrich)}
|
|
/>
|
|
<Stat
|
|
icon={Clock}
|
|
label={t("scheduler.queue.shortsPending")}
|
|
hint={t("scheduler.queue.shortsPendingHint")}
|
|
value={formatNumber(data.queue.videos_pending_shorts)}
|
|
/>
|
|
<Stat
|
|
icon={RadioTower}
|
|
label={t("scheduler.queue.liveRefresh")}
|
|
hint={t("scheduler.queue.liveRefreshHint")}
|
|
value={formatNumber(data.queue.videos_live_refresh)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quota */}
|
|
<div>
|
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">
|
|
{t("scheduler.quotaTitle")}
|
|
</div>
|
|
<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),
|
|
budget: formatNumber(data.quota.daily_budget),
|
|
})}
|
|
</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),
|
|
})}
|
|
</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>
|
|
<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>
|
|
);
|
|
}
|