- 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:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user