385 lines
17 KiB
TypeScript
385 lines
17 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { Cpu, HardDrive, Disc, Download, Trash2, Activity, Film, Server } 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 { HeroCinemaBanner } from '../components/HeroCinemaBanner'
|
|
|
|
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
|
|
meta?: JobMeta | null
|
|
}
|
|
|
|
interface SystemInfo {
|
|
api_version: string
|
|
plaetze: { name: string; frei_gb: number; gesamt_gb: number }[]
|
|
workers: { name: string; encoders: string[] }[]
|
|
}
|
|
|
|
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}`
|
|
}
|
|
|
|
async function fetchJobs(): Promise<Job[]> {
|
|
try {
|
|
const response = await api.get('/jobs')
|
|
return response.data
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
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)
|
|
const [sparklinePoints, setSparklinePoints] = useState<number[]>([20, 35, 15, 45, 30, 60, 40, 75, 50, 65])
|
|
const { toast } = useToast()
|
|
|
|
const jobLoeschen = async (job: Job) => {
|
|
try {
|
|
await api.delete(`/jobs/${job.id}`)
|
|
toast('success', `„${job.title || job.id.slice(0, 8)}" aus der Liste entfernt`)
|
|
setJobs(await fetchJobs())
|
|
} 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`)
|
|
setJobs(await fetchJobs())
|
|
} catch {
|
|
toast('error', 'Aufräumen fehlgeschlagen')
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const loadData = async () => {
|
|
try {
|
|
const [jobsData, sysData] = await Promise.all([
|
|
fetchJobs(),
|
|
api.get('/system/info').then(r => r.data).catch(() => null),
|
|
])
|
|
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])
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
loadData()
|
|
const interval = setInterval(loadData, 4000)
|
|
return () => clearInterval(interval)
|
|
}, [])
|
|
|
|
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')
|
|
|
|
// 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(' ')
|
|
|
|
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">
|
|
|
|
{/* 1. Top Hero Cinema Showcase */}
|
|
<HeroCinemaBanner
|
|
aktiverJob={aktiverJob}
|
|
completedJobs={completedJobs}
|
|
onDetailClick={id => setDetailJobId(id)}
|
|
onCancelClick={id => api.post(`/jobs/${id}/cancel`).catch(() => {})}
|
|
/>
|
|
|
|
{/* 2. Main 2-Column Grid Layout */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
|
|
|
{/* LEFT COLUMN (lg:col-span-7) */}
|
|
<div className="lg:col-span-7 space-y-6">
|
|
|
|
{/* Current Job Card */}
|
|
<Card className="border border-amber-500/30 bg-[#0f1422] shadow-xl">
|
|
<CardHeader className="border-b border-slate-800">
|
|
<CardTitle className="text-amber-400 flex items-center gap-2">
|
|
<Disc size={18} className={aktiverJob ? 'text-amber-400 animate-disc-spin' : 'text-slate-500'} />
|
|
Current Job
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-5">
|
|
{aktiverJob ? (
|
|
<div className="flex items-start gap-4">
|
|
{posterUrl(aktiverJob.meta) ? (
|
|
<img
|
|
src={posterUrl(aktiverJob.meta)!}
|
|
alt={aktiverJob.title || ''}
|
|
className="w-20 h-28 rounded-xl object-cover shadow-md border border-slate-700/80 flex-shrink-0"
|
|
/>
|
|
) : (
|
|
<div className="w-20 h-28 rounded-xl bg-gradient-to-br from-slate-800 to-slate-900 border border-slate-700 flex items-center justify-center flex-shrink-0">
|
|
<Disc size={32} className="text-amber-400" />
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1 min-w-0 space-y-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<h3 className="text-lg font-bold text-slate-100 truncate">
|
|
{aktiverJob.title || 'Unbekannter Titel'}
|
|
</h3>
|
|
<TypeBadge type={aktiverJob.type} />
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between text-xs text-slate-400 font-mono">
|
|
<span>Status: {aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Extraktion'}</span>
|
|
<span className="text-amber-400 font-bold">{aktiverJob.progress}%</span>
|
|
</div>
|
|
<div className="w-full h-2.5 rounded-full bg-slate-800 overflow-hidden">
|
|
<div
|
|
className="h-full bg-gradient-to-r from-amber-500 to-amber-400 rounded-full transition-all duration-500 shadow-[0_0_10px_rgba(245,158,11,0.5)]"
|
|
style={{ width: `${aktiverJob.progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-xs text-slate-400 space-y-0.5 font-mono pt-1">
|
|
<p>Status: {aktiverJob.status === 'transcoding' ? 'HandBrake H.265' : 'MakeMKV Lossless Extraktion'}</p>
|
|
<p>Disc typ: <strong className="text-amber-300">{aktiverJob.type.toUpperCase()}</strong></p>
|
|
<p>Gerät: {aktiverJob.device} · Gestartet: {new Date(aktiverJob.startTime).toLocaleTimeString('de-DE')}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-6 text-slate-400 text-sm">
|
|
Kein aktiver Job — Laufwerk ist bereit für eine neue Disc
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Ripping Queue Card (ECHTE WARTESCHLANGE) */}
|
|
<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">
|
|
{queueJobs.length === 0 ? (
|
|
<div className="text-center py-8 text-slate-500 text-xs font-mono">
|
|
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>
|
|
<span className="text-xs text-slate-400 font-mono">Wartend</span>
|
|
</div>
|
|
))
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
</div>
|
|
|
|
{/* 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 */}
|
|
<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">
|
|
<Server size={18} className="text-amber-400" />
|
|
Server Status (Echte Live-Daten)
|
|
</CardTitle>
|
|
</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">
|
|
<span className="text-slate-400 flex items-center gap-1.5"><Cpu size={14} className="text-amber-400" /> Auslastung:</span>
|
|
<span className="text-amber-400 font-bold">{aktiverJob ? `${cpuPercent}% (Aktiv)` : 'Standby (0%)'}</span>
|
|
</div>
|
|
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
|
<div
|
|
className="h-full bg-amber-500 rounded-full transition-all duration-500"
|
|
style={{ width: `${aktiverJob ? Math.max(15, cpuPercent) : 5}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
|
<span className="text-slate-400 flex items-center gap-1.5"><Activity size={14} className="text-amber-400" /> Worker Status:</span>
|
|
<span className="text-slate-200">{workerCount} Worker Online</span>
|
|
</div>
|
|
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
|
<div className="h-full bg-emerald-500 rounded-full w-full" />
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
|
<span className="text-slate-400 flex items-center gap-1.5"><HardDrive size={14} className="text-amber-400" /> Speichermounts:</span>
|
|
<span className="text-slate-200">{freiGb > 0 ? `${freiGb} GB frei` : 'Aktiv'}</span>
|
|
</div>
|
|
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
|
<div
|
|
className="h-full bg-amber-500/80 rounded-full transition-all duration-500"
|
|
style={{ width: `${Math.max(10, belegtPercent)}%` }}
|
|
/>
|
|
</div>
|
|
</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
|
|
d={`${sparklinePath} L 100 50 L 0 50 Z`}
|
|
strokeWidth="2.5"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Recently Ripped Card (ECHTE DE-DATENBANK JOBS) */}
|
|
<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>
|
|
{completedJobs.length > 0 && (
|
|
<Button variant="ghost" size="sm" onClick={() => setAufraeumenOffen(true)}>
|
|
<Trash2 size={13} /> Aufräumen
|
|
</Button>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent className="p-4">
|
|
{completedJobs.length === 0 ? (
|
|
<div className="text-center py-8 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>
|
|
|
|
{/* 3. Lower Section: Drive Slot & Live Log */}
|
|
<DeviceDiscovery />
|
|
<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"
|
|
/>
|
|
</div>
|
|
)
|
|
}
|