Files
rippy/docker/ui/src/pages/Dashboard.tsx
T
HitonabiandClaude Opus 5 e67138e9ca
Ampel / ampel (push) Successful in 1m26s
feat(ui): Laufende Disc-Erkennung ist jetzt sichtbar
Commander: „das die disc erkennung noch läuft muss sichtbar sein"

Er hat recht, und die Wartezeit ist gemessen: `makemkvcon info` laeuft an
einer Blu-ray in seine 120-Sekunden-Grenze (Disc nach 119 s erkannt). Zwei
Minuten, in denen NICHTS zu sehen war — weder die Disc noch ein Hinweis. Das
sah aus wie ein leeres Laufwerk, also wie ein Fehler.

## Der Zustand war da, er wurde nur weggeworfen

`_auto_prescan` legt beim Start `{"_laeuft": True}` in den Vorrat.
`laufwerke_mit_disc` liess so einen Eintrag KOMPLETT weg — damit keine
halbfertige Disc-Karte mit „Wird erkannt…" als Titel und einem „Rippen
starten"-Knopf erscheint. Richtig gedacht, falsch geloest.

Jetzt gibt es ein eigenes Feld `disc_wird_erkannt`. Die Oberflaeche kann
„wird gelesen" zeigen, ohne einen Titel zu behaupten, den es noch nicht gibt:
eine Karte mit Spinner ueber der Laufwerksliste und eine eigene Zeile im
Server-Status.

## Und der Haken dahinter

Genau waehrend der Erkennung ist die Laufwerks-Auskunft am teuersten:
makemkvcon haelt das Laufwerk, und `device_info` wartet mit. Gemessen:

    /devices waehrend des Scans     14,0 s
    Zeitgrenze des Schnappschusses   5,0 s   ->  devices = None

`None` heisst „konnte nicht nachsehen", und die Oberflaeche behaelt dann
ihren Stand — nach einem frischen Laden also GAR NICHTS. Die Anzeige waere
ausgerechnet in ihrer eigenen Phase leer geblieben.

`laufwerke_notdurft()` antwortet deshalb ohne ioctl: letzter bekannter Stand
plus die aktuelle Erkennungs-Marke aus dem Vorrat. Beim Kaltstart wenigstens
der Laufwerksname — die LISTE der Laufwerke ist billig, teuer ist erst das
Anfassen. Nichts wird erfunden: kein Typ, kein Modell. Und wer noch nie
erfolgreich gelesen hat, schweigt weiter mit `None`.

Im Browser gegengeprueft: „Disc wird gelesen — Laufwerk G:" mit Spinner,
Server-Status „Disc wird gelesen — das dauert bis zu zwei Minuten", danach
der richtige Titel.

859 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 15:12:35 +02:00

