From 8cd2e6868ac1d94a38bbe69b8f9e738aa1622d2d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 16 Jun 2026 15:58:23 +0200 Subject: [PATCH] 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. --- frontend/src/components/Scheduler.tsx | 133 +++++++++++++++++--- frontend/src/i18n/locales/de/scheduler.json | 19 +++ frontend/src/i18n/locales/en/scheduler.json | 19 +++ frontend/src/i18n/locales/hu/scheduler.json | 19 +++ frontend/src/lib/api.ts | 5 + 5 files changed, 175 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 1226fb7..8ac9928 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -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 ; +const DOT_CLASS: Record = { + 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 = ; + return withTooltip ? {dot} : dot; +} + +function StatusLegend() { + const { t } = useTranslation(); + return ( +
+ {(["running", "ok", "idle", "error", "skipped"] as const).map((k) => ( + + + {t(`scheduler.dot.${k}`)} + + ))} +
+ ); +} + +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 (
- +
-
- {t(`scheduler.jobs.${job.id}`, job.id)} - - {" "} - · {t("scheduler.everyMin", { count: job.interval_minutes })} - +
+ + + {t(`scheduler.jobs.${job.id}`, job.id)} + + + {editing ? ( + + 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" + /> + {t("scheduler.minutes")} + + + + ) : ( + + + + )}
{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
{t("scheduler.loading")}
; if (!data) @@ -195,9 +282,15 @@ export default function Scheduler() { {/* Jobs */}
{t("scheduler.jobsTitle")}
+
{data.jobs.map((job) => ( - + intervalMut.mutate({ jobId: job.id, minutes })} + /> ))}
diff --git a/frontend/src/i18n/locales/de/scheduler.json b/frontend/src/i18n/locales/de/scheduler.json index e54aa38..293c752 100644 --- a/frontend/src/i18n/locales/de/scheduler.json +++ b/frontend/src/i18n/locales/de/scheduler.json @@ -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", diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json index 60bff5c..e0db313 100644 --- a/frontend/src/i18n/locales/en/scheduler.json +++ b/frontend/src/i18n/locales/en/scheduler.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index 485bf0e..bfedaf9 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -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", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 960bbe1..0615f19 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -434,6 +434,11 @@ export const api = { myUsage: (): Promise => req("/api/quota/my-usage"), adminQuota: (days = 30): Promise => req(`/api/quota/admin?days=${days}`), schedulerStatus: (): Promise => 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 }> =>