From 6db64230eb2f25f8c8e394b889edb2f7250fc069 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sun, 26 Jul 2026 12:59:08 +0200 Subject: [PATCH] fix(dashboard): zwei Placebos raus, Restzeit rein - und Loeschen sagt die Zahl Commander: "Der Server Status muss dringend ueberarbeitet werden, das Dashboard soll ja quasi alles auf einen Blick zeigen." Berechtigt - die Karte hiess "Echte Live-Daten" und enthielt drei Angaben, von denen zwei erfunden waren: 1. "Auslastung: 0 % (Aktiv)" war NICHT die CPU-Last, sondern der Fortschritt des Jobs - bzw. eine feste 15 bzw. 5, wenn keiner lief. Rippy misst nirgends CPU-Last, also wird sie auch nicht behauptet. 2. Die Verlaufskurve daneben war Math.random(). Reine Dekoration, die wie eine Messung aussah. 3. "N Worker Online" zaehlte die registrierten Eintraege aus /system/info - auch Leichen alter Container-Rebuilds. Die echte Erreichbarkeit steht in /capabilities (Celery-Ping) und wird jetzt von dort geholt. Statt dessen: laufende Phase im Klartext mit RESTZEIT, jeder erreichbare Worker mit Kernen/Vektorbefehlen/extern, freier Platz mit Warnung, wenn er nicht mehr fuer eine Disc reicht. Und wenn kein Worker antwortet, steht das rot da statt "1 Worker Online". Restzeit auch im aktiven Rip-Banner und in der Job-Tabelle. Solange die Datenlage duenn ist, steht dort "Restzeit wird gemessen" - keine erfundene Zahl. Job entfernen fragt jetzt vorher nach den Rohdaten und nennt die GB, die daneben liegen bleiben und danach nicht mehr erreichbar sind - auf Wunsch loescht es sie mit. Der Fall aus v3.14, bei dem 75 GB unsichtbar verwaisten. Co-Authored-By: Claude Opus 5 --- docker/ui/src/components/ConfirmDialog.tsx | 6 + docker/ui/src/pages/Dashboard.tsx | 291 ++++++++++++++++----- 2 files changed, 233 insertions(+), 64 deletions(-) diff --git a/docker/ui/src/components/ConfirmDialog.tsx b/docker/ui/src/components/ConfirmDialog.tsx index 4ec37f4..54d7acc 100644 --- a/docker/ui/src/components/ConfirmDialog.tsx +++ b/docker/ui/src/components/ConfirmDialog.tsx @@ -1,3 +1,4 @@ +import { ReactNode } from 'react' import { AlertTriangle, CheckCircle2, HelpCircle } from 'lucide-react' import { Modal } from './ui/Modal' import { Button } from './ui/Button' @@ -8,6 +9,9 @@ interface ConfirmDialogProps { onConfirm: () => void title: string message: string + // Zusatz unter der Meldung — z. B. ein Kasten mit der Größe der Rohdaten, + // die beim Entfernen liegen bleiben, samt Wahl „mitlöschen". + extra?: ReactNode confirmText?: string cancelText?: string type?: 'warning' | 'info' | 'success' @@ -19,6 +23,7 @@ export default function ConfirmDialog({ onConfirm, title, message, + extra, confirmText = 'Bestätigen', cancelText = 'Abbrechen', type = 'info', @@ -57,6 +62,7 @@ export default function ConfirmDialog({

{message}

+ {extra} diff --git a/docker/ui/src/pages/Dashboard.tsx b/docker/ui/src/pages/Dashboard.tsx index dc65adb..4976555 100644 --- a/docker/ui/src/pages/Dashboard.tsx +++ b/docker/ui/src/pages/Dashboard.tsx @@ -31,6 +31,10 @@ interface Job { title?: string can_retry?: boolean meta?: JobMeta | null + // Restzeit von der API (eta.py). '' = noch keine Aussage möglich — dann wird + // bewusst nichts angezeigt statt einer erfundenen Zahl. + eta_sekunden?: number + eta_text?: string } interface SystemInfo { @@ -39,6 +43,16 @@ interface SystemInfo { workers: { name: string; encoders: string[] }[] } +// Ein Worker aus GET /capabilities — inklusive der ECHTEN Erreichbarkeit. +// /system/info liefert nur die registrierten Einträge; die Kachel „N Worker +// online" zählte damit auch Leichen aus alten Rebuilds mit (Befund 26.07.2026). +interface WorkerLive { + name: string + encoders: string[] + online?: boolean + info?: { cpu_modell?: string, cpu_kerne?: string, cpu_simd?: string, extern?: string } +} + function posterUrl(meta?: JobMeta | null): string | null { const p = meta?.poster_path if (!p) return null @@ -60,14 +74,47 @@ export default function Dashboard() { const [loading, setLoading] = useState(true) const [detailJobId, setDetailJobId] = useState(null) const [aufraeumenOffen, setAufraeumenOffen] = useState(false) + // Job, dessen Entfernen gerade bestätigt werden soll — plus was daneben liegt. + const [loeschKandidat, setLoeschKandidat] = useState(null) + const [rohdaten, setRohdaten] = useState<{ gb: number, dateien: number, pfade: string[] } | null>(null) + const [rohdatenMitloeschen, setRohdatenMitloeschen] = useState(false) const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all') - const [sparklinePoints, setSparklinePoints] = useState([20, 35, 15, 45, 30, 60, 40, 75, 50, 65]) + const [workersLive, setWorkersLive] = useState([]) const { toast } = useToast() - const jobLoeschen = async (job: Job) => { + /* + * Entfernen fragt jetzt ZUERST, was daneben liegen bleibt. + * + * Grund (Vorfall 25.07.2026, v3.14): „Job aus der Liste entfernen" löscht + * bewusst keine Dateien. Das ist richtig — aber Job und Rohdaten sind nur + * über die Job-ID verbunden, der Rohschnitt ist danach also UNERREICHBAR: + * nichts zeigt mehr darauf, „Neu komprimieren" ist unmöglich, und im UI ist + * nichts davon zu sehen. Damals verwaisten so 75 GB unsichtbar auf der + * Platte, gefunden erst per SSH. + */ + const loeschenVorbereiten = async (job: Job) => { + setLoeschKandidat(job) + setRohdaten(null) + setRohdatenMitloeschen(false) try { - await api.delete(`/jobs/${job.id}`) - toast('success', `„${job.title || job.id.slice(0, 8)}" aus der Liste entfernt`) + const r = await api.get(`/jobs/${job.id}/rohdaten`) + setRohdaten(r.data) + } catch { + setRohdaten(null) // dann eben ohne Zahl fragen, statt gar nicht + } + } + + const jobLoeschen = async () => { + const job = loeschKandidat + if (!job) return + setLoeschKandidat(null) + try { + const r = await api.delete(`/jobs/${job.id}`, { + params: rohdatenMitloeschen ? { rohdaten: true } : {}, + }) + const befreit = r.data?.rohdaten_geloescht_gb + toast('success', `„${job.title || job.id.slice(0, 8)}" aus der Liste entfernt` + + (befreit ? ` — ${befreit} GB Rohdaten gelöscht` : '')) setJobs(await fetchJobs()) } catch (e: any) { toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen') @@ -88,16 +135,18 @@ export default function Dashboard() { useEffect(() => { const loadData = async () => { try { - const [jobsData, sysData] = await Promise.all([ + const [jobsData, sysData, capsData] = await Promise.all([ fetchJobs(), api.get('/system/info').then(r => r.data).catch(() => null), + // Für die ECHTE Online-Zahl: /capabilities kennt den Celery-Ping, + // /system/info nur die Registrierung. Der Endpunkt ist seit v3.15 + // schnell (0,003 s, Ping läuft im Hintergrund) — er darf hier also + // im 4-Sekunden-Takt mitlaufen. + api.get('/capabilities').then(r => r.data?.workers || []).catch(() => []), ]) setJobs(jobsData) if (sysData) setSystemInfo(sysData) - - const isBusy = jobsData.some((j: Job) => j.status === 'processing' || j.status === 'transcoding') - const newPoint = isBusy ? Math.floor(Math.random() * 40) + 60 : Math.floor(Math.random() * 25) + 10 - setSparklinePoints(prev => [...prev.slice(1), newPoint]) + setWorkersLive(capsData) } finally { setLoading(false) } @@ -113,18 +162,37 @@ export default function Dashboard() { const completedJobs = jobs.filter(j => j.status === 'completed') const failedJobs = jobs.filter(j => j.status === 'failed') + /* + * Was diese Karte anzeigt — und was sie NICHT mehr anzeigt. + * + * Commander 26.07.2026: „Der Server Status muss dringend überarbeitet werden, + * das Dashboard soll ja quasi alles auf einen Blick zeigen." Berechtigt: Die + * Karte hieß „Echte Live-Daten" und enthielt drei Angaben, von denen zwei + * erfunden waren. + * + * 1. „Auslastung: 0 % (Aktiv)" war NICHT die CPU-Last, sondern der + * Fortschritt des Jobs — bzw. eine feste 15/5, wenn keiner lief. Rippy + * misst nirgends CPU-Last, also wird sie auch nicht behauptet. + * 2. Die Verlaufskurve daneben war `Math.random()`. Reine Dekoration, die + * wie eine Messung aussah. + * 3. „N Worker Online" zählte die registrierten Einträge aus + * /system/info — auch Leichen alter Container-Rebuilds. Die echte + * Erreichbarkeit steht in /capabilities (Celery-Ping). + * + * Statt dessen stehen jetzt Angaben, die es wirklich gibt: laufende Phase mit + * Restzeit, erreichbare Worker mit ihrer Rechenleistung, freier Platz. + */ const mainPlatz = systemInfo?.plaetze?.[0] const freiGb = mainPlatz ? mainPlatz.frei_gb : 0 const gesamtGb = mainPlatz ? mainPlatz.gesamt_gb : 1 const belegtPercent = Math.min(100, Math.max(0, Math.round(((gesamtGb - freiGb) / (gesamtGb || 1)) * 100))) - const cpuPercent = aktiverJob ? aktiverJob.progress : (jobs.length > 0 ? 15 : 5) - const workerCount = systemInfo?.workers?.length || 1 - - const sparklinePath = sparklinePoints.map((val, idx) => { - const x = (idx / (sparklinePoints.length - 1)) * 100 - const y = 50 - (val / 100) * 40 - return `${idx === 0 ? 'M' : 'L'} ${x} ${y}` - }).join(' ') + const workerOnline = workersLive.filter(w => w.online) + const encoderWorker = workerOnline.filter(w => (w.encoders || []).length > 0) + const hardwareWorker = workerOnline.filter(w => (w.encoders || []).some(e => !e.startsWith('cpu'))) + // Phase im Klartext — „processing" heißt rippen, „transcoding" komprimieren. + const phaseText = !aktiverJob ? 'Bereit — keine Disc in Arbeit' + : aktiverJob.status === 'transcoding' ? 'Kompression läuft (HandBrake)' + : 'Rip läuft (MakeMKV, verlustfrei)' const filteredJobs = jobs.filter(j => { if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding' @@ -271,11 +339,17 @@ export default function Dashboard() {
-
+
{aktiverJob.progress}%
+ {/* Die Angabe, deren Fehlen den 50-Stunden-Lauf am 25.07.2026 + unsichtbar machte. Ohne belastbare Messreihe steht hier + ehrlich, dass noch gemessen wird (siehe api/eta.py). */} +
+ {aktiverJob.eta_text || 'Restzeit wird gemessen …'} +
+ {job.eta_text && ( +
{job.eta_text}
+ )}
@@ -393,9 +470,9 @@ export default function Dashboard() { {(job.status === 'completed' || job.status === 'failed') && ( @@ -421,57 +498,110 @@ export default function Dashboard() { - Server Status (Echte Live-Daten) + Server-Status - -
-
-
-
- Auslastung: - {aktiverJob ? `${cpuPercent}% (Aktiv)` : 'Standby (0%)'} -
-
-
-
-
+ +
-
-
- Worker Status: - {workerCount} Worker Online -
-
-
-
-
- -
-
- Speichermounts: - {freiGb > 0 ? `${freiGb} GB frei` : 'Aktiv'} -
-
-
-
+ {/* 1. Was tut Rippy JETZT — mit Restzeit statt Phantasie-Last */} +
+
+ + Gerade: + + + {phaseText} +
+ {aktiverJob ? ( + <> +
+
+
+
+ + {aktiverJob.title || aktiverJob.id.slice(0, 8)} · {aktiverJob.progress} % + + {/* Leer heißt: noch keine belastbare Schätzung. Dann wird + das auch so gesagt — nicht geraten (siehe eta.py). */} + + {aktiverJob.eta_text || 'Restzeit wird noch gemessen'} + +
+ + ) : ( +

+ {queueJobs.length > 0 + ? `${queueJobs.length} Job(s) warten in der Schlange` + : 'Disc einlegen — Rippy erkennt sie selbst'} +

+ )}
-
- - +
+ + Worker erreichbar: + + 0 ? 'text-slate-200' : 'text-rose-400 font-bold'}> + {workerOnline.length} von {workersLive.length} + {hardwareWorker.length > 0 ? ' · Hardware-Encoder dabei' : ''} + +
+ {workerOnline.length === 0 ? ( +

+ Kein Worker antwortet — ohne ihn läuft kein Rip. + Prüfen: docker compose ps +

+ ) : ( +
+ {workerOnline.map(w => ( +

+ {w.name} + {w.info?.cpu_kerne ? ` · ${w.info.cpu_kerne} Kerne` : ''} + {w.info?.cpu_simd && w.info.cpu_simd !== 'unbekannt' ? ` · ${w.info.cpu_simd}` : ''} + {w.info?.extern === 'ja' ? ' · extern' : ''} + {(w.encoders || []).length === 0 ? ' · kann nicht komprimieren' : ''} +

+ ))} + {encoderWorker.length === 0 && ( +

+ Keiner dieser Worker hat HandBrake — es wird nur gerippt, nicht komprimiert. +

+ )} +
+ )} +
+ + {/* 3. Platz — war schon immer echt */} +
+
+ + {mainPlatz?.name || 'Ablage'}: + + 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}> + {freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'} + +
+
+
90 ? 'bg-rose-500' : 'bg-amber-500/80' + }`} + style={{ width: `${Math.max(2, belegtPercent)}%` }} /> - +
+ {freiGb > 0 && freiGb < 60 && ( +

+ Eine Blu-ray braucht roh ~40 GB, eine 4K-UHD bis 100 GB — + das reicht nicht mehr für jede Disc. +

+ )}
@@ -548,6 +678,39 @@ export default function Dashboard() { confirmText="Aufräumen" type="warning" /> + + {/* Entfernen eines EINZELNEN Jobs — mit der Zahl, die vorher fehlte. */} + setLoeschKandidat(null)} + onConfirm={jobLoeschen} + title={`„${loeschKandidat?.title || loeschKandidat?.id.slice(0, 8) || ''}" entfernen?`} + message="Der Eintrag verschwindet aus der Liste. Die fertig abgelegten Dateien bleiben, wo sie sind." + confirmText={rohdatenMitloeschen ? 'Entfernen und löschen' : 'Entfernen'} + type="warning" + extra={rohdaten && rohdaten.gb > 0 ? ( +
+

+ Daneben liegen {rohdaten.gb} GB Rohdaten + {rohdaten.dateien > 0 ? ` (${rohdaten.dateien} Datei${rohdaten.dateien === 1 ? '' : 'en'})` : ''}. + Ohne den Eintrag zeigt nichts mehr darauf — sie sind dann über Rippy + nicht mehr erreichbar und „Neu komprimieren" ist unmöglich. +

+ {rohdaten.pfade.map(p => ( +

{p}

+ ))} + +
+ ) : null} + />
) }