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:
Audiolib
2026-05-26 17:27:54 +02:00
parent a9a9a35efb
commit 3aab0ac9f1
5 changed files with 250 additions and 11 deletions

11
frontend/src/api/logs.ts Normal file
View 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
}

View File

@@ -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>
)
}