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
-
-
+ {/* 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'}
+
+ 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.
+