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:
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import logging.handlers
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
@@ -11,14 +12,45 @@ from .config import get_settings
|
|||||||
from .services.file_watcher import start_file_watcher, stop_file_watcher
|
from .services.file_watcher import start_file_watcher, stop_file_watcher
|
||||||
from .services.podcast_feed import update_all_feeds
|
from .services.podcast_feed import update_all_feeds
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
||||||
|
def _setup_logging():
|
||||||
|
settings = get_settings()
|
||||||
|
os.makedirs(settings.log_dir, exist_ok=True)
|
||||||
|
|
||||||
|
fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||||
|
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
# Console (bestehend)
|
||||||
|
console = logging.StreamHandler()
|
||||||
|
console.setFormatter(fmt)
|
||||||
|
root.addHandler(console)
|
||||||
|
|
||||||
|
# app.log — alle Logs
|
||||||
|
app_fh = logging.handlers.RotatingFileHandler(
|
||||||
|
os.path.join(settings.log_dir, "app.log"),
|
||||||
|
maxBytes=5_000_000, backupCount=2, encoding="utf-8"
|
||||||
|
)
|
||||||
|
app_fh.setFormatter(fmt)
|
||||||
|
root.addHandler(app_fh)
|
||||||
|
|
||||||
|
# matching.log — nur Matching-bezogene Logger
|
||||||
|
match_fh = logging.handlers.RotatingFileHandler(
|
||||||
|
os.path.join(settings.log_dir, "matching.log"),
|
||||||
|
maxBytes=5_000_000, backupCount=2, encoding="utf-8"
|
||||||
|
)
|
||||||
|
match_fh.setFormatter(fmt)
|
||||||
|
for name in ("app.services.matcher", "app.services.matching"):
|
||||||
|
logging.getLogger(name).addHandler(match_fh)
|
||||||
|
|
||||||
|
|
||||||
|
_setup_logging()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_scheduler = AsyncIOScheduler()
|
_scheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -28,7 +60,6 @@ async def lifespan(app: FastAPI):
|
|||||||
await init_db()
|
await init_db()
|
||||||
await start_file_watcher()
|
await start_file_watcher()
|
||||||
|
|
||||||
# Podcast-Feed-Scheduler
|
|
||||||
_scheduler.add_job(update_all_feeds, "interval", hours=settings.podcast_update_interval_hours, id="feed_update")
|
_scheduler.add_job(update_all_feeds, "interval", hours=settings.podcast_update_interval_hours, id="feed_update")
|
||||||
_scheduler.start()
|
_scheduler.start()
|
||||||
|
|
||||||
@@ -55,7 +86,7 @@ if os.path.exists(settings.covers_dir):
|
|||||||
app.mount("/covers", StaticFiles(directory=settings.covers_dir), name="covers")
|
app.mount("/covers", StaticFiles(directory=settings.covers_dir), name="covers")
|
||||||
|
|
||||||
from .routers import auth, libraries, items, stream, me, users, settings as settings_router
|
from .routers import auth, libraries, items, stream, me, users, settings as settings_router
|
||||||
from .routers import matching, podcasts, setup, filebrowser
|
from .routers import matching, podcasts, setup, filebrowser, logs
|
||||||
|
|
||||||
app.include_router(setup.router)
|
app.include_router(setup.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
@@ -68,3 +99,4 @@ app.include_router(settings_router.router)
|
|||||||
app.include_router(matching.router)
|
app.include_router(matching.router)
|
||||||
app.include_router(podcasts.router)
|
app.include_router(podcasts.router)
|
||||||
app.include_router(filebrowser.router)
|
app.include_router(filebrowser.router)
|
||||||
|
app.include_router(logs.router)
|
||||||
|
|||||||
57
backend/app/routers/logs.py
Normal file
57
backend/app/routers/logs.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import os
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from ..dependencies import require_admin
|
||||||
|
from ..models.user import User
|
||||||
|
from ..config import get_settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
||||||
|
|
||||||
|
_LOG_FILES = {
|
||||||
|
"app": "app.log",
|
||||||
|
"matching": "matching.log",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{log_name}")
|
||||||
|
async def get_log(
|
||||||
|
log_name: str,
|
||||||
|
lines: int = Query(300, ge=10, le=5000),
|
||||||
|
_admin: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
if log_name not in _LOG_FILES:
|
||||||
|
raise HTTPException(status_code=404, detail="Unbekannte Log-Datei")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
path = os.path.join(settings.log_dir, _LOG_FILES[log_name])
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return {"lines": [], "total": 0, "showing": 0, "exists": False, "path": path}
|
||||||
|
|
||||||
|
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
all_lines = f.readlines()
|
||||||
|
|
||||||
|
last = all_lines[-lines:]
|
||||||
|
return {
|
||||||
|
"lines": [l.rstrip("\n") for l in last],
|
||||||
|
"total": len(all_lines),
|
||||||
|
"showing": len(last),
|
||||||
|
"exists": True,
|
||||||
|
"path": path,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{log_name}")
|
||||||
|
async def clear_log(
|
||||||
|
log_name: str,
|
||||||
|
_admin: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
if log_name not in _LOG_FILES:
|
||||||
|
raise HTTPException(status_code=404, detail="Unbekannte Log-Datei")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
path = os.path.join(settings.log_dir, _LOG_FILES[log_name])
|
||||||
|
|
||||||
|
if os.path.exists(path):
|
||||||
|
open(path, "w").close()
|
||||||
|
|
||||||
|
return {"success": True}
|
||||||
@@ -85,6 +85,7 @@ async def _download_cover(url: str, item_id: str) -> str | None:
|
|||||||
if ".png" in url:
|
if ".png" in url:
|
||||||
ext = ".png"
|
ext = ".png"
|
||||||
dest = os.path.join(settings.covers_dir, f"{item_id}{ext}")
|
dest = os.path.join(settings.covers_dir, f"{item_id}{ext}")
|
||||||
|
logger.info(f"Cover-Download: {url}")
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
|
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
|
||||||
r = await client.get(url)
|
r = await client.get(url)
|
||||||
@@ -92,9 +93,12 @@ async def _download_cover(url: str, item_id: str) -> str | None:
|
|||||||
os.makedirs(settings.covers_dir, exist_ok=True)
|
os.makedirs(settings.covers_dir, exist_ok=True)
|
||||||
with open(dest, "wb") as f:
|
with open(dest, "wb") as f:
|
||||||
f.write(r.content)
|
f.write(r.content)
|
||||||
|
logger.info(f"Cover gespeichert: {dest} ({len(r.content)} Bytes)")
|
||||||
return dest
|
return dest
|
||||||
|
else:
|
||||||
|
logger.warning(f"Cover-Download HTTP {r.status_code}: {url}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Cover-Download fehlgeschlagen ({url}): {e}")
|
logger.warning(f"Cover-Download Fehler ({url}): {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -133,6 +137,8 @@ async def _apply_match(db: AsyncSession, item: LibraryItem, result: MatchResult,
|
|||||||
cover_path = await _download_cover(result.cover_url, item.id)
|
cover_path = await _download_cover(result.cover_url, item.id)
|
||||||
if cover_path:
|
if cover_path:
|
||||||
item.cover_path = cover_path
|
item.cover_path = cover_path
|
||||||
|
elif not result.cover_url:
|
||||||
|
logger.info(f"Kein Cover-URL in Match-Ergebnis ({result.source}: {result.source_id})")
|
||||||
|
|
||||||
# Kapitel aus MusicBrainz-Tracklisting
|
# Kapitel aus MusicBrainz-Tracklisting
|
||||||
if result.chapters:
|
if result.chapters:
|
||||||
|
|||||||
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 React, { useEffect, useRef, useState } from 'react'
|
||||||
import { Users, Library, Settings, Trash2, Plus, RefreshCw, Loader2, Check, X, FolderOpen, Pencil, Sparkles } from 'lucide-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 { getUsers, createUser, deleteUser, getSettings, updateSettings } from '../api/me'
|
||||||
import { getLibraries, scanLibrary, matchAllLibrary, createLibrary, updateLibrary, deleteLibrary } from '../api/libraries'
|
import { getLibraries, scanLibrary, matchAllLibrary, createLibrary, updateLibrary, deleteLibrary } from '../api/libraries'
|
||||||
|
import { getLog, clearLog } from '../api/logs'
|
||||||
import FileBrowser from '../components/common/FileBrowser'
|
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() {
|
export default function Admin() {
|
||||||
const [tab, setTab] = useState<Tab>('users')
|
const [tab, setTab] = useState<Tab>('users')
|
||||||
@@ -13,7 +21,7 @@ export default function Admin() {
|
|||||||
<div style={{ padding: '32px 32px 24px' }}>
|
<div style={{ padding: '32px 32px 24px' }}>
|
||||||
<h1 className="text-ink font-semibold mb-6" style={{ fontSize: '22px', letterSpacing: '-0.5px' }}>Administration</h1>
|
<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">
|
<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
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setTab(t)}
|
onClick={() => setTab(t)}
|
||||||
@@ -22,7 +30,7 @@ export default function Admin() {
|
|||||||
}`}
|
}`}
|
||||||
style={{ fontSize: '13px' }}
|
style={{ fontSize: '13px' }}
|
||||||
>
|
>
|
||||||
{t === 'users' ? 'Benutzer' : t === 'libraries' ? 'Bibliotheken' : 'Einstellungen'}
|
{TAB_LABELS[t]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -30,6 +38,7 @@ export default function Admin() {
|
|||||||
{tab === 'users' && <UsersPanel />}
|
{tab === 'users' && <UsersPanel />}
|
||||||
{tab === 'libraries' && <LibrariesPanel />}
|
{tab === 'libraries' && <LibrariesPanel />}
|
||||||
{tab === 'settings' && <SettingsPanel />}
|
{tab === 'settings' && <SettingsPanel />}
|
||||||
|
{tab === 'logs' && <LogsPanel />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -386,3 +395,127 @@ function SettingsPanel() {
|
|||||||
</div>
|
</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