From 73b06b1fb6de245139de7bb729a5b3fabbf66987 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 16 Jun 2026 14:38:51 +0200 Subject: [PATCH] feat(scheduler): live admin Scheduler dashboard + reusable poll hook (HU/EN/DE) New admin Scheduler page (left-nav entry) with a live, self-refreshing view of job activity, queued work and quota. Polling is factored into a reusable useLiveQuery hook (pauses when the tab is unfocused) that the notification bell and future yt-dlp job queue will reuse instead of re-implementing. --- frontend/src/App.tsx | 6 +- frontend/src/components/Header.tsx | 4 +- frontend/src/components/NavSidebar.tsx | 5 +- frontend/src/components/Scheduler.tsx | 273 ++++++++++++++++++++ frontend/src/i18n/locales/de/header.json | 2 + frontend/src/i18n/locales/de/scheduler.json | 46 ++++ frontend/src/i18n/locales/en/header.json | 2 + frontend/src/i18n/locales/en/scheduler.json | 46 ++++ frontend/src/i18n/locales/hu/header.json | 2 + frontend/src/i18n/locales/hu/scheduler.json | 46 ++++ frontend/src/lib/api.ts | 34 +++ frontend/src/lib/urlState.ts | 8 +- frontend/src/lib/useLiveQuery.ts | 22 ++ 13 files changed, 491 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/Scheduler.tsx create mode 100644 frontend/src/i18n/locales/de/scheduler.json create mode 100644 frontend/src/i18n/locales/en/scheduler.json create mode 100644 frontend/src/i18n/locales/hu/scheduler.json create mode 100644 frontend/src/lib/useLiveQuery.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 688a049..7501839 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ import Feed from "./components/Feed"; import Channels, { type ChannelStatusFilter } from "./components/Channels"; import Playlists from "./components/Playlists"; import Stats from "./components/Stats"; +import Scheduler from "./components/Scheduler"; import SettingsPanel from "./components/SettingsPanel"; import OnboardingWizard from "./components/OnboardingWizard"; import { shouldAutoOpenOnboarding } from "./lib/onboarding"; @@ -62,7 +63,8 @@ function loadInitialPage(): Page { stored === "channels" || stored === "stats" || stored === "playlists" || - stored === "settings" + stored === "settings" || + stored === "scheduler" ) return stored; return "feed"; @@ -271,6 +273,8 @@ export default function App() { /> ) : page === "stats" && meQuery.data!.role === "admin" ? ( + ) : page === "scheduler" && meQuery.data!.role === "admin" ? ( + ) : page === "playlists" ? ( ) : page === "settings" ? ( diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index f5c3bdc..5afe782 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -78,7 +78,9 @@ export default function Header({ ? t("header.account.playlists") : page === "settings" ? t("settings.title") - : t("header.channelManager")} + : page === "scheduler" + ? t("header.scheduler") + : t("header.channelManager")} )} diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 0d8c75b..cb6d989 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -3,6 +3,7 @@ import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { + Activity, BarChart3, ChevronLeft, ChevronRight, @@ -109,8 +110,10 @@ export default function NavSidebar({ { page: "channels", icon: Tv, label: t("header.account.channels") }, { page: "playlists", icon: ListVideo, label: t("header.account.playlists") }, ]; - if (me.role === "admin") + if (me.role === "admin") { items.push({ page: "stats", icon: BarChart3, label: t("header.account.stats") }); + items.push({ page: "scheduler", icon: Activity, label: t("header.account.scheduler") }); + } const rowBase = "w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition"; diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx new file mode 100644 index 0000000..1226fb7 --- /dev/null +++ b/frontend/src/components/Scheduler.tsx @@ -0,0 +1,273 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + Activity, + AlertTriangle, + Clock, + Database, + Gauge, + Pause, + Play, + RadioTower, +} 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; + +// 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`; +} + +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 ; +} + +function JobRow({ job }: { job: SchedulerJob }) { + const { t } = useTranslation(); + const next = secsUntil(job.next_run); + return ( +
+ +
+
+ {t(`scheduler.jobs.${job.id}`, job.id)} + + {" "} + · {t("scheduler.everyMin", { count: job.interval_minutes })} + +
+
+ {job.running + ? t("scheduler.runningNow") + : job.last_finished + ? t("scheduler.lastRun", { time: relativeTime(job.last_finished) }) + : t("scheduler.neverRun")} + {job.last_error ? ( + · {job.last_error} + ) : job.last_result ? ( + · {job.last_result} + ) : null} +
+
+
+ {job.running ? ( + {t("scheduler.active")} + ) : next != null ? ( + <> +
{t("scheduler.nextRun")}
+
{fmtCountdown(next)}
+ + ) : ( + "—" + )} +
+
+ ); +} + +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 } = useTranslation(); + const qc = useQueryClient(); + const q = useLiveQuery(["scheduler"], api.schedulerStatus, { + intervalMs: 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") }), + }); + + if (q.isLoading && !data) + return
{t("scheduler.loading")}
; + if (!data) + return
{t("common.somethingWrong")}
; + + 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) => ( + + ))} +
+
+ + {/* Queue */} +
+
{t("scheduler.queueTitle")}
+
+ + + + + +
+
+ + {/* Quota */} +
+
{t("scheduler.quotaTitle")}
+
+
+ + + {t("scheduler.quotaUsed", { + used: data.quota.used_today.toLocaleString(), + budget: data.quota.daily_budget.toLocaleString(), + })} + + + + {t("scheduler.quotaRemaining", { + count: data.quota.remaining_today, + formatted: data.quota.remaining_today.toLocaleString(), + })} + +
+
+
90 ? "bg-red-500" : "bg-accent"}`} + style={{ width: `${quotaPct}%` }} + /> +
+
{t("scheduler.quotaNote")}
+
+
+
+ ); +} diff --git a/frontend/src/i18n/locales/de/header.json b/frontend/src/i18n/locales/de/header.json index e220b88..6c24304 100644 --- a/frontend/src/i18n/locales/de/header.json +++ b/frontend/src/i18n/locales/de/header.json @@ -3,6 +3,7 @@ "searchPlaceholder": "Deine Abos durchsuchen…", "channelManager": "Kanalverwaltung", "usageStats": "Nutzung & Statistik", + "scheduler": "Planer", "scope": { "label": "Feed-Quelle", "my": "Meine", @@ -16,6 +17,7 @@ "channels": "Kanäle", "playlists": "Wiedergabelisten", "stats": "Statistik", + "scheduler": "Planer", "settings": "Einstellungen", "about": "Über", "signOut": "Abmelden", diff --git a/frontend/src/i18n/locales/de/scheduler.json b/frontend/src/i18n/locales/de/scheduler.json new file mode 100644 index 0000000..e54aa38 --- /dev/null +++ b/frontend/src/i18n/locales/de/scheduler.json @@ -0,0 +1,46 @@ +{ + "title": "Hintergrund-Planer", + "loading": "Planer wird geladen…", + "running": "Läuft", + "paused": "Pausiert", + "notHere": "Läuft in dieser Instanz nicht", + "notHereNote": "Der Planer läuft auf dem Server. Diese Instanz zeigt die gemeinsame Warteschlange und das Kontingent, aber die Live-Job-Aktivität erscheint nur dort, wo der Planer läuft.", + "updating": "wird aktualisiert…", + "pause": "Pausieren", + "resume": "Fortsetzen", + "pauseHint": "Die gesamte Hintergrund-Synchronisierung der App pausieren oder fortsetzen (für alle Nutzer).", + "toggleFailed": "Der Planer-Status konnte nicht geändert werden", + "jobsTitle": "Jobs", + "queueTitle": "Wartende Arbeit", + "quotaTitle": "Gemeinsames API-Kontingent", + "everyMin": "alle {{count}} Min", + "runningNow": "Läuft gerade…", + "lastRun": "Letzter Lauf {{time}}", + "neverRun": "In dieser Sitzung noch nicht gelaufen", + "active": "aktiv", + "nextRun": "nächster", + "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.", + "jobs": { + "rss_poll": "RSS-Abfrage (neue Uploads)", + "enrich": "Anreicherung + Live-Aktualisierung", + "backfill": "Backfill (aktuell + ganze Historie)", + "autotag": "Automatisches Tagging", + "shorts": "Shorts-Klassifizierung", + "subscriptions": "Abo-Resync", + "playlist_sync": "YouTube-Wiedergabelisten-Sync" + }, + "queue": { + "recentPending": "Zu synchronisierende Kanäle", + "recentPendingHint": "Kanäle, deren neueste Uploads noch nicht abgerufen wurden.", + "deepPending": "Ganze Historie ausstehend", + "deepPendingHint": "Für vollständigen Verlauf vorgemerkte Kanäle, die noch nicht fertig sind.", + "enrichPending": "Zu anreichernde Videos", + "enrichPendingHint": "Videos, die noch auf Details warten (Dauer, Statistiken, Live-/Short-Klassifizierung).", + "shortsPending": "Zu klassifizierende Shorts", + "shortsPendingHint": "Bereits angereicherte Videos, die noch auf die Shorts-Prüfung warten.", + "liveRefresh": "Live zu aktualisieren", + "liveRefreshHint": "Live-/bevorstehende Videos (und gerade beendete Streams ohne Dauer), bis sie sich stabilisieren." + } +} diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json index e6f29dc..794deea 100644 --- a/frontend/src/i18n/locales/en/header.json +++ b/frontend/src/i18n/locales/en/header.json @@ -3,6 +3,7 @@ "searchPlaceholder": "Search your subscriptions…", "channelManager": "Channel manager", "usageStats": "Usage & stats", + "scheduler": "Scheduler", "scope": { "label": "Feed source", "my": "Mine", @@ -16,6 +17,7 @@ "channels": "Channels", "playlists": "Playlists", "stats": "Stats", + "scheduler": "Scheduler", "settings": "Settings", "about": "About", "signOut": "Sign out", diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json new file mode 100644 index 0000000..60bff5c --- /dev/null +++ b/frontend/src/i18n/locales/en/scheduler.json @@ -0,0 +1,46 @@ +{ + "title": "Background scheduler", + "loading": "Loading scheduler…", + "running": "Running", + "paused": "Paused", + "notHere": "Not running in this instance", + "notHereNote": "The scheduler runs on the server. This instance shows the shared queue and quota, but live job activity appears only where the scheduler runs.", + "updating": "updating…", + "pause": "Pause", + "resume": "Resume", + "pauseHint": "Pause or resume all background sync for the whole app (every user).", + "toggleFailed": "Couldn't change the scheduler state", + "jobsTitle": "Jobs", + "queueTitle": "Work queued", + "quotaTitle": "Shared API quota", + "everyMin": "every {{count}} min", + "runningNow": "Running now…", + "lastRun": "Last run {{time}}", + "neverRun": "Hasn't run yet this session", + "active": "active", + "nextRun": "next", + "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.", + "jobs": { + "rss_poll": "RSS poll (new uploads)", + "enrich": "Enrichment + live refresh", + "backfill": "Backfill (recent + full history)", + "autotag": "Auto-tagging", + "shorts": "Shorts classification", + "subscriptions": "Subscription resync", + "playlist_sync": "YouTube playlist sync" + }, + "queue": { + "recentPending": "Channels to sync", + "recentPendingHint": "Channels whose recent uploads haven't been fetched yet.", + "deepPending": "Full-history pending", + "deepPendingHint": "Channels opted into full-history backfill that aren't finished yet.", + "enrichPending": "Videos to enrich", + "enrichPendingHint": "Videos awaiting details (duration, stats, live/short classification).", + "shortsPending": "Shorts to classify", + "shortsPendingHint": "Enriched videos still awaiting the Shorts probe.", + "liveRefresh": "Live to refresh", + "liveRefreshHint": "Live/upcoming videos (and just-ended streams without a duration yet) re-checked until they settle." + } +} diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json index 16f3380..0e8b17f 100644 --- a/frontend/src/i18n/locales/hu/header.json +++ b/frontend/src/i18n/locales/hu/header.json @@ -3,6 +3,7 @@ "searchPlaceholder": "Keresés a feliratkozásaid között…", "channelManager": "Csatornakezelő", "usageStats": "Használat és statisztika", + "scheduler": "Ütemező", "scope": { "label": "Hírfolyam forrása", "my": "Sajátom", @@ -16,6 +17,7 @@ "channels": "Csatornák", "playlists": "Lejátszási listák", "stats": "Statisztika", + "scheduler": "Ütemező", "settings": "Beállítások", "about": "Névjegy", "signOut": "Kijelentkezés", diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json new file mode 100644 index 0000000..485bf0e --- /dev/null +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -0,0 +1,46 @@ +{ + "title": "Háttér-ütemező", + "loading": "Ütemező betöltése…", + "running": "Fut", + "paused": "Szüneteltetve", + "notHere": "Ebben a példányban nem fut", + "notHereNote": "Az ütemező a szerveren fut. Ez a példány a közös sort és kvótát mutatja, de az élő job-aktivitás csak ott látszik, ahol az ütemező fut.", + "updating": "frissítés…", + "pause": "Szüneteltetés", + "resume": "Folytatás", + "pauseHint": "A teljes app háttér-szinkronjának szüneteltetése vagy folytatása (minden felhasználóra).", + "toggleFailed": "Nem sikerült módosítani az ütemező állapotát", + "jobsTitle": "Feladatok", + "queueTitle": "Sorban álló munka", + "quotaTitle": "Közös API-kvóta", + "everyMin": "{{count}} percenként", + "runningNow": "Most fut…", + "lastRun": "Utolsó futás {{time}}", + "neverRun": "Ebben a munkamenetben még nem futott", + "active": "aktív", + "nextRun": "köv.", + "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.", + "jobs": { + "rss_poll": "RSS-lekérdezés (új feltöltések)", + "enrich": "Adatdúsítás + élő frissítés", + "backfill": "Backfill (friss + teljes előzmény)", + "autotag": "Automatikus címkézés", + "shorts": "Shorts-besorolás", + "subscriptions": "Feliratkozások újraszinkronja", + "playlist_sync": "YouTube lejátszási listák szinkronja" + }, + "queue": { + "recentPending": "Szinkronra váró csatorna", + "recentPendingHint": "Csatornák, amelyek friss feltöltéseit még nem kértük le.", + "deepPending": "Teljes előzmény függőben", + "deepPendingHint": "Teljes előzményre kijelölt csatornák, amelyek még nincsenek kész.", + "enrichPending": "Dúsításra váró videó", + "enrichPendingHint": "Videók, amelyek még adatokra várnak (hossz, statisztikák, live/short besorolás).", + "shortsPending": "Besorolásra váró Shorts", + "shortsPendingHint": "Már dúsított videók, amelyek még a Shorts-próbára várnak.", + "liveRefresh": "Frissítendő élő", + "liveRefreshHint": "Élő/közelgő videók (és a frissen véget ért, hossz nélküli adások), amíg le nem ülepednek." + } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 647102a..960bbe1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -303,6 +303,39 @@ export interface ManagedChannel { backfill_done: boolean; } +export interface SchedulerJob { + id: string; + interval_minutes: number; + next_run: string | null; + running: boolean; + status: "ok" | "error" | "skipped" | null; + last_started: string | null; + last_finished: string | null; + last_result: string | null; + last_error: string | null; +} + +export interface SchedulerStatus { + running_here: boolean; + enabled: boolean; + paused: boolean; + jobs: SchedulerJob[]; + queue: { + channels_recent_pending: number; + channels_deep_pending: number; + deep_eta_seconds: number; + videos_pending_enrich: number; + videos_pending_shorts: number; + videos_live_refresh: number; + }; + quota: { + used_today: number; + remaining_today: number; + daily_budget: number; + backfill_reserve: number; + }; +} + export interface Account { id: number; email: string; @@ -400,6 +433,7 @@ export const api = { // --- quota usage --- myUsage: (): Promise => req("/api/quota/my-usage"), adminQuota: (days = 30): Promise => req(`/api/quota/admin?days=${days}`), + schedulerStatus: (): Promise => req("/api/admin/scheduler"), // --- onboarding / admin --- requestAccess: (email: string): Promise<{ status: string }> => diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index a0ad9d2..ab2a38b 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -78,11 +78,15 @@ export function hasFilterParams(params: URLSearchParams): boolean { return KEYS.some((k) => params.has(k)); } -export type Page = "feed" | "channels" | "stats" | "playlists" | "settings"; +export type Page = "feed" | "channels" | "stats" | "playlists" | "settings" | "scheduler"; export function readPage(): Page { const p = new URLSearchParams(window.location.search).get("page"); - return p === "channels" || p === "stats" || p === "playlists" || p === "settings" + return p === "channels" || + p === "stats" || + p === "playlists" || + p === "settings" || + p === "scheduler" ? p : "feed"; } diff --git a/frontend/src/lib/useLiveQuery.ts b/frontend/src/lib/useLiveQuery.ts new file mode 100644 index 0000000..121045b --- /dev/null +++ b/frontend/src/lib/useLiveQuery.ts @@ -0,0 +1,22 @@ +import { useQuery, type QueryKey } from "@tanstack/react-query"; + +// Reusable "live" polling query: a thin wrapper over react-query that refetches on an +// interval and — by leaving refetchIntervalInBackground at its default false — pauses while +// the tab is unfocused, so it doesn't poll a server nobody's watching. This is the shared +// live-progress mechanism: the Scheduler dashboard uses it now; the notification bell and the +// future yt-dlp job queue reuse it rather than each re-implementing polling. +export function useLiveQuery( + key: QueryKey, + queryFn: () => Promise, + opts: { intervalMs?: number; enabled?: boolean } = {} +) { + const { intervalMs = 4000, enabled = true } = opts; + return useQuery({ + queryKey: key, + queryFn, + enabled, + refetchInterval: enabled ? intervalMs : false, + refetchIntervalInBackground: false, + staleTime: 0, + }); +}