Ampel / ampel (push) Successful in 1m26s
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>
1020 lines
49 KiB
TypeScript
1020 lines
49 KiB
TypeScript
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 & 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 & Inhaltsangabe</th>
|
||
<th className="px-6 py-3.5">Status & 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>
|
||
)
|
||
}
|