feat(admin): Admin-Panel Phase 2 — Wörterbuch-CRUD, Live-Log, DB-Export, Metriken-Balken

- Aussprache-Lexikon (de/en) komplett im Browser editierbar: Einträge
  hinzufügen/löschen in Sektionen Abkürzungen/Einheiten/Begriffe;
  LRU-Cache wird nach jedem Schreibvorgang automatisch geleert.
- Live-Log-Tab: WebSocket auf /api/admin/log streamt journalctl
  des voice-assistant.service live im Terminal-Style ins Admin-Panel.
- DB-Export: SQLite-Datenbank über /api/admin/db-export herunterladen.
- Metriken-Tab: CSS-Balkendiagramm (Anfragen je Nutzer) ergänzt.
- Dokumentation: §7.5 Admin-Web-Panel, B.5 API-Endpunkte, Sachregister.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-19 01:15:28 +02:00
commit 366d71ba26
4 changed files with 424 additions and 11 deletions

View file

@ -1,4 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException, Request
import asyncio
from pathlib import Path
import yaml
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from pydantic import BaseModel
from app.auth import is_admin_user, require_admin, require_admin_or_user
from app.config import settings
@ -7,6 +13,16 @@ from app.schemas import MemoryCreate, MemoryOut, UserCreate, UserCreated, UserUp
router = APIRouter()
_CONFIG_DIR = Path(__file__).resolve().parents[2] / "config"
_ALLOWED_SECTIONS = {"abbreviations", "units", "terms"}
_ALLOWED_LANGS = {"de", "en"}
class PronunciationEntry(BaseModel):
section: str
key: str
value: str
@router.get("/admin/request-headers")
async def request_headers(request: Request, key: str | None = None):
@ -152,3 +168,118 @@ async def get_user_usage(user_id: str):
async def get_all_usage():
"""Aggregierte Nutzungsstatistik aller Nutzer."""
return get_store().get_all_usage()
# ── Datenbank-Export ────────────────────────────────────────────────────────
@router.get("/admin/db-export", dependencies=[Depends(require_admin)])
async def export_db():
"""Laed die SQLite-Datenbank als Datei herunter (Backup)."""
path = Path(settings.db_path)
if not path.exists():
raise HTTPException(status_code=404, detail="Datenbank nicht gefunden.")
return FileResponse(
path,
media_type="application/octet-stream",
filename="voice-assistant.db",
headers={"Content-Disposition": 'attachment; filename="voice-assistant.db"'},
)
# ── Aussprache-Lexikon CRUD ─────────────────────────────────────────────────
def _read_pronunciation(lang: str) -> dict:
path = _CONFIG_DIR / f"pronunciation.{lang}.yaml"
if not path.exists():
return {"abbreviations": {}, "units": {}, "terms": {}}
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
return {
"abbreviations": dict(data.get("abbreviations") or {}),
"units": dict(data.get("units") or {}),
"terms": dict(data.get("terms") or {}),
}
def _write_pronunciation(lang: str, data: dict) -> None:
path = _CONFIG_DIR / f"pronunciation.{lang}.yaml"
path.write_text(
yaml.dump(data, allow_unicode=True, default_flow_style=False, sort_keys=False),
encoding="utf-8",
)
# LRU-Cache des Normalizers invalidieren, damit die Aenderung sofort greift.
from app.pipeline.tts_normalizer import _load_lexicon
_load_lexicon.cache_clear()
@router.get("/admin/pronunciation/{lang}", dependencies=[Depends(require_admin)])
async def get_pronunciation(lang: str):
"""Gibt alle Eintraege des Aussprache-Lexikons zurueck."""
if lang not in _ALLOWED_LANGS:
raise HTTPException(status_code=400, detail=f"Sprache muss eine von {_ALLOWED_LANGS} sein.")
return _read_pronunciation(lang)
@router.post("/admin/pronunciation/{lang}", dependencies=[Depends(require_admin)])
async def add_pronunciation(lang: str, entry: PronunciationEntry):
"""Fuegt einen Eintrag zum Aussprache-Lexikon hinzu oder ueberschreibt ihn."""
if lang not in _ALLOWED_LANGS:
raise HTTPException(status_code=400, detail=f"Sprache muss eine von {_ALLOWED_LANGS} sein.")
if entry.section not in _ALLOWED_SECTIONS:
raise HTTPException(status_code=400, detail=f"Section muss eine von {_ALLOWED_SECTIONS} sein.")
if not entry.key.strip() or not entry.value.strip():
raise HTTPException(status_code=422, detail="key und value duerfen nicht leer sein.")
data = _read_pronunciation(lang)
data[entry.section][entry.key.strip()] = entry.value.strip()
_write_pronunciation(lang, data)
return {"section": entry.section, "key": entry.key.strip(), "value": entry.value.strip()}
@router.delete("/admin/pronunciation/{lang}/{section}/{key:path}", dependencies=[Depends(require_admin)])
async def delete_pronunciation(lang: str, section: str, key: str):
"""Loescht einen Eintrag aus dem Aussprache-Lexikon."""
if lang not in _ALLOWED_LANGS:
raise HTTPException(status_code=400, detail="Unbekannte Sprache.")
if section not in _ALLOWED_SECTIONS:
raise HTTPException(status_code=400, detail="Unbekannte Section.")
data = _read_pronunciation(lang)
if key not in data[section]:
raise HTTPException(status_code=404, detail=f"Eintrag '{key}' nicht gefunden.")
del data[section][key]
_write_pronunciation(lang, data)
return {"deleted": key}
# ── Live-Log (journalctl → WebSocket) ──────────────────────────────────────
@router.websocket("/admin/log")
async def admin_log_ws(websocket: WebSocket, key: str | None = None):
"""Streamt den systemd-Journal-Log des Voice-Assistant-Service live."""
# Auth: Admin-Key als Query-Param ODER SSO-Identitaet via Cookie/Header.
from app.auth import authenticate, _bearer_token
client_host = websocket.client.host if websocket.client else ""
token = _bearer_token(websocket.headers.get("authorization")) or key
user = authenticate(websocket.headers, client_host, token)
if not is_admin_user(user):
await websocket.close(code=1008)
return
await websocket.accept()
proc = await asyncio.create_subprocess_exec(
"journalctl", "--user", "-f", "-u", "voice-assistant.service",
"-n", "100", "--no-pager", "-o", "short",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
try:
while True:
line = await proc.stdout.readline()
if not line:
break
await websocket.send_text(line.decode("utf-8", "replace").rstrip())
except (WebSocketDisconnect, Exception):
pass
finally:
try:
proc.terminate()
except Exception:
pass