Add logging system: app.log + matching.log with admin viewer
- Backend: RotatingFileHandler for app.log (all) and matching.log (matcher/matching services)
- New GET/DELETE /api/logs/{app|matching} endpoints (admin-only)
- matcher.py: improved cover-download logging (URL, bytes, HTTP errors, missing cover URL)
- Frontend: Logs tab in Admin panel with log switcher, line count selector, color-coded ERROR/WARNING lines, clear button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
11
frontend/src/api/logs.ts
Normal file
11
frontend/src/api/logs.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import api from './client'
|
||||
|
||||
export async function getLog(name: 'app' | 'matching', lines = 300) {
|
||||
const res = await api.get(`/api/logs/${name}`, { params: { lines } })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function clearLog(name: 'app' | 'matching') {
|
||||
const res = await api.delete(`/api/logs/${name}`)
|
||||
return res.data
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Users, Library, Settings, Trash2, Plus, RefreshCw, Loader2, Check, X, FolderOpen, Pencil, Sparkles } from 'lucide-react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Users, Library, Settings, Trash2, Plus, RefreshCw, Loader2, Check, X, FolderOpen, Pencil, Sparkles, ScrollText } from 'lucide-react'
|
||||
import { getUsers, createUser, deleteUser, getSettings, updateSettings } from '../api/me'
|
||||
import { getLibraries, scanLibrary, matchAllLibrary, createLibrary, updateLibrary, deleteLibrary } from '../api/libraries'
|
||||
import { getLog, clearLog } from '../api/logs'
|
||||
import FileBrowser from '../components/common/FileBrowser'
|
||||
|
||||
type Tab = 'users' | 'libraries' | 'settings'
|
||||
type Tab = 'users' | 'libraries' | 'settings' | 'logs'
|
||||
|
||||
const TAB_LABELS: Record<Tab, string> = {
|
||||
users: 'Benutzer',
|
||||
libraries: 'Bibliotheken',
|
||||
settings: 'Einstellungen',
|
||||
logs: 'Logs',
|
||||
}
|
||||
|
||||
export default function Admin() {
|
||||
const [tab, setTab] = useState<Tab>('users')
|
||||
@@ -13,7 +21,7 @@ export default function Admin() {
|
||||
<div style={{ padding: '32px 32px 24px' }}>
|
||||
<h1 className="text-ink font-semibold mb-6" style={{ fontSize: '22px', letterSpacing: '-0.5px' }}>Administration</h1>
|
||||
<div className="flex gap-1 mb-6 bg-surface rounded-xl p-1 w-fit border border-divider">
|
||||
{(['users', 'libraries', 'settings'] as Tab[]).map((t) => (
|
||||
{(['users', 'libraries', 'settings', 'logs'] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
@@ -22,7 +30,7 @@ export default function Admin() {
|
||||
}`}
|
||||
style={{ fontSize: '13px' }}
|
||||
>
|
||||
{t === 'users' ? 'Benutzer' : t === 'libraries' ? 'Bibliotheken' : 'Einstellungen'}
|
||||
{TAB_LABELS[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -30,6 +38,7 @@ export default function Admin() {
|
||||
{tab === 'users' && <UsersPanel />}
|
||||
{tab === 'libraries' && <LibrariesPanel />}
|
||||
{tab === 'settings' && <SettingsPanel />}
|
||||
{tab === 'logs' && <LogsPanel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -386,3 +395,127 @@ function SettingsPanel() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type LogName = 'app' | 'matching'
|
||||
|
||||
function LogsPanel() {
|
||||
const [logName, setLogName] = useState<LogName>('app')
|
||||
const [lines, setLines] = useState(300)
|
||||
const [data, setData] = useState<{ lines: string[]; total: number; showing: number; exists: boolean } | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const d = await getLog(logName, lines)
|
||||
setData(d)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [logName, lines])
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'auto' })
|
||||
}, [data])
|
||||
|
||||
const handleClear = async () => {
|
||||
if (!confirm(`Log „${logName}" wirklich leeren?`)) return
|
||||
setClearing(true)
|
||||
await clearLog(logName).catch(() => {})
|
||||
setClearing(false)
|
||||
await load()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||
<div className="flex gap-1 bg-surface rounded-lg p-1 border border-divider">
|
||||
{(['app', 'matching'] as LogName[]).map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setLogName(n)}
|
||||
className={`px-3 py-1.5 rounded-md transition-colors ${logName === n ? 'bg-card text-ink font-medium' : 'text-muted hover:text-ink'}`}
|
||||
style={{ fontSize: '12px' }}
|
||||
>
|
||||
{n === 'app' ? 'app.log' : 'matching.log'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={lines}
|
||||
onChange={(e) => setLines(Number(e.target.value))}
|
||||
className="bg-card border border-divider rounded-lg px-2 py-1.5 text-sm text-muted focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{[100, 300, 500, 1000, 2000].map((n) => (
|
||||
<option key={n} value={n}>{n} Zeilen</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 bg-card border border-divider px-3 py-1.5 rounded-lg text-muted hover:text-ink transition-colors disabled:opacity-50"
|
||||
style={{ fontSize: '12px' }}
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} /> Aktualisieren
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClear}
|
||||
disabled={clearing}
|
||||
className="flex items-center gap-1.5 bg-card border border-divider px-3 py-1.5 rounded-lg text-muted hover:text-red-400 transition-colors disabled:opacity-50 ml-auto"
|
||||
style={{ fontSize: '12px' }}
|
||||
>
|
||||
<Trash2 size={12} /> Leeren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<p className="text-muted mb-2" style={{ fontSize: '11px' }}>
|
||||
{data.exists
|
||||
? `Zeige ${data.showing} von ${data.total} Zeilen`
|
||||
: 'Log-Datei existiert noch nicht'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="bg-surface border border-divider rounded-xl overflow-auto"
|
||||
style={{ height: 'calc(100vh - 280px)', minHeight: '200px' }}
|
||||
>
|
||||
{loading && !data ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="text-primary animate-spin" size={24} />
|
||||
</div>
|
||||
) : !data?.exists || data.lines.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-muted" style={{ fontSize: '13px' }}>Keine Einträge</p>
|
||||
</div>
|
||||
) : (
|
||||
<pre
|
||||
className="text-muted p-4"
|
||||
style={{ fontSize: '11px', lineHeight: '1.6', whiteSpace: 'pre-wrap', wordBreak: 'break-all', fontFamily: 'monospace' }}
|
||||
>
|
||||
{data.lines.map((line, i) => {
|
||||
const isError = /\bERROR\b/.test(line)
|
||||
const isWarn = /\bWARNING\b/.test(line)
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{ color: isError ? '#f87171' : isWarn ? '#fbbf24' : undefined }}
|
||||
>
|
||||
{line}{'\n'}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user