1020 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react'
import { Cpu, HardDrive, Disc, Download, Trash2, Activity, Film, Server, RotateCcw, FileSpreadsheet, Layers } from 'lucide-react'
import { api } from '../lib/api'
import { useToast } from '../context/ToastContext'
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
import { StatusBadge, TypeBadge } from '../components/ui/Badge'
import ConfirmDialog from '../components/ConfirmDialog'
import DeviceDiscovery from '../components/DeviceDiscovery'
import JobDetailModal from '../components/JobDetailModal'
import LiveLogSection from '../components/LiveLogSection'
import RetryDialog from '../components/RetryDialog'
import { useStrom } from '../lib/useEventStream'
import { useBetrieb } from '../lib/useBetrieb'
interface JobMeta {
year?: number
overview?: string
poster_path?: string
genres?: string[]
runtime?: number
type?: string
source?: string
}
interface Job {
id: string
type: 'cd' | 'dvd' | 'bluray' | 'uhd'
status: 'pending' | 'processing' | 'transcoding' | 'canceling' | 'completed' | 'failed'
device: string
startTime: string
endTime?: string
progress: number
title?: string
can_retry?: boolean
// "transcode" | "rip" | "unklar" | "" — welche Wiederholung passt (phasen.py)
retry_art?: string
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 {
api_version: string
plaetze: { name: string; pfad?: string; gleiches_laufwerk?: boolean;
frei_gb: number; gesamt_gb: number }[]
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 }
}
// Ein Laufwerk aus GET /devices. Gebraucht, weil die Server-Status-Karte sonst
// „keine Disc in Arbeit" behauptet, während oben auf DEMSELBEN Bildschirm
// „Akira im Laufwerk erkannt" steht (Commander-Befund 26.07.2026).
interface LaufwerkLive {
id: string
status: string
disc?: { title?: string, year?: number | null, disc_type?: string }
/** Die Erkennung laeuft noch — es gibt noch keinen Titel, aber auch kein
* leeres Laufwerk. Dauert an einer Blu-ray rund zwei Minuten. */
disc_wird_erkannt?: boolean
}
// Ein Ablageziel aus GET /storage-targets — Verzeichnisse unter /app/media
// inklusive der eingehängten Netzwerk-Freigaben, mit freiem Platz.
//
// Commander-Befund 26.07.2026: Der Server-Status zeigte nur „Media
// (/app/media)" — also die Container-Platte. Die eingehängte NAS-Freigabe mit
// ihren 2,1 TB fehlte, obwohl genau dort die Rohdaten und (bei externem
// Encoden) auch die Ablage liegen müssen. Wer wissen will, ob noch Platz für
// eine Disc ist, sah die falsche Zahl.
interface AblageZiel {
name: string
path: string
is_mount: boolean
free_gb: number | null
}
function posterUrl(meta?: JobMeta | null): string | null {
const p = meta?.poster_path
if (!p) return null
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
}
/*
* Ein fehlgeschlagener Abruf gibt `null` — NICHT eine leere Liste.
*
* Commander-Befund 26.07.2026: „Wenn der Worker installiert ist, wird dieser
* Bereich oft neu geladen." Gemessen war es kein Neuladen: Die Antworten von
* /jobs und /capabilities waren über zwanzig Sekunden byteweise IDENTISCH, alle
* Endpunkte antworteten in unter 30 ms. Der Grund lag im Fehlerzweig — hier
* stand `return []`, und jeder einzelne verpasste Abruf setzte damit die
* Job-Liste auf LEER. Für einen Takt zeigte die Tabelle „Keine Jobs", die Zähler
* sprangen auf (0), vier Sekunden später war alles wieder da.
*
* Ein verpasster Abruf ist keine Nachricht über die Welt. Wer nichts Neues
* weiß, behält, was er wusste.
*/
async function fetchJobs(): Promise<Job[] | null> {
try {
const response = await api.get('/jobs')
return Array.isArray(response.data) ? response.data : null
} catch {
return null
}
}
export default function Dashboard() {
const [jobs, setJobs] = useState<Job[]>([])
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
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)
// Job, für den ein neuer Versuch bestätigt werden soll (siehe wiederholen()).
const [neuKandidat, setNeuKandidat] = useState<Job | null>(null)
const [neuRohdaten, setNeuRohdaten] = useState<{ gb: number, dateien: number, pfade: string[] } | null>(null)
const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all')
const [workersLive, setWorkersLive] = useState<WorkerLive[]>([])
const [laufwerke, setLaufwerke] = useState<LaufwerkLive[]>([])
const [ablagen, setAblagen] = useState<AblageZiel[]>([])
const strom = useStrom()
// Was hier ueberhaupt angezeigt werden DARF, haengt am Betrieb.
// Ohne das stand im Windows-Fenster "Pruefen: docker compose ps".
const betrieb = useBetrieb()
const { toast } = useToast()
/*
* 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 {
const r = await api.get(`/jobs/${job.id}/rohdaten`)
setRohdaten(r.data)
} catch {
setRohdaten(null) // dann eben ohne Zahl fragen, statt gar nicht
}
}
/*
* Der „Neu"-Knopf sagt jetzt, WAS er tut.
*
* Commander-Frage 26.07.2026: „Hier gibt es den Button neu' aber WAS wird
* dann gemacht? Komprimierung? Neu Gerippt?" — Antwort damals: immer
* Komprimierung, egal wie der Job gestorben war. Für einen abgebrochenen Rip
* war das falsch (5,1 GB Bruchstück von rund 40 GB).
*
* `job.retry_art` kommt aus api/phasen.py und kennt drei Fälle:
* 'transcode' → der Rip war fertig → „Neu komprimieren"
* 'rip' → der Rip starb → „Neu rippen" (Disc muss drin sein)
* 'unklar' → Bestandsjob → fragen, mit der Rohdaten-Größe als
* Entscheidungshilfe
*
* Sonderfall: Phase 'transcode', aber die Rohdaten sind fort (`can_retry`
* false) — dann hilft auch hier nur ein neuer Rip.
*/
const retryPlan = (job: Job): 'transcode' | 'rip' | 'unklar' => {
if (job.retry_art === 'transcode') return job.can_retry ? 'transcode' : 'rip'
if (job.retry_art === 'rip') return 'rip'
return 'unklar'
}
const wiederholen = async (job: Job) => {
setNeuKandidat(job)
setNeuRohdaten(null)
try {
const r = await api.get(`/jobs/${job.id}/rohdaten`)
setNeuRohdaten(r.data)
} catch {
setNeuRohdaten(null) // dann eben ohne Zahl fragen
}
}
const neuStarten = async (art: 'transcode' | 'rip') => {
const job = neuKandidat
if (!job) return
setNeuKandidat(null)
const weg = art === 'rip' ? 'retry-rip' : 'retry-transcode'
try {
await api.post(`/jobs/${job.id}/${weg}`)
toast('success', art === 'rip'
? 'Neuer Rip gestartet — der alte Eintrag bleibt zum Nachlesen stehen'
: 'Kompression neu eingereiht — die Disc wird nicht gebraucht')
const frisch = await fetchJobs()
if (frisch) setJobs(frisch)
} catch (e: any) {
toast('error', e?.response?.data?.detail || 'Fehlgeschlagen')
}
}
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` : ''))
const frisch = await fetchJobs()
if (frisch) setJobs(frisch)
} catch (e: any) {
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen')
}
}
const erledigteAufraeumen = async () => {
setAufraeumenOffen(false)
try {
const r = await api.delete('/jobs')
toast('success', `${r.data.deleted} erledigte Jobs entfernt — Dateien bleiben liegen`)
const frisch = await fetchJobs()
if (frisch) setJobs(frisch)
} catch {
toast('error', 'Aufräumen fehlgeschlagen')
}
}
/*
* KEIN TAKTGEBER MEHR (Etappe V2-3).
*
* Hier standen zwei: alle 4 s Jobs und Laufwerke, alle 12 s Hardware,
* Worker und Ablagen — zusammen 75 Anfragen pro Minute JE OFFENEM TAB. Bei
* der damaligen Grenze von 100/min wies die API laufend mit HTTP 429 ab
* (gemessen: 97 Abweisungen im Log), und weil ein Fehlschlag im UI als
* „es gibt nichts" gelesen wurde, leerte sich die Job-Liste im Sekundentakt.
* Genau das hat der Commander als „wird oft neu geladen" gemeldet.
*
* Jetzt kommt alles aus der EINEN Verbindung, die App.tsx haelt.
*
* ⚠️ DIE REGEL BLEIBT — sie ist nur umgezogen: Ein fehlender Wert im Strom
* heisst „nichts Neues erfahren", NICHT „es gibt nichts". Deshalb wird unten
* jeder Wert nur uebernommen, wenn er wirklich da ist; sonst bleibt der
* letzte bekannte Stand stehen. Das UI zeigt dann oben rechts
* „VERBINDUNG WEG" statt leerer Listen.
*/
useEffect(() => {
setJobs(strom.jobs as Job[])
setLoading(false)
}, [strom.jobs])
useEffect(() => {
if (strom.devices !== null) setLaufwerke(strom.devices as LaufwerkLive[])
}, [strom.devices])
useEffect(() => {
if (strom.workers.length || strom.verbunden) setWorkersLive(strom.workers as WorkerLive[])
}, [strom.workers, strom.verbunden])
useEffect(() => {
if (strom.systemInfo) setSystemInfo(strom.systemInfo as SystemInfo)
}, [strom.systemInfo])
useEffect(() => {
if (strom.ablagen !== null) setAblagen(strom.ablagen as AblageZiel[])
}, [strom.ablagen])
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
const queueJobs = jobs.filter(j => j.status === 'pending')
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 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.
*
* Die eingelegte Disc gehört mit hinein (Commander-Befund 26.07.2026): Vorher
* stand hier stur „Bereit — keine Disc in Arbeit / Disc einlegen, Rippy erkennt
* sie selbst", während oben auf DEMSELBEN Bildschirm „Akira im Laufwerk
* erkannt" prangte. Zwei Aussagen, ein Blick, Widerspruch — und der Nutzer
* weiß nicht, welcher er glauben soll.
*/
const discImLaufwerk = laufwerke.find(l => l.disc?.title)
// Nur die echten Netzwerk-Freigaben, nicht die normalen Unterordner von
// /app/media (movies/series/music liegen auf der Container-Platte und sind
// dort schon mitgezählt).
const freigaben = ablagen.filter(a => a.is_mount)
const phaseText = aktiverJob
? (aktiverJob.status === 'transcoding'
? 'Kompression läuft (HandBrake)'
: 'Rip läuft (MakeMKV, verlustfrei)')
: discImLaufwerk
? 'Disc erkannt — wartet auf „Rippen starten"'
// Zwischen „Disc eingelegt" und „erkannt" liegen rund zwei Minuten
// (makemkvcon info laeuft in seine 120-Sekunden-Grenze). Ohne diese
// Zeile stand dort „kein Datentraeger", und das sah aus wie ein
// Fehler (Commander 29.08.2026).
: laufwerke.some(l => l.disc_wird_erkannt)
? 'Disc wird gelesen — das dauert bis zu zwei Minuten'
: 'Bereit — kein Datenträger im Laufwerk'
const filteredJobs = jobs.filter(j => {
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
if (activeTab === 'queue') return j.status === 'pending'
if (activeTab === 'completed') return j.status === 'completed'
if (activeTab === 'failed') return j.status === 'failed'
return true
})
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-amber-500"></div>
</div>
)
}
return (
<div className="space-y-6">
{/* POS 1: LAUFWERKE (Device Discovery Slot ganz oben) */}
<DeviceDiscovery />
{/* POS 2: RIPPING OPERATIONS & MEDIEN-MANAGER (DIREKT UNTER LAUFWERKE!) */}
<Card className="border border-amber-500/30 bg-[#0f1422] shadow-2xl overflow-hidden">
<CardHeader className="border-b border-slate-800 flex flex-wrap items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<CardTitle className="text-amber-400 flex items-center gap-2 text-xl">
<Layers size={22} className="text-amber-400" />
Ripping Operations &amp; Medien-Manager
</CardTitle>
<span className="text-xs font-mono text-slate-400">({jobs.length} Jobs gesamt)</span>
</div>
<div className="flex items-center gap-3 flex-wrap">
{/* Master Filter Tabs */}
<div className="flex items-center p-1 rounded-xl bg-slate-900 border border-slate-800">
<button
onClick={() => setActiveTab('all')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
activeTab === 'all'
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
: 'text-slate-400 hover:text-slate-200'
}`}
>
<span>Alle ({jobs.length})</span>
</button>
<button
onClick={() => setActiveTab('active')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
activeTab === 'active'
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
: 'text-slate-400 hover:text-slate-200'
}`}
>
{aktiverJob && <span className="w-2 h-2 rounded-full bg-amber-400 animate-ping" />}
<span>Aktiv ({aktiverJob ? 1 : 0})</span>
</button>
<button
onClick={() => setActiveTab('queue')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
activeTab === 'queue'
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
: 'text-slate-400 hover:text-slate-200'
}`}
>
<span>Warteschlange ({queueJobs.length})</span>
</button>
<button
onClick={() => setActiveTab('completed')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
activeTab === 'completed'
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
: 'text-slate-400 hover:text-slate-200'
}`}
>
<span>Fertig ({completedJobs.length})</span>
</button>
{failedJobs.length > 0 && (
<button
onClick={() => setActiveTab('failed')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
activeTab === 'failed'
? 'bg-rose-500 text-white shadow-md font-bold'
: 'text-rose-400 hover:text-rose-200'
}`}
>
<span>Fehler ({failedJobs.length})</span>
</button>
)}
</div>
{jobs.length > 0 && (
<a
href="/api/jobs/export"
download
title="Job-Historie als CSV herunterladen"
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-xl border border-slate-700 bg-slate-800/80 text-slate-300 hover:bg-slate-700 transition-colors"
>
<FileSpreadsheet size={14} />
CSV
</a>
)}
{completedJobs.length > 0 && (
<Button variant="secondary" size="sm" onClick={() => setAufraeumenOffen(true)}>
<Trash2 size={13} /> Aufräumen
</Button>
)}
</div>
</CardHeader>
<CardContent className="p-0">
{/* Live Rip Banner inside Panel when a Job is active */}
{aktiverJob && (activeTab === 'all' || activeTab === 'active') && (
<div className="p-5 border-b border-amber-500/30 bg-gradient-to-r from-amber-500/15 via-slate-900 to-slate-950">
<div className="flex flex-col sm:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-4">
{posterUrl(aktiverJob.meta) ? (
<img src={posterUrl(aktiverJob.meta)!} alt="" className="w-16 h-24 rounded-xl object-cover border border-amber-500/40 shadow-lg" />
) : (
<div className="w-16 h-24 rounded-xl bg-slate-900 border border-slate-800 flex items-center justify-center">
<Disc size={32} className="text-amber-400 animate-disc-spin" />
</div>
)}
<div className="space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase bg-amber-500 text-slate-950">
AKTIVER RIP IN ARBEIT
</span>
<TypeBadge type={aktiverJob.type} />
<StatusBadge status={aktiverJob.status} />
</div>
<h3 className="text-xl font-bold text-slate-100">{aktiverJob.title || 'Unbekannter Titel'}</h3>
<p className="text-xs text-slate-400 font-mono">
Laufwerk: {aktiverJob.device} · {aktiverJob.status === 'transcoding' ? 'HandBrake H.265' : 'MakeMKV Lossless'}
</p>
</div>
</div>
<div className="flex items-center gap-4 w-full sm:w-auto justify-end">
<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
</Button>
<Button variant="danger" size="sm" onClick={() => api.post(`/jobs/${aktiverJob.id}/cancel`).catch(() => {})}>
Abbrechen
</Button>
</div>
</div>
</div>
)}
{/* Unified Jobs Table */}
{filteredJobs.length === 0 ? (
<div className="text-center py-16">
<div className="mx-auto w-16 h-16 rounded-2xl flex items-center justify-center mb-3 bg-slate-900 text-slate-500 border border-slate-800">
<Disc size={32} />
</div>
<p className="text-sm font-mono text-slate-400">Keine Jobs in diesem Tab vorhanden</p>
<p className="text-xs text-slate-500 mt-1">Lege eine Disc ein, um einen Ripping-Prozess zu starten.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead className="bg-slate-950/90 border-b border-slate-800 text-[11px] font-bold uppercase tracking-wider text-slate-400">
<tr>
<th className="px-6 py-3.5">Typ</th>
<th className="px-6 py-3.5">Titel &amp; Inhaltsangabe</th>
<th className="px-6 py-3.5">Status &amp; Phase</th>
<th className="px-6 py-3.5">Laufwerk</th>
<th className="px-6 py-3.5">Fortschritt</th>
<th className="px-6 py-3.5">Startzeit</th>
<th className="px-6 py-3.5 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/80 text-sm">
{filteredJobs.map(job => {
const poster = posterUrl(job.meta)
return (
<tr key={job.id} className="transition-colors hover:bg-slate-900/60">
<td className="px-6 py-4 whitespace-nowrap">
<TypeBadge type={job.type} />
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
{poster ? (
<img src={poster} alt="" className="w-10 h-14 rounded-lg object-cover flex-shrink-0 border border-slate-700/80 shadow-md" />
) : (
<div className="w-10 h-14 rounded-lg bg-slate-900 border border-slate-800 flex items-center justify-center flex-shrink-0 text-slate-500">
<Film size={18} />
</div>
)}
<div className="min-w-0">
<button
onClick={() => setDetailJobId(job.id)}
className="font-bold text-slate-100 hover:text-amber-400 transition-colors text-left block line-clamp-1 text-base"
>
{job.title || 'Unbekannter Titel'}
</button>
{job.meta?.overview && (
<p className="text-xs text-slate-400 line-clamp-2 mt-0.5 leading-snug">
{job.meta.overview}
</p>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={job.status} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
{job.device}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="w-32 space-y-1">
<div className="flex items-center justify-between text-xs font-mono text-slate-400">
<span>{job.progress}%</span>
</div>
<div className="w-full h-1.5 rounded-full bg-slate-800 overflow-hidden">
<div
className="h-full bg-amber-500 rounded-full transition-all duration-300"
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">
{new Date(job.startTime).toLocaleString('de-DE')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setDetailJobId(job.id)}>
Details
</Button>
{/*
Abbrechen DIREKT in der Zeile (Commander
29.08.2026: „man kann einen rip garnicht
abbrechen").
Es gab genau einen Abbrechen-Knopf, und der
steckte in der Kachel „laufender Job". Die
erschien nur bei Status `processing` — der
Ereignisstrom lieferte aber den rohen
DB-Status `running`. Also keine Kachel, also
kein Knopf, also kein Weg zurueck.
Die Ursache ist behoben; ein zweiter Weg zum
Abbrechen ist trotzdem richtig, denn genau
hier sucht man ihn.
*/}
{['processing', 'transcoding', 'pending'].includes(job.status) && (
<Button
variant="danger"
size="sm"
onClick={() => api.post(`/jobs/${job.id}/cancel`)
.then(() => toast('info', 'Abbruch angefordert — der Rip haelt beim naechsten Zwischenschritt an.'))
.catch((e: any) => toast('error',
e?.response?.data?.detail || 'Abbrechen ging nicht'))}
>
Abbrechen
</Button>
)}
{job.status === 'canceling' && (
<span className="text-xs font-mono text-slate-400">bricht ab </span>
)}
{job.status === 'completed' && (
<Button variant="amber" size="sm" onClick={() => setDetailJobId(job.id)}>
<Download size={13} /> Download
</Button>
)}
{job.status === 'failed' && (
<Button
variant="secondary"
size="sm"
onClick={() => wiederholen(job)}
title="Fragt vorher, was genau wiederholt wird"
>
<RotateCcw size={13} />
{retryPlan(job) === 'transcode' ? 'Neu komprimieren'
: retryPlan(job) === 'rip' ? 'Neu rippen'
: 'Neu …'}
</Button>
)}
{(job.status === 'completed' || job.status === 'failed') && (
<button
onClick={() => loeschenVorbereiten(job)}
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
title="Aus der Liste entfernen"
>
<Trash2 size={14} />
</button>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* POS 3: SYSTEM STATUS & ZULETZT GERIPPT HIGHLIGHT LEISTE (UNTER DEM MASTER-PANEL) */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Left: Server Status Card */}
<div className="lg:col-span-7">
<Card className="border border-slate-800 bg-[#0f1422] h-full flex flex-col justify-between">
<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
</CardTitle>
</CardHeader>
<CardContent className="p-5 flex-1 flex flex-col justify-center">
<div className="space-y-4 w-full">
{/* 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`
: discImLaufwerk
? `${discImLaufwerk.disc!.title}`
+ (discImLaufwerk.disc!.year ? ` (${discImLaufwerk.disc!.year})` : '')
+ ' liegt bereit — oben auf „Rippen starten"'
: 'Disc einlegen — Rippy erkennt sie selbst'}
</p>
)}
</div>
{/* 2. Wer arbeitet hier — und das hängt am BETRIEB.
⚠️ Commander-Befund 28.08.2026: Im Windows-Fenster stand
„Worker erreichbar: 0 von 1 — Kein Worker antwortet.
Prüfen: docker compose ps". Auf einem Windows-PC gibt es
keinen zweiten Worker, auf den man warten könnte, und
kein docker compose, das man prüfen könnte. Die Meldung
war nicht nur unpassend, sie war falsch. */}
{!betrieb.kann.externe_worker ? (
<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" /> Rippy arbeitet:
</span>
<span className="text-slate-200">
auf diesem Rechner
</span>
</div>
<p className="text-[11px] font-mono text-slate-500">
{aktiverJob
? 'Rippen und Komprimieren laufen hier — kein zweiter Rechner nötig.'
: 'Rippen und Komprimieren erledigt Rippy selbst.'}
</p>
</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">
<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.
{betrieb.hilfe_befehl && (
<> Prüfen: <span className="text-slate-300">{betrieb.hilfe_befehl}</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 — Container-Platte UND die eingehängten Freigaben.
Vorher stand hier nur die Container-Platte; wer wissen
wollte, ob die NAS noch Platz für eine Disc hat, sah die
falsche Zahl (Commander-Befund 26.07.2026). */}
<div>
{/*
Jeder Ort mit seinem PFAD (Commander 29.08.2026): „bei
Platz für rippy' sollte eher das arbeitsverzeichnis und
der Ablagepfad sein." Vorher stand hier eine nackte Zahl
für den ERSTEN Ort — welcher Ordner das war, sah man nicht.
*/}
{!betrieb.kann.container_pfade && (systemInfo?.plaetze || []).map(ort => (
<div key={ort.name + ort.pfad} className="mb-1.5">
<div className="flex items-center justify-between text-xs font-mono">
<span className="text-slate-400 flex items-center gap-1.5">
<HardDrive size={14} className="text-amber-400" />
{ort.name}:
</span>
<span className={ort.frei_gb > 0 && ort.frei_gb < 60
? 'text-amber-400 font-bold' : 'text-slate-200'}>
{ort.frei_gb > 0 ? `${ort.frei_gb} von ${ort.gesamt_gb} GB frei` : 'unbekannt'}
</span>
</div>
{ort.pfad && (
<p className="text-[11px] font-mono text-slate-500 truncate" title={ort.pfad}>
{ort.pfad}
</p>
)}
</div>
))}
{!betrieb.kann.container_pfade
&& (systemInfo?.plaetze || []).length > 1
&& systemInfo?.plaetze?.every(o => o.gleiches_laufwerk) && (
/* Ehrlich sagen, dass die Zahl zweimal dieselbe ist —
statt die zweite Zeile wegzulassen. */
<p className="text-[11px] font-mono text-slate-500 mb-1">
Beide auf demselben Laufwerk der Platz wird geteilt.
</p>
)}
<div className={`flex items-center justify-between text-xs font-mono mb-1 ${
betrieb.kann.container_pfade ? '' : 'hidden'}`}>
<span className="text-slate-400 flex items-center gap-1.5">
<HardDrive size={14} className="text-amber-400" />
Container-Platte:
</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)}%` }}
/>
</div>
{freiGb > 0 && freiGb < 60 && freigaben.length === 0 && (
<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>
{/* Die eingehängten Freigaben. Sie sind der Ort, an dem die
Rohdaten liegen und — bei externem Encoden — liegen MÜSSEN.
⚠️ NUR im Container. Unter Windows hängt Rippy nichts ein:
Dort gibt man einen Ordner oder einen UNC-Pfad an, fertig.
Der Satz „Ohne Freigabe kann ein externer Encoder nichts
tun — er sieht die Container-Platte nicht" stand am
28.08.2026 im Windows-Fenster und ergab dort keinen Sinn:
Es gibt weder einen externen Encoder noch eine
Container-Platte. */}
{betrieb.kann.freigaben_einhaengen && (
<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">
<Server size={14} className="text-amber-400" /> Freigaben:
</span>
<span className={freigaben.length > 0 ? 'text-slate-200' : 'text-slate-400'}>
{freigaben.length > 0
? `${freigaben.length} eingehängt`
: 'keine eingehängt'}
</span>
</div>
{freigaben.length === 0 ? (
<p className="text-[11px] font-mono text-slate-500">
Ohne Freigabe kann ein externer Encoder nichts tun er sieht die
Container-Platte nicht. Einhängen: Einstellungen Speicherziele.
</p>
) : (
<div className="space-y-0.5">
{freigaben.map(f => (
<p key={f.path} className="text-[11px] font-mono text-slate-400 flex items-center justify-between gap-2">
<span className="truncate">
<span className="text-emerald-400"></span> {f.name}
</span>
<span className={f.free_gb != null && f.free_gb < 60
? 'text-amber-400 flex-shrink-0'
: 'text-slate-300 flex-shrink-0'}>
{f.free_gb != null ? `${f.free_gb} GB frei` : 'Platz unbekannt'}
</span>
</p>
))}
</div>
)}
</div>
)}
</div>
</CardContent>
</Card>
</div>
{/* Right: Recently Ripped Posters */}
<div className="lg:col-span-5">
<Card className="border border-slate-800 bg-[#0f1422] h-full flex flex-col justify-between">
<CardHeader className="border-b border-slate-800 flex items-center justify-between">
<CardTitle className="text-slate-200">Zuletzt gerippt ({completedJobs.length})</CardTitle>
{completedJobs.length > 0 && (
<Button variant="ghost" size="sm" onClick={() => setAufraeumenOffen(true)}>
<Trash2 size={13} /> Aufräumen
</Button>
)}
</CardHeader>
<CardContent className="p-4 flex-1 flex flex-col justify-center">
{completedJobs.length === 0 ? (
<div className="text-center py-6 text-slate-500 text-xs font-mono">
Noch keine gerippten Medien in der Datenbank
</div>
) : (
<div className="grid grid-cols-3 gap-3">
{completedJobs.slice(0, 3).map((item) => {
const poster = posterUrl(item.meta)
return (
<div key={item.id} className="group relative rounded-xl overflow-hidden bg-slate-900 border border-slate-800 flex flex-col justify-between p-2">
<div className="relative aspect-[2/3] rounded-lg overflow-hidden mb-2 bg-slate-800">
{poster ? (
<img src={poster} alt={item.title || ''} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" />
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-slate-500 p-2 text-center">
<Film size={24} className="text-amber-400 mb-1" />
<span className="text-[9px] uppercase font-bold text-slate-400">{item.type}</span>
</div>
)}
</div>
<div className="space-y-1 text-left">
<h4 className="text-xs font-bold text-slate-100 truncate" title={item.title}>{item.title || 'Unbekannt'}</h4>
<p className="text-[10px] text-slate-400 font-mono">{new Date(item.startTime).toLocaleDateString('de-DE')}</p>
<Button
variant="amber"
size="sm"
className="w-full text-[10px] py-1 h-auto"
onClick={() => setDetailJobId(item.id)}
>
Details
</Button>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* POS 4: Live-Log Section */}
<LiveLogSection />
<JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} />
<ConfirmDialog
isOpen={aufraeumenOffen}
onClose={() => setAufraeumenOffen(false)}
onConfirm={erledigteAufraeumen}
title="Job-Liste aufräumen?"
message="Alle fertigen und fehlgeschlagenen Jobs verschwinden aus der Liste. Die gerippten Dateien bleiben selbstverständlich liegen."
confirmText="Aufräumen"
type="warning"
/>
{/* „Neu" nennt jetzt beim Namen, was es tut (siehe wiederholen()). */}
<RetryDialog
offen={neuKandidat !== null}
titel={neuKandidat?.title || neuKandidat?.id.slice(0, 8) || ''}
plan={neuKandidat ? retryPlan(neuKandidat) : 'unklar'}
rohdaten={neuRohdaten}
onClose={() => setNeuKandidat(null)}
onStart={neuStarten}
/>
{/* 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>
)
}