feat(ui): implement Discovery, Logs, and Live Log
Ampel / ampel (push) Successful in 29s

- DeviceDiscovery: auto device detection with type and status
- LogsPage: comprehensive logs view with filters and stats
- RipTargetModal: target directory selection wizard
- ConfirmDialog: generic confirmation dialog component
- LiveLogSection: real-time job progress and logs
- Update App.tsx to include new pages
This commit is contained in:
Hitonabi
2026-07-23 12:38:16 +02:00
parent 3e88c328db
commit e9652a428c
7 changed files with 769 additions and 2 deletions
+5 -2
View File
@@ -1,11 +1,12 @@
import { useState } from 'react' 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 { useDarkMode } from './context/ThemeContext'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import MetadataPreview from './pages/MetadataPreview' import MetadataPreview from './pages/MetadataPreview'
import SettingsPage from './pages/Settings' import SettingsPage from './pages/Settings'
import LogsPage from './pages/Logs'
type Page = 'dashboard' | 'metadata' | 'settings' type Page = 'dashboard' | 'metadata' | 'settings' | 'logs'
function App() { function App() {
const [page, setPage] = useState<Page>('dashboard') const [page, setPage] = useState<Page>('dashboard')
@@ -14,6 +15,7 @@ function App() {
const navItems = [ const navItems = [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ id: 'metadata', label: 'Metadaten', icon: Disc }, { id: 'metadata', label: 'Metadaten', icon: Disc },
{ id: 'logs', label: 'Logs', icon: Terminal },
{ id: 'settings', label: 'Einstellungen', icon: Settings }, { id: 'settings', label: 'Einstellungen', icon: Settings },
] ]
@@ -72,6 +74,7 @@ function App() {
<div className="max-w-7xl mx-auto p-6"> <div className="max-w-7xl mx-auto p-6">
{page === 'dashboard' && <Dashboard />} {page === 'dashboard' && <Dashboard />}
{page === 'metadata' && <MetadataPreview />} {page === 'metadata' && <MetadataPreview />}
{page === 'logs' && <LogsPage />}
{page === 'settings' && <SettingsPage />} {page === 'settings' && <SettingsPage />}
</div> </div>
</main> </main>
@@ -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 <span className="text-4xl"></span>
case 'success': return <span className="text-4xl"></span>
default: return <span className="text-4xl">🤔</span>
}
}
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm transition-all">
<div className={`w-full max-w-md rounded-xl shadow-2xl transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
{/* Header */}
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
<div className="flex items-center gap-3">
{getIcon()}
<h2 className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
{title}
</h2>
</div>
</div>
{/* Body */}
<div className="px-6 py-6">
<p className={`text-sm leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
{message}
</p>
</div>
{/* Footer */}
<div className={`px-6 py-4 border-t transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex justify-end gap-3`}>
<button
onClick={onClose}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${theme === 'dark' ? 'text-slate-400 hover:bg-slate-700' : 'text-slate-600 hover:bg-slate-100'}`}
>
{cancelText}
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 rounded-lg text-sm font-medium text-white transition-colors ${getConfirmColor()}`}
>
{confirmText}
</button>
</div>
</div>
</div>
)
}
@@ -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<Device[]>([])
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 <AlertCircle size={16} />
case 'ready': return <CheckCircle size={16} />
case 'ripping': return <RefreshCw size={16} className="animate-spin" />
default: return <AlertCircle size={16} />
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<RefreshCw className="animate-spin text-indigo-600" size={24} />
</div>
)
}
return (
<div className={`rounded-xl shadow-sm border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex items-center justify-between`}>
<div>
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Laufwerke</h2>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Erkannte Geräte mit automatischer Typ-Erkennung</p>
</div>
<button
onClick={refreshDevices}
className={`p-2 rounded-lg transition-colors ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-600'}`}
title="Geräte neu laden"
>
<RefreshCw size={20} className={loading ? 'animate-spin' : ''} />
</button>
</div>
<div className="p-6 transition-colors duration-300">
{devices.length === 0 ? (
<div className="text-center py-12 transition-colors duration-300">
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-4 ${theme === 'dark' ? 'bg-slate-700' : 'bg-slate-100'}`}>
<HardDrive className={theme === 'dark' ? 'text-slate-400' : 'text-slate-400'} size={32} />
</div>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Keine Laufwerke gefunden</p>
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Stelle sicher, dass ein Laufwerk angeschlossen ist
</p>
</div>
) : (
<div className="space-y-4 transition-colors duration-300">
{devices.map(device => (
<div key={device.id} className={`rounded-lg p-5 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`}>
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className={`p-3 rounded-lg ${getTypeColor(device.type)}`}>
<Disc size={24} />
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<h3 className={`font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
{device.name || 'Unbekanntes Laufwerk'}
</h3>
{device.status === 'ripping' && (
<span className="flex items-center gap-1 text-xs font-medium text-blue-500">
<RefreshCw size={12} className="animate-spin" />
Rippt
</span>
)}
</div>
<div className="flex flex-wrap gap-2 mt-2">
<span className={`text-xs font-mono px-2 py-1 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-100 text-slate-600'}`}>
{device.path}
</span>
{device.serial && (
<span className={`text-xs px-2 py-1 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-100 text-slate-600'}`}>
{device.serial}
</span>
)}
{device.model && (
<span className={`text-xs px-2 py-1 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-100 text-slate-600'}`}>
{device.model}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-3">
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium ${getStatusColor(device.status)}`}>
{getStatusIcon(device.status)}
{device.status === 'empty' ? 'Keine Disc' :
device.status === 'ready' ? 'Bereit' :
'Rippt'}
</span>
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Typ: {device.type.toUpperCase()}
</span>
</div>
</div>
</div>
<button className={`text-sm font-medium transition-colors ${theme === 'dark' ? 'text-indigo-400 hover:text-indigo-300' : 'text-indigo-600 hover:text-indigo-700'}`}>
Details
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
+141
View File
@@ -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<LiveLogEntry[]>([])
const [currentJob, setCurrentJob] = useState<any>(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 <AlertCircle size={16} />
case 'warning': return <AlertCircle size={16} />
case 'success': return <CheckCircle size={16} />
default: return <Terminal size={16} />
}
}
return (
<div className={`rounded-xl shadow-sm border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex items-center justify-between`}>
<div>
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Live-Log</h2>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
{currentJob
? `Aktiver Job: ${currentJob.type.toUpperCase()} (${currentJob.progress}%)`
: 'Kein aktiver Job'}
</p>
</div>
{currentJob && (
<div className="flex items-center gap-2">
<div className={`px-3 py-1 rounded-full text-xs font-medium flex items-center gap-2 ${
currentJob.progress === 100
? theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-700'
: theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-700'
}`}>
{currentJob.progress === 100 ? <CheckCircle size={12} /> : <Play size={12} />}
{currentJob.progress}%
</div>
</div>
)}
</div>
<div className={`max-h-[400px] overflow-y-auto p-4 space-y-2 transition-colors duration-300 font-mono text-xs ${
theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'
}`}>
{logs.length === 0 ? (
<div className="text-center py-8 text-slate-500">
<Terminal size={32} className="mx-auto mb-2 opacity-50" />
<p>Warte auf Log-Einträge...</p>
</div>
) : (
logs.map((log) => (
<div key={log.id} className={`flex items-start gap-2 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
<span className="text-slate-500 whitespace-nowrap">
{new Date(log.timestamp).toLocaleTimeString('de-DE', { hour12: false })}
</span>
<span className={`font-bold ${getStatusColor(log.level)}`}>
[{log.source}]
</span>
<span className="flex-1">{log.message}</span>
<span className={getStatusColor(log.level)}>
{getStatusIcon(log.level)}
</span>
</div>
))
)}
</div>
{/* Quick Stats */}
<div className={`px-6 py-3 border-t transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} grid grid-cols-3 gap-4`}>
{currentJob && (
<>
<div>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Fortschritt</p>
<p className={`text-lg font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
{currentJob.progress}%
</p>
</div>
<div>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Gerät</p>
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
{currentJob.device || 'Unbekannt'}
</p>
</div>
<div>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Typ</p>
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
{currentJob.type?.toUpperCase() || 'Unbekannt'}
</p>
</div>
</>
)}
</div>
</div>
)
}
+138
View File
@@ -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<TargetConfig['type']>('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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm transition-all">
<div className={`w-full max-w-lg rounded-xl shadow-2xl transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
{/* Header */}
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
<h2 className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Rip-Ziel auswählen</h2>
<p className={`text-sm mt-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
Wohin sollen die gerippten Dateien gespeichert werden?
</p>
</div>
{/* Body */}
<div className="p-6 space-y-6">
{/* Type Selection */}
<div>
<label className={`block text-sm font-medium mb-3 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
Dateityp
</label>
<div className="grid grid-cols-3 gap-3">
{['movies', 'series', 'music'].map((type) => (
<button
key={type}
onClick={() => setSelectedType(type as TargetConfig['type'])}
className={`flex flex-col items-center justify-center gap-2 p-4 rounded-lg border transition-all ${
selectedType === type
? theme === 'dark'
? 'bg-indigo-900/30 border-indigo-500 text-indigo-400'
: 'bg-indigo-50 border-indigo-600 text-indigo-700'
: theme === 'dark'
? 'bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-600'
: 'bg-slate-50 border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
<span className="text-2xl">
{type === 'movies' && '🎥'}
{type === 'series' && '📺'}
{type === 'music' && '🎵'}
</span>
<span className="text-sm font-medium capitalize">{type}</span>
</button>
))}
</div>
</div>
{/* Path Display */}
<div>
<label className={`block text-sm font-medium mb-2 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
Zielverzeichnis
</label>
<div className={`flex items-center gap-3 p-4 rounded-lg border ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`}>
<span className="text-xl">📁</span>
<input
type="text"
value={customPath || targets.find(t => 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"
/>
<button
onClick={() => {
const target = targets.find(t => t.type === selectedType)
if (target) {
setCustomPath(target.path)
}
}}
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
>
Standard
</button>
</div>
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-500'}`}>
{customPath
? `Verwende benutzerdefiniertes Verzeichnis: ${customPath}`
: `Verwende Standard-Verzeichnis: ${targets.find(t => t.type === selectedType)?.path}`
}
</p>
</div>
</div>
{/* Footer */}
<div className={`px-6 py-4 border-t transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex justify-end gap-3`}>
<button
onClick={onClose}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${theme === 'dark' ? 'text-slate-400 hover:bg-slate-700' : 'text-slate-600 hover:bg-slate-100'}`}
>
Abbrechen
</button>
<button
onClick={handleSave}
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors shadow-lg shadow-indigo-500/30"
>
Speichern
</button>
</div>
</div>
</div>
)
}
+8
View File
@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react'
import axios from 'axios' import axios from 'axios'
import { Clock, Activity, HardDrive, BarChart, AlertCircle, CheckCircle, Disc, Play, Pause, SkipForward } from 'lucide-react' import { Clock, Activity, HardDrive, BarChart, AlertCircle, CheckCircle, Disc, Play, Pause, SkipForward } from 'lucide-react'
import { useDarkMode } from '../context/ThemeContext' import { useDarkMode } from '../context/ThemeContext'
import DeviceDiscovery from '../components/DeviceDiscovery'
import LiveLogSection from '../components/LiveLogSection'
interface Job { interface Job {
id: string id: string
@@ -182,6 +184,12 @@ export default function Dashboard() {
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Übersicht über alle Ripping-Jobs und Geräte</p> <p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Übersicht über alle Ripping-Jobs und Geräte</p>
</div> </div>
{/* Device Discovery Section */}
<DeviceDiscovery />
{/* Live Log Section */}
<LiveLogSection />
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 transition-colors duration-300"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 transition-colors duration-300">
<StatCard <StatCard
+232
View File
@@ -0,0 +1,232 @@
import { useState, useEffect } from 'react'
import { Terminal, Download, AlertCircle, CheckCircle, Clock, RefreshCw } from 'lucide-react'
import { useDarkMode } from '../context/ThemeContext'
interface LogEntry {
id: string
timestamp: string
level: 'info' | 'warning' | 'error' | 'success'
message: string
source: string
details?: string
}
export default function LogsPage() {
const [logs, setLogs] = useState<LogEntry[]>([])
const [filter, setFilter] = useState<string>('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 <AlertCircle size={16} />
case 'warning': return <AlertCircle size={16} />
case 'success': return <CheckCircle size={16} />
default: return <Terminal size={16} />
}
}
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 (
<div className="space-y-6 transition-colors duration-300">
{/* Header */}
<div className="flex flex-col gap-2 transition-colors duration-300">
<h1 className="text-3xl font-bold dark:text-slate-100 text-slate-900">Logs</h1>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Alle relevanten Log-Informationen des Ripping-Systems</p>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 transition-colors duration-300">
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500">
<Terminal size={18} />
</div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Info</span>
</div>
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.info}</p>
</div>
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
<AlertCircle size={18} />
</div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Warnung</span>
</div>
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.warning}</p>
</div>
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
<CheckCircle size={18} />
</div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Erfolg</span>
</div>
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.success}</p>
</div>
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-rose-500/10 text-rose-500">
<AlertCircle size={18} />
</div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Fehler</span>
</div>
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.error}</p>
</div>
</div>
{/* Filters */}
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className="flex flex-wrap items-center gap-3">
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Filter:</span>
<button
onClick={() => setFilter('all')}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
filter === 'all'
? 'bg-indigo-600 text-white'
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Alle
</button>
<button
onClick={() => setFilter('info')}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
filter === 'info'
? 'bg-blue-600 text-white'
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Info
</button>
<button
onClick={() => setFilter('success')}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
filter === 'success'
? 'bg-emerald-600 text-white'
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Erfolg
</button>
<div className="flex-1" />
<button
onClick={refreshLogs}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
>
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
Neu laden
</button>
</div>
</div>
{/* Logs List */}
<div className={`rounded-lg border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className="max-h-[600px] overflow-y-auto">
{filteredLogs.length === 0 ? (
<div className="text-center py-12 transition-colors duration-300">
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-4 ${theme === 'dark' ? 'bg-slate-700' : 'bg-slate-100'}`}>
<Terminal className={theme === 'dark' ? 'text-slate-400' : 'text-slate-400'} size={32} />
</div>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Keine Logs gefunden</p>
</div>
) : (
<div className="divide-y divide-slate-200 dark:divide-slate-700">
{filteredLogs.map(log => (
<div key={log.id} className={`p-4 transition-colors duration-300 hover:${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
<div className="flex items-start gap-3">
<div className={`mt-1 p-1.5 rounded ${getLevelColor(log.level)}`}>
{getLevelIcon(log.level)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className={`text-xs font-mono px-1.5 py-0.5 rounded ${theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'}`}>
{log.source}
</span>
<span className={`text-xs font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
{log.timestamp}
</span>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${getLevelColor(log.level)}`}>
{getLevelLabel(log.level)}
</span>
</div>
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
{log.message}
</p>
{log.details && (
<p className={`text-xs mt-1 font-mono ${theme === 'dark' ? 'text-slate-500' : 'text-slate-500'}`}>
{log.details}
</p>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}