diff --git a/docker/ui/src/App.tsx b/docker/ui/src/App.tsx index 3ca4dc1..66a0f8a 100644 --- a/docker/ui/src/App.tsx +++ b/docker/ui/src/App.tsx @@ -1,11 +1,12 @@ import { useState } from 'react' -import { LayoutDashboard, Disc, Settings, LogOut, Sun, Moon } from 'lucide-react' +import { LayoutDashboard, Disc, Settings, LogOut, Sun, Moon, Terminal } from 'lucide-react' import { useDarkMode } from './context/ThemeContext' import Dashboard from './pages/Dashboard' import MetadataPreview from './pages/MetadataPreview' import SettingsPage from './pages/Settings' +import LogsPage from './pages/Logs' -type Page = 'dashboard' | 'metadata' | 'settings' +type Page = 'dashboard' | 'metadata' | 'settings' | 'logs' function App() { const [page, setPage] = useState('dashboard') @@ -14,6 +15,7 @@ function App() { const navItems = [ { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, { id: 'metadata', label: 'Metadaten', icon: Disc }, + { id: 'logs', label: 'Logs', icon: Terminal }, { id: 'settings', label: 'Einstellungen', icon: Settings }, ] @@ -72,6 +74,7 @@ function App() {
{page === 'dashboard' && } {page === 'metadata' && } + {page === 'logs' && } {page === 'settings' && }
diff --git a/docker/ui/src/components/ConfirmDialog.tsx b/docker/ui/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..7d619fd --- /dev/null +++ b/docker/ui/src/components/ConfirmDialog.tsx @@ -0,0 +1,83 @@ +import { useState } from 'react' +import { useDarkMode } from '../context/ThemeContext' + +interface ConfirmDialogProps { + isOpen: boolean + onClose: () => void + onConfirm: () => void + title: string + message: string + confirmText?: string + cancelText?: string + type?: 'warning' | 'info' | 'success' +} + +export default function ConfirmDialog({ + isOpen, + onClose, + onConfirm, + title, + message, + confirmText = 'Bestätigen', + cancelText = 'Abbrechen', + type = 'info', +}: ConfirmDialogProps) { + const { theme } = useDarkMode() + + const getIcon = () => { + switch (type) { + case 'warning': return ⚠️ + case 'success': return + default: return 🤔 + } + } + + const getConfirmColor = () => { + switch (type) { + case 'warning': return 'bg-amber-600 hover:bg-amber-700 shadow-lg shadow-amber-500/30' + case 'success': return 'bg-emerald-600 hover:bg-emerald-700 shadow-lg shadow-emerald-500/30' + default: return 'bg-indigo-600 hover:bg-indigo-700 shadow-lg shadow-indigo-500/30' + } + } + + if (!isOpen) return null + + return ( +
+
+ {/* Header */} +
+
+ {getIcon()} +

+ {title} +

+
+
+ + {/* Body */} +
+

+ {message} +

+
+ + {/* Footer */} +
+ + +
+
+
+ ) +} diff --git a/docker/ui/src/components/DeviceDiscovery.tsx b/docker/ui/src/components/DeviceDiscovery.tsx new file mode 100644 index 0000000..b7c8a96 --- /dev/null +++ b/docker/ui/src/components/DeviceDiscovery.tsx @@ -0,0 +1,162 @@ +import { useState, useEffect } from 'react' +import axios from 'axios' +import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react' +import { useDarkMode } from '../context/ThemeContext' + +interface Device { + id: string + name: string + type: 'cd' | 'dvd' | 'bluray' | 'unknown' + path: string + status: 'empty' | 'ready' | 'ripping' + serial?: string + model?: string +} + +const API_URL = 'http://localhost:8000' + +export default function DeviceDiscovery() { + const [devices, setDevices] = useState([]) + const [loading, setLoading] = useState(true) + const { theme } = useDarkMode() + + const refreshDevices = async () => { + try { + const response = await axios.get(`${API_URL}/devices`) + setDevices(response.data) + } catch (error) { + console.error('Fehler beim Laden der Geräte:', error) + } finally { + setLoading(false) + } + } + + useEffect(() => { + refreshDevices() + const interval = setInterval(refreshDevices, 5000) + return () => clearInterval(interval) + }, []) + + const getTypeColor = (type: string) => { + switch (type) { + case 'cd': return theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-600' + case 'dvd': return theme === 'dark' ? 'bg-purple-900/30 text-purple-400' : 'bg-purple-100 text-purple-600' + case 'bluray': return theme === 'dark' ? 'bg-pink-900/30 text-pink-400' : 'bg-pink-100 text-pink-600' + default: return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600' + } + } + + const getStatusColor = (status: string) => { + switch (status) { + case 'empty': return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600' + case 'ready': return theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-700' + case 'ripping': return theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-700' + default: return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600' + } + } + + const getStatusIcon = (status: string) => { + switch (status) { + case 'empty': return + case 'ready': return + case 'ripping': return + default: return + } + } + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+
+
+

Laufwerke

+

Erkannte Geräte mit automatischer Typ-Erkennung

+
+ +
+ +
+ {devices.length === 0 ? ( +
+
+ +
+

Keine Laufwerke gefunden

+

+ Stelle sicher, dass ein Laufwerk angeschlossen ist +

+
+ ) : ( +
+ {devices.map(device => ( +
+
+
+
+ +
+
+
+

+ {device.name || 'Unbekanntes Laufwerk'} +

+ {device.status === 'ripping' && ( + + + Rippt + + )} +
+
+ + {device.path} + + {device.serial && ( + + {device.serial} + + )} + {device.model && ( + + {device.model} + + )} +
+
+ + {getStatusIcon(device.status)} + {device.status === 'empty' ? 'Keine Disc' : + device.status === 'ready' ? 'Bereit' : + 'Rippt'} + + + Typ: {device.type.toUpperCase()} + +
+
+
+ +
+
+ ))} +
+ )} +
+
+ ) +} diff --git a/docker/ui/src/components/LiveLogSection.tsx b/docker/ui/src/components/LiveLogSection.tsx new file mode 100644 index 0000000..da72d4d --- /dev/null +++ b/docker/ui/src/components/LiveLogSection.tsx @@ -0,0 +1,141 @@ +import { useState, useEffect } from 'react' +import axios from 'axios' +import { Terminal, Disc, Play, CheckCircle, AlertCircle, Clock, Download } from 'lucide-react' +import { useDarkMode } from '../context/ThemeContext' + +interface LiveLogEntry { + id: string + timestamp: string + level: 'info' | 'warning' | 'error' | 'success' + message: string + source: string +} + +export default function LiveLogSection() { + const [logs, setLogs] = useState([]) + const [currentJob, setCurrentJob] = useState(null) + const { theme } = useDarkMode() + + const API_URL = 'http://localhost:8000' + + useEffect(() => { + const fetchJobs = async () => { + try { + const response = await axios.get(`${API_URL}/jobs`) + const jobs = response.data + const current = jobs.find((j: any) => j.status === 'processing') + setCurrentJob(current || jobs.find((j: any) => j.status === 'pending')) + } catch (error) { + console.error('Fehler beim Laden der Jobs:', error) + } + } + + fetchJobs() + const jobInterval = setInterval(fetchJobs, 5000) + + // TODO: WebSocket/SSE für Echtzeit-Logs implementieren + // const logInterval = setInterval(fetchLogs, 1000) + + return () => { + clearInterval(jobInterval) + // clearInterval(logInterval) + } + }, []) + + const getStatusColor = (level: string) => { + switch (level) { + case 'error': return 'text-rose-500' + case 'warning': return 'text-amber-500' + case 'success': return 'text-emerald-500' + default: return 'text-blue-500' + } + } + + const getStatusIcon = (level: string) => { + switch (level) { + case 'error': return + case 'warning': return + case 'success': return + default: return + } + } + + return ( +
+
+
+

Live-Log

+

+ {currentJob + ? `Aktiver Job: ${currentJob.type.toUpperCase()} (${currentJob.progress}%)` + : 'Kein aktiver Job'} +

+
+ {currentJob && ( +
+
+ {currentJob.progress === 100 ? : } + {currentJob.progress}% +
+
+ )} +
+ +
+ {logs.length === 0 ? ( +
+ +

Warte auf Log-Einträge...

+
+ ) : ( + logs.map((log) => ( +
+ + {new Date(log.timestamp).toLocaleTimeString('de-DE', { hour12: false })} + + + [{log.source}] + + {log.message} + + {getStatusIcon(log.level)} + +
+ )) + )} +
+ + {/* Quick Stats */} +
+ {currentJob && ( + <> +
+

Fortschritt

+

+ {currentJob.progress}% +

+
+
+

Gerät

+

+ {currentJob.device || 'Unbekannt'} +

+
+
+

Typ

+

+ {currentJob.type?.toUpperCase() || 'Unbekannt'} +

+
+ + )} +
+
+ ) +} diff --git a/docker/ui/src/components/RipTargetModal.tsx b/docker/ui/src/components/RipTargetModal.tsx new file mode 100644 index 0000000..96e6359 --- /dev/null +++ b/docker/ui/src/components/RipTargetModal.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react' +import { useDarkMode } from '../context/ThemeContext' + +interface TargetConfig { + id: string + name: string + path: string + type: 'movies' | 'series' | 'music' + isActive: boolean +} + +interface RipTargetModalProps { + isOpen: boolean + onClose: () => void + onSave: (target: TargetConfig) => void +} + +export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetModalProps) { + const [selectedType, setSelectedType] = useState('movies') + const [customPath, setCustomPath] = useState('') + const { theme } = useDarkMode() + + const defaultTargets: TargetConfig[] = [ + { id: '1', name: 'Movies', path: '/app/media/movies', type: 'movies', isActive: true }, + { id: '2', name: 'Series', path: '/app/media/series', type: 'series', isActive: true }, + { id: '3', name: 'Music', path: '/app/media/music', type: 'music', isActive: true }, + ] + + const [targets, setTargets] = useState(defaultTargets) + + const handleSave = () => { + const target = targets.find(t => t.type === selectedType) + if (target) { + onSave(target) + } + onClose() + } + + if (!isOpen) return null + + return ( +
+
+ {/* Header */} +
+

Rip-Ziel auswählen

+

+ Wohin sollen die gerippten Dateien gespeichert werden? +

+
+ + {/* Body */} +
+ {/* Type Selection */} +
+ +
+ {['movies', 'series', 'music'].map((type) => ( + + ))} +
+
+ + {/* Path Display */} +
+ +
+ 📁 + t.type === selectedType)?.path || ''} + onChange={(e) => setCustomPath(e.target.value)} + className={`flex-1 bg-transparent outline-none text-sm font-mono ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`} + placeholder="/path/to/target" + /> + +
+

+ {customPath + ? `Verwende benutzerdefiniertes Verzeichnis: ${customPath}` + : `Verwende Standard-Verzeichnis: ${targets.find(t => t.type === selectedType)?.path}` + } +

+
+
+ + {/* Footer */} +
+ + +
+
+
+ ) +} diff --git a/docker/ui/src/pages/Dashboard.tsx b/docker/ui/src/pages/Dashboard.tsx index 3b3c312..7e9ed10 100644 --- a/docker/ui/src/pages/Dashboard.tsx +++ b/docker/ui/src/pages/Dashboard.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from 'react' import axios from 'axios' import { Clock, Activity, HardDrive, BarChart, AlertCircle, CheckCircle, Disc, Play, Pause, SkipForward } from 'lucide-react' import { useDarkMode } from '../context/ThemeContext' +import DeviceDiscovery from '../components/DeviceDiscovery' +import LiveLogSection from '../components/LiveLogSection' interface Job { id: string @@ -182,6 +184,12 @@ export default function Dashboard() {

Übersicht über alle Ripping-Jobs und Geräte

+ {/* Device Discovery Section */} + + + {/* Live Log Section */} + + {/* Stats Cards */}
([]) + const [filter, setFilter] = useState('all') + const [loading, setLoading] = useState(true) + const { theme } = useDarkMode() + + const refreshLogs = async () => { + try { + // TODO: API endpoint für Logs implementieren + // const response = await axios.get(`${API_URL}/logs`) + // setLogs(response.data) + + // Mock-Daten für Demo + const mockLogs: LogEntry[] = [ + { id: '1', timestamp: '2024-01-15 14:30:22', level: 'info', source: 'udev-daemon', message: 'Disc-Einwurf erkannt' }, + { id: '2', timestamp: '2024-01-15 14:30:25', level: 'info', source: 'worker', message: 'Job erstellt: DVD, Device: /dev/disc/PLEXTOR_BD_XD' }, + { id: '3', timestamp: '2024-01-15 14:30:30', level: 'success', source: 'prescan', message: 'Pre-Scan abgeschlossen, Confidence: 0.92' }, + { id: '4', timestamp: '2024-01-15 14:30:35', level: 'info', source: 'tmdb', message: 'Metadaten lookup erfolgreich: "The Matrix"' }, + { id: '5', timestamp: '2024-01-15 14:31:00', level: 'info', source: 'ripping', message: 'Starte MakeMKV-Ripping...' }, + { id: '6', timestamp: '2024-01-15 14:35:22', level: 'warning', source: 'ripping', message: 'Kapitel 5 übersprungen (Trailer)' }, + { id: '7', timestamp: '2024-01-15 14:40:15', level: 'success', source: 'ripping', message: 'Ripping abgeschlossen, Dateigröße: 4.2 GB' }, + { id: '8', timestamp: '2024-01-15 14:40:20', level: 'info', source: 'jellyfin', message: 'NFO-Datei generiert' }, + { id: '9', timestamp: '2024-01-15 14:40:25', level: 'info', source: 'jellyfin', message: 'Poster heruntergeladen' }, + { id: '10', timestamp: '2024-01-15 14:40:30', level: 'success', source: 'worker', message: 'Job abgeschlossen' }, + ] + setLogs(mockLogs) + } catch (error) { + console.error('Fehler beim Laden der Logs:', error) + } finally { + setLoading(false) + } + } + + useEffect(() => { + refreshLogs() + const interval = setInterval(refreshLogs, 10000) + return () => clearInterval(interval) + }, []) + + const getLevelColor = (level: string) => { + switch (level) { + case 'error': return theme === 'dark' ? 'bg-rose-900/30 text-rose-400 border-rose-700/20' : 'bg-rose-100 text-rose-700 border-rose-600/20' + case 'warning': return theme === 'dark' ? 'bg-amber-900/30 text-amber-400 border-amber-700/20' : 'bg-amber-100 text-amber-700 border-amber-600/20' + case 'success': return theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400 border-emerald-700/20' : 'bg-emerald-100 text-emerald-700 border-emerald-600/20' + default: return theme === 'dark' ? 'bg-blue-900/30 text-blue-400 border-blue-700/20' : 'bg-blue-100 text-blue-700 border-blue-600/20' + } + } + + const getLevelIcon = (level: string) => { + switch (level) { + case 'error': return + case 'warning': return + case 'success': return + default: return + } + } + + const getLevelLabel = (level: string) => { + switch (level) { + case 'error': return 'Fehler' + case 'warning': return 'Warnung' + case 'success': return 'Erfolg' + default: return 'Info' + } + } + + const filteredLogs = filter === 'all' + ? logs + : logs.filter(log => log.level === filter || log.source === filter) + + const stats = { + info: logs.filter(l => l.level === 'info').length, + warning: logs.filter(l => l.level === 'warning').length, + error: logs.filter(l => l.level === 'error').length, + success: logs.filter(l => l.level === 'success').length, + } + + return ( +
+ {/* Header */} +
+

Logs

+

Alle relevanten Log-Informationen des Ripping-Systems

+
+ + {/* Stats */} +
+
+
+
+ +
+ Info +
+

{stats.info}

+
+
+
+
+ +
+ Warnung +
+

{stats.warning}

+
+
+
+
+ +
+ Erfolg +
+

{stats.success}

+
+
+
+
+ +
+ Fehler +
+

{stats.error}

+
+
+ + {/* Filters */} +
+
+ Filter: + + + +
+ +
+
+ + {/* Logs List */} +
+
+ {filteredLogs.length === 0 ? ( +
+
+ +
+

Keine Logs gefunden

+
+ ) : ( +
+ {filteredLogs.map(log => ( +
+
+
+ {getLevelIcon(log.level)} +
+
+
+ + {log.source} + + + {log.timestamp} + + + {getLevelLabel(log.level)} + +
+

+ {log.message} +

+ {log.details && ( +

+ {log.details} +

+ )} +
+
+
+ ))} +
+ )} +
+
+
+ ) +}