Add live logs to admin dashboard with level filtering and error middleware

This commit is contained in:
root
2026-03-31 15:54:21 -05:00
parent 40322e8861
commit a0d9f1f9d9
4 changed files with 153 additions and 6 deletions

View File

@@ -1,6 +1,8 @@
import logging
import io
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -15,6 +17,39 @@ router = APIRouter(prefix="/admin", tags=["admin"])
ADMIN_EMAILS = ["chris.ryan@deepcutsai.com"] ADMIN_EMAILS = ["chris.ryan@deepcutsai.com"]
# In-memory log buffer for admin viewing
LOG_BUFFER_SIZE = 500
_log_buffer: list[dict] = []
class AdminLogHandler(logging.Handler):
"""Captures log records into an in-memory buffer for the admin UI."""
def emit(self, record):
entry = {
"timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": self.format(record),
}
_log_buffer.append(entry)
if len(_log_buffer) > LOG_BUFFER_SIZE:
_log_buffer.pop(0)
# Attach handler to root logger and uvicorn
_handler = AdminLogHandler()
_handler.setLevel(logging.INFO)
_handler.setFormatter(logging.Formatter("%(message)s"))
logging.getLogger().addHandler(_handler)
logging.getLogger("uvicorn.access").addHandler(_handler)
logging.getLogger("uvicorn.error").addHandler(_handler)
logging.getLogger("app").addHandler(_handler)
# Create app logger for explicit logging
app_logger = logging.getLogger("app")
app_logger.setLevel(logging.INFO)
@router.get("/stats") @router.get("/stats")
async def get_stats( async def get_stats(
@@ -98,3 +133,19 @@ async def get_stats(
}, },
"user_breakdown": user_breakdown, "user_breakdown": user_breakdown,
} }
@router.get("/logs")
async def get_logs(
user: User = Depends(get_current_user),
level: str = Query("ALL", description="Filter by level: ALL, ERROR, WARNING, INFO"),
limit: int = Query(100, description="Number of log entries"),
):
if user.email not in ADMIN_EMAILS:
raise HTTPException(status_code=403, detail="Admin only")
logs = _log_buffer[-limit:]
if level != "ALL":
logs = [l for l in logs if l["level"] == level.upper()]
return {"logs": list(reversed(logs)), "total": len(_log_buffer)}

View File

@@ -1,5 +1,9 @@
from fastapi import FastAPI import logging
import traceback
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.core.config import settings from app.core.config import settings
from app.api.endpoints import admin, auth, bandcamp, billing, lastfm, manual_import, playlist_fix, playlists, profile, recommendations, youtube_music from app.api.endpoints import admin, auth, bandcamp, billing, lastfm, manual_import, playlist_fix, playlists, profile, recommendations, youtube_music
@@ -27,6 +31,21 @@ app.include_router(bandcamp.router, prefix="/api")
app.include_router(profile.router, prefix="/api") app.include_router(profile.router, prefix="/api")
logger = logging.getLogger("app")
@app.middleware("http")
async def log_errors(request: Request, call_next):
try:
response = await call_next(request)
if response.status_code >= 400:
logger.warning(f"{request.method} {request.url.path} -> {response.status_code}")
return response
except Exception as e:
logger.error(f"{request.method} {request.url.path} -> 500: {e}\n{traceback.format_exc()}")
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@app.get("/api/health") @app.get("/api/health")
async def health(): async def health():
return {"status": "ok", "app": "vynl"} return {"status": "ok", "app": "vynl"}

View File

