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 { 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([]) const [systemInfo, setSystemInfo] = useState(null) 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) // Job, für den ein neuer Versuch bestätigt werden soll (siehe wiederholen()). const [neuKandidat, setNeuKandidat] = useState(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([]) const [laufwerke, setLaufwerke] = useState([]) const [ablagen, setAblagen] = useState([]) 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 (
) } return (
{/* POS 1: LAUFWERKE (Device Discovery Slot ganz oben) */} {/* POS 2: RIPPING OPERATIONS & MEDIEN-MANAGER (DIREKT UNTER LAUFWERKE!) */}
Ripping Operations & Medien-Manager ({jobs.length} Jobs gesamt)
{/* Master Filter Tabs */}
{failedJobs.length > 0 && ( )}
{jobs.length > 0 && ( CSV )} {completedJobs.length > 0 && ( )}
{/* Live Rip Banner inside Panel when a Job is active */} {aktiverJob && (activeTab === 'all' || activeTab === 'active') && (
{posterUrl(aktiverJob.meta) ? ( ) : (
)}
AKTIVER RIP IN ARBEIT

{aktiverJob.title || 'Unbekannter Titel'}

Laufwerk: {aktiverJob.device} · {aktiverJob.status === 'transcoding' ? 'HandBrake H.265' : 'MakeMKV Lossless'}

{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 …'}
)} {/* Unified Jobs Table */} {filteredJobs.length === 0 ? (

Keine Jobs in diesem Tab vorhanden

Lege eine Disc ein, um einen Ripping-Prozess zu starten.

) : (
{filteredJobs.map(job => { const poster = posterUrl(job.meta) return ( ) })}
Typ Titel & Inhaltsangabe Status & Phase Laufwerk Fortschritt Startzeit Aktionen
{poster ? ( ) : (
)}
{job.meta?.overview && (

{job.meta.overview}

)}
{job.device}
{job.progress}%
{job.eta_text && (
{job.eta_text}
)}
{new Date(job.startTime).toLocaleString('de-DE')}
{/* 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) && ( )} {job.status === 'canceling' && ( bricht ab … )} {job.status === 'completed' && ( )} {job.status === 'failed' && ( )} {(job.status === 'completed' || job.status === 'failed') && ( )}
)} {/* POS 3: SYSTEM STATUS & ZULETZT GERIPPT HIGHLIGHT LEISTE (UNTER DEM MASTER-PANEL) */}
{/* Left: Server Status Card */}
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` : discImLaufwerk ? `${discImLaufwerk.disc!.title}` + (discImLaufwerk.disc!.year ? ` (${discImLaufwerk.disc!.year})` : '') + ' liegt bereit — oben auf „Rippen starten"' : 'Disc einlegen — Rippy erkennt sie selbst'}

)}
{/* 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 ? (
Rippy arbeitet: auf diesem Rechner

{aktiverJob ? 'Rippen und Komprimieren laufen hier — kein zweiter Rechner nötig.' : 'Rippen und Komprimieren erledigt Rippy 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. {betrieb.hilfe_befehl && ( <> Prüfen: {betrieb.hilfe_befehl} )}

) : (
{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 — 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). */}
{/* 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 => (
{ort.name}: 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'}
{ort.pfad && (

{ort.pfad}

)}
))} {!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. */

Beide auf demselben Laufwerk — der Platz wird geteilt.

)}
Container-Platte: 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 && freigaben.length === 0 && (

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

)}
{/* 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 && (
Freigaben: 0 ? 'text-slate-200' : 'text-slate-400'}> {freigaben.length > 0 ? `${freigaben.length} eingehängt` : 'keine eingehängt'}
{freigaben.length === 0 ? (

Ohne Freigabe kann ein externer Encoder nichts tun — er sieht die Container-Platte nicht. Einhängen: Einstellungen → Speicherziele.

) : (
{freigaben.map(f => (

{f.name} {f.free_gb != null ? `${f.free_gb} GB frei` : 'Platz unbekannt'}

))}
)}
)}
{/* Right: Recently Ripped Posters */}
Zuletzt gerippt ({completedJobs.length}) {completedJobs.length > 0 && ( )} {completedJobs.length === 0 ? (
Noch keine gerippten Medien in der Datenbank
) : (
{completedJobs.slice(0, 3).map((item) => { const poster = posterUrl(item.meta) return (
{poster ? ( {item.title ) : (
{item.type}
)}

{item.title || 'Unbekannt'}

{new Date(item.startTime).toLocaleDateString('de-DE')}

) })}
)}
{/* POS 4: Live-Log Section */} setDetailJobId(null)} /> 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()). */} setNeuKandidat(null)} onStart={neuStarten} /> {/* 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} />
) }