feat(scheduler): per-job help tooltips, status legend, inline interval edit

Each job shows a tooltip (what it does + what happens if it stops), a status
dot legend + per-dot tooltips clarify the colours, and the interval is inline-
editable (pencil -> number -> save) wired to the new PATCH endpoint. HU/EN/DE.
This commit is contained in:
npeter83 2026-06-16 15:58:23 +02:00
parent 84aebe16c7
commit 2a02fb353e
5 changed files with 175 additions and 20 deletions

View file

@ -4,12 +4,15 @@ 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";
@ -34,32 +37,109 @@ function fmtCountdown(s: number): string {
return `${h}h ${m % 60}m`;
}
function StatusDot({ job }: { job: SchedulerJob }) {
const color = job.running
? "bg-accent animate-pulse"
: job.status === "ok"
? "bg-emerald-500"
: job.status === "error"
? "bg-red-500"
: job.status === "skipped"
? "bg-amber-500"
: "bg-border";
return <span className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${color}`} />;
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 JobRow({ job }: { job: SchedulerJob }) {
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>
);
}
function JobRow({
job,
onSave,
saving,
}: {
job: SchedulerJob;
onSave: (minutes: number) => void;
saving: 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 job={job} />
<StatusDot k={statusKey(job)} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{t(`scheduler.jobs.${job.id}`, job.id)}
<span className="text-muted font-normal">
{" "}
· {t("scheduler.everyMin", { count: job.interval_minutes })}
</span>
<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
@ -140,6 +220,13 @@ export default function 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") }),
});
if (q.isLoading && !data)
return <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
if (!data)
@ -195,9 +282,15 @@ export default function Scheduler() {
{/* 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} />
<JobRow
key={job.id}
job={job}
saving={intervalMut.isPending}
onSave={(minutes) => intervalMut.mutate({ jobId: job.id, minutes })}
/>
))}
</div>
</div>

View file

@ -22,6 +22,25 @@
"quotaUsed": "{{used}} / {{budget}} Einheiten heute verbraucht",
"quotaRemaining": "{{formatted}} übrig",
"quotaNote": "Gemeinsames YouTube-Data-API-Budget; Reset um Mitternacht US-Pazifik. Backfill tritt zurück, um Spielraum für interaktive Nutzung zu lassen.",
"minutes": "Min",
"editInterval": "Klicken, um das Intervall zu ändern",
"intervalFailed": "Intervall konnte nicht geändert werden",
"dot": {
"running": "läuft gerade",
"ok": "letzter Lauf OK",
"idle": "untätig (in dieser Sitzung noch nicht gelaufen)",
"error": "letzter Lauf fehlgeschlagen",
"skipped": "übersprungen (Sync pausiert)"
},
"jobDesc": {
"rss_poll": "Prüft den RSS-Feed jedes Kanals auf neue Uploads (kostenlos, kein Kontingent). Fällt er aus, erscheinen neue Videos erst später, wenn ein Backfill sie erfasst — der Feed hinkt YouTube hinterher.",
"enrich": "Holt Videodetails (Dauer, Aufrufe, Live-/Shorts-Flags) und prüft Live-Videos erneut, bis sie enden. Fällt er aus, bleiben Videos ohne Dauer/Statistik und beendete Streams hängen als „live“ fest.",
"backfill": "Holt neue Uploads und dann den gesamten Verlauf vorgemerkter Kanäle, innerhalb des gemeinsamen Kontingents. Fällt er aus, werden ältere Videos und der volle Verlauf nicht ergänzt.",
"autotag": "Erkennt Sprache/Themen jedes Kanals und vergibt automatische Tags. Fällt er aus, bekommen neue Kanäle keine Auto-Tags, sodass Sprach-/Themenfilter sie verfehlen.",
"shorts": "Prüft youtube.com/shorts, um Shorts zu markieren. Fällt er aus, werden Shorts im Feed nicht von normalen Videos getrennt.",
"subscriptions": "Importiert die YouTube-Abos jedes Nutzers neu. Fällt er aus, werden auf YouTube hinzugefügte oder entfernte Abos hier nicht übernommen.",
"playlist_sync": "Spiegelt die YouTube-Wiedergabelisten jedes Nutzers in die App. Fällt er aus, erscheinen Änderungen an deinen YouTube-Listen lokal nicht."
},
"jobs": {
"rss_poll": "RSS-Abfrage (neue Uploads)",
"enrich": "Anreicherung + Live-Aktualisierung",

View file

@ -22,6 +22,25 @@
"quotaUsed": "{{used}} / {{budget}} units used today",
"quotaRemaining": "{{formatted}} left",
"quotaNote": "Shared YouTube Data API budget; resets at midnight US Pacific. Backfill yields to leave headroom for interactive use.",
"minutes": "min",
"editInterval": "Click to change how often this runs",
"intervalFailed": "Couldn't change the interval",
"dot": {
"running": "running now",
"ok": "last run OK",
"idle": "idle (not run yet this session)",
"error": "last run failed",
"skipped": "skipped (sync paused)"
},
"jobDesc": {
"rss_poll": "Checks each channel's RSS feed for new uploads (free, no quota). If it stops, new videos only appear later when a backfill pass catches them, so your feed lags behind YouTube.",
"enrich": "Fetches video details (duration, views, live/Shorts flags) and re-checks live videos until they end. If it stops, videos stay without a duration/stats and ended livestreams stay stuck as “live”.",
"backfill": "Pulls in recent uploads, then the full back-catalogue for opted-in channels, within the shared quota. If it stops, older videos and full history don't fill in.",
"autotag": "Detects each channel's language/topics and applies automatic tags. If it stops, new channels get no auto tags, so language/topic filters miss them.",
"shorts": "Probes youtube.com/shorts to mark which videos are Shorts. If it stops, Shorts aren't separated from normal videos in the feed.",
"subscriptions": "Re-imports every user's YouTube subscriptions. If it stops, channels subscribed to or dropped on YouTube aren't reflected here.",
"playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally."
},
"jobs": {
"rss_poll": "RSS poll (new uploads)",
"enrich": "Enrichment + live refresh",

View file

@ -22,6 +22,25 @@
"quotaUsed": "{{used}} / {{budget}} egység ma felhasználva",
"quotaRemaining": "{{formatted}} maradt",
"quotaNote": "Közös YouTube Data API-keret; éjfélkor (US Pacific) nullázódik. A backfill hátrébb sorol, hogy maradjon hely az interaktív használatnak.",
"minutes": "perc",
"editInterval": "Kattints a gyakoriság módosításához",
"intervalFailed": "Nem sikerült módosítani a gyakoriságot",
"dot": {
"running": "most fut",
"ok": "utolsó futás OK",
"idle": "tétlen (ebben a munkamenetben még nem futott)",
"error": "utolsó futás hibázott",
"skipped": "kihagyva (szinkron szüneteltetve)"
},
"jobDesc": {
"rss_poll": "Az egyes csatornák RSS-feedjét nézi új feltöltésekért (ingyenes, nem fogyaszt kvótát). Ha leáll, az új videók csak később jelennek meg, amikor egy backfill behúzza őket — így a hírfolyam lemarad a YouTube mögött.",
"enrich": "Lekéri a videók adatait (hossz, megtekintés, élő/Shorts jelzők), és frissíti az élő videókat, amíg véget nem érnek. Ha leáll, a videók hossz/statisztika nélkül maradnak, a véget ért adások pedig „élő” állapotban ragadnak.",
"backfill": "Behúzza a friss feltöltéseket, majd a kijelölt csatornák teljes előzményét, a közös kvótán belül. Ha leáll, a régebbi videók és a teljes előzmény nem töltődik fel.",
"autotag": "Felismeri a csatornák nyelvét/témáit, és automatikus címkéket ad. Ha leáll, az új csatornák címke nélkül maradnak, így a nyelv/téma szűrők kihagyják őket.",
"shorts": "A youtube.com/shorts próbával jelöli, mely videók Shorts-ok. Ha leáll, a Shorts-ok nem különülnek el a normál videóktól a hírfolyamban.",
"subscriptions": "Újraimportálja minden felhasználó YouTube-feliratkozásait. Ha leáll, a YouTube-on felvett vagy törölt feliratkozások nem tükröződnek itt.",
"playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan."
},
"jobs": {
"rss_poll": "RSS-lekérdezés (új feltöltések)",
"enrich": "Adatdúsítás + élő frissítés",

View file

@ -434,6 +434,11 @@ export const api = {
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
updateSchedulerJob: (jobId: string, intervalMinutes: number): Promise<{ id: string; interval_minutes: number }> =>
req(`/api/admin/scheduler/jobs/${jobId}`, {
method: "PATCH",
body: JSON.stringify({ interval_minutes: intervalMinutes }),
}),
// --- onboarding / admin ---
requestAccess: (email: string): Promise<{ status: string }> =>