@@ -310,4 +310,14 @@ export interface AdminStats {
export const getAdminStats = () => export const getAdminStats = () =>
api.get<AdminStats>('/admin/stats').then((r) => r.data) api.get<AdminStats>('/admin/stats').then((r) => r.data)
export interface LogEntry {
timestamp: string
level: string
logger: string
message: string
}
export const getAdminLogs = (level: string = 'ALL', limit: number = 100) =>
api.get<{ logs: LogEntry[]; total: number }>('/admin/logs', { params: { level, limit } }).then((r) => r.data)
export default api export default api

View File

@@ -1,13 +1,16 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Shield, Users, ListMusic, Sparkles, Heart, ThumbsDown } from 'lucide-react' import { Shield, Users, ListMusic, Sparkles, Heart, ThumbsDown, ScrollText, RefreshCw } from 'lucide-react'
import { useAuth } from '../lib/auth' import { useAuth } from '../lib/auth'
import { getAdminStats, type AdminStats } from '../lib/api' import { getAdminStats, getAdminLogs, type AdminStats, type LogEntry } from '../lib/api'
const ADMIN_EMAIL = 'chris.ryan@deepcutsai.com' const ADMIN_EMAIL = 'chris.ryan@deepcutsai.com'
export default function Admin() { export default function Admin() {
const { user } = useAuth() const { user } = useAuth()
const [stats, setStats] = useState<AdminStats | null>(null) const [stats, setStats] = useState<AdminStats | null>(null)
const [logs, setLogs] = useState<LogEntry[]>([])
const [logLevel, setLogLevel] = useState('ALL')
const [logTotal, setLogTotal] = useState(0)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -20,14 +23,20 @@ export default function Admin() {
return return
} }
getAdminStats() Promise.all([
.then(setStats) getAdminStats().then(setStats),
getAdminLogs('ALL', 200).then((d) => { setLogs(d.logs); setLogTotal(d.total) }),
])
.catch((err) => { .catch((err) => {
setError(err.response?.status === 403 ? 'Access denied. Admin privileges required.' : 'Failed to load admin stats.') setError(err.response?.status === 403 ? 'Access denied. Admin privileges required.' : 'Failed to load admin stats.')
}) })
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [isAdmin]) }, [isAdmin])
const refreshLogs = () => {
getAdminLogs(logLevel, 200).then((d) => { setLogs(d.logs); setLogTotal(d.total) })
}
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-32"> <div className="flex items-center justify-center py-32">
@@ -181,6 +190,64 @@ export default function Admin() {
</table> </table>
</div> </div>
</div> </div>
{/* Logs */}
<div className="bg-white rounded-2xl border border-purple-100 overflow-hidden mt-8">
<div className="p-6 border-b border-purple-100 flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<ScrollText className="w-5 h-5 text-purple" />
<h3 className="text-lg font-bold text-charcoal">Logs</h3>
<span className="text-xs text-charcoal-muted">({logTotal} total)</span>
</div>
<div className="flex items-center gap-2">
{['ALL', 'ERROR', 'WARNING', 'INFO'].map((lvl) => (
<button
key={lvl}
onClick={() => { setLogLevel(lvl); getAdminLogs(lvl, 200).then((d) => { setLogs(d.logs); setLogTotal(d.total) }) }}
className={`px-3 py-1 rounded-full text-xs font-medium cursor-pointer border transition-colors ${
logLevel === lvl
? 'bg-purple text-white border-purple'
: 'bg-white text-charcoal-muted border-purple-100 hover:border-purple/30'
}`}
>
{lvl}
</button>
))}
<button
onClick={refreshLogs}
className="p-1.5 rounded-lg hover:bg-purple-50 cursor-pointer border-none bg-transparent transition-colors"
title="Refresh logs"
>
<RefreshCw className="w-4 h-4 text-charcoal-muted" />
</button>
</div>
</div>
<div className="max-h-96 overflow-y-auto font-mono text-xs">
{logs.length === 0 ? (
<p className="p-6 text-charcoal-muted text-center text-sm">No logs found</p>
) : (
logs.map((log, i) => (
<div
key={i}
className={`px-4 py-2 border-b border-purple-50/50 flex gap-3 ${
log.level === 'ERROR' ? 'bg-red-50/50' : log.level === 'WARNING' ? 'bg-amber-50/50' : ''
}`}
>
<span className="text-charcoal-muted whitespace-nowrap flex-shrink-0">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span
className={`font-semibold flex-shrink-0 w-14 ${
log.level === 'ERROR' ? 'text-red-600' : log.level === 'WARNING' ? 'text-amber-600' : 'text-charcoal-muted'
}`}
>
{log.level}
</span>
<span className="text-charcoal break-all">{log.message}</span>
</div>
))
)}
</div>
</div>
</div> </div>
) )
} }