feat(ui): 5-Kachel-Banner ohne schwarze Loecher gefuellt, Vollstaendige Job-Tabelle mit TMDB-Beschreibungen wiederhergestellt
Ampel / ampel (push) Successful in 28s

This commit is contained in:
Hitonabi
2026-07-24 16:36:12 +02:00
parent 9b19b74d39
commit e18315814c
2 changed files with 273 additions and 85 deletions
+198 -26
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Cpu, HardDrive, Disc, Download, Trash2, Activity, Film, Server } from 'lucide-react'
import { Cpu, HardDrive, Disc, Download, Trash2, Activity, Film, Server, RotateCcw, FileSpreadsheet, List, LayoutGrid } from 'lucide-react'
import { api } from '../lib/api'
import { useToast } from '../context/ToastContext'
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
@@ -10,6 +10,7 @@ import DeviceDiscovery from '../components/DeviceDiscovery'
import JobDetailModal from '../components/JobDetailModal'
import LiveLogSection from '../components/LiveLogSection'
import { HeroCinemaBanner } from '../components/HeroCinemaBanner'
import { RadialProgress } from '../components/ui/RadialProgress'
interface JobMeta {
year?: number
@@ -61,6 +62,7 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true)
const [detailJobId, setDetailJobId] = useState<string | null>(null)
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
const [tableFilter, setTableFilter] = useState<'all' | 'active' | 'completed' | 'failed'>('all')
const [sparklinePoints, setSparklinePoints] = useState<number[]>([20, 35, 15, 45, 30, 60, 40, 75, 50, 65])
const { toast } = useToast()
@@ -95,7 +97,6 @@ export default function Dashboard() {
setJobs(jobsData)
if (sysData) setSystemInfo(sysData)
// Live Sparkline Graph Punkte dynamisch simulieren basierend auf echter Aktivität
const isBusy = jobsData.some((j: Job) => j.status === 'processing' || j.status === 'transcoding')
const newPoint = isBusy ? Math.floor(Math.random() * 40) + 60 : Math.floor(Math.random() * 25) + 10
setSparklinePoints(prev => [...prev.slice(1), newPoint])
@@ -113,23 +114,26 @@ export default function Dashboard() {
const queueJobs = jobs.filter(j => j.status === 'pending')
const completedJobs = jobs.filter(j => j.status === 'completed')
// Berechnung ECHTER Speicherplatz-Daten aus der API (/system/info)
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)))
// Echte CPU/Job Auslastung
const cpuPercent = aktiverJob ? aktiverJob.progress : (jobs.length > 0 ? 15 : 5)
const workerCount = systemInfo?.workers?.length || 1
// Sparkline SVG Path Generator
const sparklinePath = sparklinePoints.map((val, idx) => {
const x = (idx / (sparklinePoints.length - 1)) * 100
const y = 50 - (val / 100) * 40
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`
}).join(' ')
const filteredTableJobs = jobs.filter(j => {
if (tableFilter === 'active') return j.status === 'processing' || j.status === 'transcoding' || j.status === 'pending'
if (tableFilter === 'completed') return j.status === 'completed'
if (tableFilter === 'failed') return j.status === 'failed'
return true
})
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
@@ -141,7 +145,7 @@ export default function Dashboard() {
return (
<div className="space-y-6">
{/* 1. Top Hero Cinema Showcase */}
{/* 1. Top Hero Cinema Showcase (5 Kacheln vollständig ausgefüllt) */}
<HeroCinemaBanner
aktiverJob={aktiverJob}
completedJobs={completedJobs}
@@ -149,7 +153,7 @@ export default function Dashboard() {
onCancelClick={id => api.post(`/jobs/${id}/cancel`).catch(() => {})}
/>
{/* 2. Main 2-Column Grid Layout */}
{/* 2. Main 2-Column Grid Layout (SC1 Mockup Bereich) */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* LEFT COLUMN (lg:col-span-7) */}
@@ -214,27 +218,29 @@ export default function Dashboard() {
</CardContent>
</Card>
{/* Ripping Queue Card (ECHTE WARTESCHLANGE) */}
{/* Ripping Queue Card */}
<Card className="border border-slate-800 bg-[#0f1422]">
<CardHeader className="border-b border-slate-800">
<CardTitle className="text-slate-200">Ripping Queue ({queueJobs.length})</CardTitle>
</CardHeader>
<CardContent className="p-4 space-y-3">
<CardContent className="p-4">
{queueJobs.length === 0 ? (
<div className="text-center py-8 text-slate-500 text-xs font-mono">
<div className="py-4 text-center text-xs font-mono text-slate-500">
Warteschlange ist leer (0 Jobs eingereiht)
</div>
) : (
queueJobs.map((j, idx) => (
<div key={j.id} className="flex items-center justify-between p-3 rounded-xl bg-slate-900/60 border border-slate-800 text-sm">
<div className="flex items-center gap-3">
<span className="font-mono text-amber-400 font-bold">{idx + 1}.</span>
<span className="font-medium text-slate-200">{j.title || 'Unbekannt'}</span>
<TypeBadge type={j.type} />
<div className="space-y-2">
{queueJobs.map((j, idx) => (
<div key={j.id} className="flex items-center justify-between p-3 rounded-xl bg-slate-900/60 border border-slate-800 text-sm">
<div className="flex items-center gap-3">
<span className="font-mono text-amber-400 font-bold">{idx + 1}.</span>
<span className="font-medium text-slate-200">{j.title || 'Unbekannt'}</span>
<TypeBadge type={j.type} />
</div>
<span className="text-xs text-slate-400 font-mono">Wartend</span>
</div>
<span className="text-xs text-slate-400 font-mono">Wartend</span>
</div>
))
))}
</div>
)}
</CardContent>
</Card>
@@ -244,7 +250,7 @@ export default function Dashboard() {
{/* RIGHT COLUMN (lg:col-span-5) */}
<div className="lg:col-span-5 space-y-6">
{/* Server Status Card mit ECHTEN API-DATEN & LIVE SPARKLINE GRAPH */}
{/* Server Status Card */}
<Card className="border border-slate-800 bg-[#0f1422]">
<CardHeader className="border-b border-slate-800">
<CardTitle className="text-slate-200 flex items-center gap-2">
@@ -254,8 +260,6 @@ export default function Dashboard() {
</CardHeader>
<CardContent className="p-5">
<div className="flex flex-col sm:flex-row items-center justify-between gap-6">
{/* Echte API Daten */}
<div className="space-y-4 flex-1 w-full">
<div>
<div className="flex items-center justify-between text-xs font-mono mb-1">
@@ -294,7 +298,6 @@ export default function Dashboard() {
</div>
</div>
{/* Dynamischer Live Sparkline Graph */}
<div className="w-full sm:w-36 h-24 flex-shrink-0 flex items-end justify-center pt-2">
<svg viewBox="0 0 100 50" className="w-full h-full stroke-amber-400 fill-amber-500/10 transition-all duration-500">
<path
@@ -308,7 +311,7 @@ export default function Dashboard() {
</CardContent>
</Card>
{/* Recently Ripped Card (ECHTE DE-DATENBANK JOBS) */}
{/* Recently Ripped Card */}
<Card className="border border-slate-800 bg-[#0f1422]">
<CardHeader className="border-b border-slate-800 flex items-center justify-between">
<CardTitle className="text-slate-200">Zuletzt gerippt ({completedJobs.length})</CardTitle>
@@ -364,8 +367,177 @@ export default function Dashboard() {
</div>
{/* 3. Lower Section: Drive Slot & Live Log */}
{/* 3. Laufwerke & Disc-Erkennung (Device Discovery Slot) */}
<DeviceDiscovery />
{/* 4. VOLLSTÄNDIGE JOB-TABELLE & MEDIEN-BIBLIOTHEK (AUS DEM ALTEN DESIGN + METADATEN/BESCHREIBUNGEN) */}
<Card className="border border-slate-800 bg-[#0f1422] overflow-hidden">
<CardHeader className="border-b border-slate-800 flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
<CardTitle className="text-slate-100">Job-Historie &amp; Medien-Bibliothek</CardTitle>
<span className="text-xs font-mono text-slate-400">({filteredTableJobs.length} Einträge)</span>
</div>
<div className="flex items-center gap-3 flex-wrap">
{/* Filter Pills */}
<div className="flex items-center p-1 rounded-xl bg-slate-900 border border-slate-800">
{(['all', 'active', 'completed', 'failed'] as const).map((f) => (
<button
key={f}
onClick={() => setTableFilter(f)}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all ${
tableFilter === f
? 'bg-amber-500 text-slate-950 font-bold shadow-sm'
: 'text-slate-400 hover:text-slate-200'
}`}
>
{f === 'all' ? 'Alle' : f === 'active' ? 'Aktiv' : f === 'completed' ? 'Fertig' : 'Fehler'}
</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={13} />
CSV Export
</a>
)}
{jobs.some(j => j.status === 'completed' || j.status === 'failed') && (
<Button
variant="secondary"
size="sm"
onClick={() => setAufraeumenOffen(true)}
>
<Trash2 size={13} /> Aufräumen
</Button>
)}
</div>
</CardHeader>
{filteredTableJobs.length === 0 ? (
<div className="text-center py-12 text-slate-500 text-xs font-mono">
Keine Jobs für diesen Filter vorhanden.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead className="bg-slate-950/80 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; Beschreibung</th>
<th className="px-6 py-3.5">Status</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">
{filteredTableJobs.map(job => {
const poster = posterUrl(job.meta)
return (
<tr key={job.id} className="transition-colors hover:bg-slate-900/50">
<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" />
) : (
<div className="w-10 h-14 rounded-lg bg-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"
>
{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>
</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>
{job.status === 'completed' && (
<Button variant="amber" size="sm" onClick={() => setDetailJobId(job.id)}>
<Download size={13} /> Download
</Button>
)}
{job.status === 'failed' && job.can_retry && (
<Button
variant="secondary"
size="sm"
onClick={() => {
api.post(`/jobs/${job.id}/retry-transcode`)
.then(() => toast('success', 'Kompression neu eingereiht'))
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Fehlgeschlagen'))
}}
>
<RotateCcw size={13} /> Neu
</Button>
)}
{(job.status === 'completed' || job.status === 'failed') && (
<button
onClick={() => jobLoeschen(job)}
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
title="Löschen"
>
<Trash2 size={14} />
</button>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</Card>
{/* 5. Live-Log Section */}
<LiveLogSection />
<JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} />