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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-26 12:59:08 +02:00
parent 638edc6a9f
commit 6db64230eb
2 changed files with 233 additions and 64 deletions
+227 -64
View File
@@ -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<string | null>(null)
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
// Job, dessen Entfernen gerade bestätigt werden soll — plus was daneben liegt.
const [loeschKandidat, setLoeschKandidat] = useState<Job | null>(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<number[]>([20, 35, 15, 45, 30, 60, 40, 75, 50, 65])
const [workersLive, setWorkersLive] = useState<WorkerLive[]>([])
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() {
</div>
<div className="flex items-center gap-4 w-full sm:w-auto justify-end">
<div className="w-36 space-y-1 text-right">
<div className="w-40 space-y-1 text-right">
<div className="text-xs font-mono text-amber-400 font-bold">{aktiverJob.progress}%</div>
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
<div className="h-full bg-amber-500 rounded-full transition-all duration-300" style={{ width: `${aktiverJob.progress}%` }} />
</div>
{/* 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). */}
<div className="text-[11px] font-mono text-slate-300">
{aktiverJob.eta_text || 'Restzeit wird gemessen …'}
</div>
</div>
<Button variant="amber" size="sm" onClick={() => setDetailJobId(aktiverJob.id)}>
Details
@@ -360,6 +434,9 @@ export default function Dashboard() {
style={{ width: `${job.progress}%` }}
/>
</div>
{job.eta_text && (
<div className="text-[10px] font-mono text-slate-500">{job.eta_text}</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
@@ -393,9 +470,9 @@ export default function Dashboard() {
{(job.status === 'completed' || job.status === 'failed') && (
<button
onClick={() => jobLoeschen(job)}
onClick={() => loeschenVorbereiten(job)}
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
title="Löschen"
title="Aus der Liste entfernen"
>
<Trash2 size={14} />
</button>
@@ -421,57 +498,110 @@ export default function Dashboard() {
<CardHeader className="border-b border-slate-800">
<CardTitle className="text-slate-200 flex items-center gap-2">
<Server size={18} className="text-amber-400" />
Server Status (Echte Live-Daten)
Server-Status
</CardTitle>
</CardHeader>
<CardContent className="p-5 flex-1 flex items-center">
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 w-full">
<div className="space-y-4 flex-1 w-full">
<div>
<div className="flex items-center justify-between text-xs font-mono mb-1">
<span className="text-slate-400 flex items-center gap-1.5"><Cpu size={14} className="text-amber-400" /> Auslastung:</span>
<span className="text-amber-400 font-bold">{aktiverJob ? `${cpuPercent}% (Aktiv)` : 'Standby (0%)'}</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
<div
className="h-full bg-amber-500 rounded-full transition-all duration-500"
style={{ width: `${aktiverJob ? Math.max(15, cpuPercent) : 5}%` }}
/>
</div>
</div>
<CardContent className="p-5 flex-1 flex flex-col justify-center">
<div className="space-y-4 w-full">
<div>
<div className="flex items-center justify-between text-xs font-mono mb-1">
<span className="text-slate-400 flex items-center gap-1.5"><Activity size={14} className="text-amber-400" /> Worker Status:</span>
<span className="text-slate-200">{workerCount} Worker Online</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
<div className="h-full bg-emerald-500 rounded-full w-full" />
</div>
</div>
<div>
<div className="flex items-center justify-between text-xs font-mono mb-1">
<span className="text-slate-400 flex items-center gap-1.5"><HardDrive size={14} className="text-amber-400" /> Speichermounts:</span>
<span className="text-slate-200">{freiGb > 0 ? `${freiGb} GB frei` : 'Aktiv'}</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
<div
className="h-full bg-amber-500/80 rounded-full transition-all duration-500"
style={{ width: `${Math.max(10, belegtPercent)}%` }}
/>
</div>
{/* 1. Was tut Rippy JETZT — mit Restzeit statt Phantasie-Last */}
<div>
<div className="flex items-center justify-between text-xs font-mono mb-1 gap-2">
<span className="text-slate-400 flex items-center gap-1.5 flex-shrink-0">
<Activity size={14} className="text-amber-400" /> Gerade:
</span>
<span className={`text-right ${aktiverJob ? 'text-amber-400 font-bold' : 'text-slate-300'}`}>
{phaseText}
</span>
</div>
{aktiverJob ? (
<>
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
<div
className="h-full bg-amber-500 rounded-full transition-all duration-500"
style={{ width: `${Math.max(2, aktiverJob.progress)}%` }}
/>
</div>
<div className="flex items-center justify-between text-[11px] font-mono mt-1">
<span className="text-slate-300 truncate pr-2">
{aktiverJob.title || aktiverJob.id.slice(0, 8)} · {aktiverJob.progress} %
</span>
{/* Leer heißt: noch keine belastbare Schätzung. Dann wird
das auch so gesagt — nicht geraten (siehe eta.py). */}
<span className="text-amber-400 flex-shrink-0">
{aktiverJob.eta_text || 'Restzeit wird noch gemessen'}
</span>
</div>
</>
) : (
<p className="text-[11px] font-mono text-slate-500">
{queueJobs.length > 0
? `${queueJobs.length} Job(s) warten in der Schlange`
: 'Disc einlegen — Rippy erkennt sie selbst'}
</p>
)}
</div>
<div className="w-full sm:w-40 h-24 flex-shrink-0 flex items-end justify-center pt-2">
<svg viewBox="0 0 100 50" className="w-full h-full stroke-amber-400 fill-amber-500/10 transition-all duration-500">
<path
d={`${sparklinePath} L 100 50 L 0 50 Z`}
strokeWidth="2.5"
strokeLinecap="round"
{/* 2. Worker — die ECHTE Erreichbarkeit, plus was sie können */}
<div>
<div className="flex items-center justify-between text-xs font-mono mb-1">
<span className="text-slate-400 flex items-center gap-1.5">
<Cpu size={14} className="text-amber-400" /> Worker erreichbar:
</span>
<span className={workerOnline.length > 0 ? 'text-slate-200' : 'text-rose-400 font-bold'}>
{workerOnline.length} von {workersLive.length}
{hardwareWorker.length > 0 ? ' · Hardware-Encoder dabei' : ''}
</span>
</div>
{workerOnline.length === 0 ? (
<p className="text-[11px] font-mono text-rose-400">
Kein Worker antwortet ohne ihn läuft kein Rip.
Prüfen: <span className="text-slate-300">docker compose ps</span>
</p>
) : (
<div className="space-y-0.5">
{workerOnline.map(w => (
<p key={w.name} className="text-[11px] font-mono text-slate-400 truncate">
<span className="text-emerald-400"></span> {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' : ''}
</p>
))}
{encoderWorker.length === 0 && (
<p className="text-[11px] font-mono text-amber-400">
Keiner dieser Worker hat HandBrake es wird nur gerippt, nicht komprimiert.
</p>
)}
</div>
)}
</div>
{/* 3. Platz — war schon immer echt */}
<div>
<div className="flex items-center justify-between text-xs font-mono mb-1">
<span className="text-slate-400 flex items-center gap-1.5">
<HardDrive size={14} className="text-amber-400" /> {mainPlatz?.name || 'Ablage'}:
</span>
<span className={freiGb > 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}>
{freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'}
</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
belegtPercent > 90 ? 'bg-rose-500' : 'bg-amber-500/80'
}`}
style={{ width: `${Math.max(2, belegtPercent)}%` }}
/>
</svg>
</div>
{freiGb > 0 && freiGb < 60 && (
<p className="text-[11px] font-mono text-amber-400 mt-1">
Eine Blu-ray braucht roh ~40 GB, eine 4K-UHD bis 100 GB
das reicht nicht mehr für jede Disc.
</p>
)}
</div>
</div>
</CardContent>
@@ -548,6 +678,39 @@ export default function Dashboard() {
confirmText="Aufräumen"
type="warning"
/>
{/* Entfernen eines EINZELNEN Jobs — mit der Zahl, die vorher fehlte. */}
<ConfirmDialog
isOpen={loeschKandidat !== null}
onClose={() => 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 ? (
<div className="mt-3 text-xs p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300 space-y-2">
<p>
<strong>Daneben liegen {rohdaten.gb} GB Rohdaten</strong>
{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.
</p>
{rohdaten.pfade.map(p => (
<p key={p} className="font-mono break-all opacity-80">{p}</p>
))}
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={rohdatenMitloeschen}
onChange={e => setRohdatenMitloeschen(e.target.checked)}
className="w-4 h-4 mt-0.5 accent-rose-500 rounded"
/>
<span>Rohdaten jetzt mitlöschen und {rohdaten.gb} GB freigeben</span>
</label>
</div>
) : null}
/>
</div>
)
}