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

@ -44,7 +44,7 @@ funktioniert trotzdem, die Ausgabe ist dann unformatiert.
6. [Einstellungen und Konfiguration](#6-einstellungen-und-konfiguration) 6. [Einstellungen und Konfiguration](#6-einstellungen-und-konfiguration)
**Administration** **Administration**
7. [Nutzerverwaltung und Authentifizierung](#7-nutzerverwaltung-und-authentifizierung) 7. [Nutzerverwaltung und Authentifizierung](#7-nutzerverwaltung-und-authentifizierung) · [7.5 Admin-Web-Panel](#75-admin-web-panel)
8. [Gedächtnis und Erinnerungen](#8-gedächtnis-und-erinnerungen) 8. [Gedächtnis und Erinnerungen](#8-gedächtnis-und-erinnerungen)
9. [Resilienz, Fallbacks und Metriken](#9-resilienz-fallbacks-und-metriken) 9. [Resilienz, Fallbacks und Metriken](#9-resilienz-fallbacks-und-metriken)
10. [Notfall-Erkennung und Eskalation](#10-notfall-erkennung-und-eskalation) 10. [Notfall-Erkennung und Eskalation](#10-notfall-erkennung-und-eskalation)
@ -487,6 +487,7 @@ https://va.beispiel.de/ ← remote über Reverse-Proxy (alles, inkl. Mikr
| **🎤 Mikrofon-Button** | **Idle (grün 🎤):** Tippen → Aufnahme startet · **Aufnahme (rot pulsierend 🎤):** Tippen → Aufnahme stoppt und wird gesendet · **KI antwortet (amber ⏹):** Tippen → Antwort sofort unterbrechen (Barge-in) | | **🎤 Mikrofon-Button** | **Idle (grün 🎤):** Tippen → Aufnahme startet · **Aufnahme (rot pulsierend 🎤):** Tippen → Aufnahme stoppt und wird gesendet · **KI antwortet (amber ⏹):** Tippen → Antwort sofort unterbrechen (Barge-in) |
| **Stimme ▾** | TTS-Provider wählen: leer = Server-Default, `chatterbox` = neuronale Stimme, `openrouter` = Cloud-TTS | | **Stimme ▾** | TTS-Provider wählen: leer = Server-Default, `chatterbox` = neuronale Stimme, `openrouter` = Cloud-TTS |
| **☀️ / 🌙** | Tag-/Nacht-Modus; folgt sonst automatisch dem Betriebssystem | | **☀️ / 🌙** | Tag-/Nacht-Modus; folgt sonst automatisch dem Betriebssystem |
| **⚙️** (Admin) | Öffnet das Admin-Panel — nur für Admin-Nutzer sichtbar (→ § 7.5) |
| **Angemeldet als …** | SSO-Identität; „Gast" wenn AUTH deaktiviert oder kein SSO-Cookie | | **Angemeldet als …** | SSO-Identität; „Gast" wenn AUTH deaktiviert oder kein SSO-Cookie |
#### 5.1.3 Typischer Ablauf — Textchat #### 5.1.3 Typischer Ablauf — Textchat
@ -833,13 +834,17 @@ Vor dem TTS läuft ein Normalizer, der Ausspracheprobleme des Phonemizers behebt
Stärke: `TTS_NORMALIZE_LEVEL=auto|full|light|off` Stärke: `TTS_NORMALIZE_LEVEL=auto|full|light|off`
`auto` = piper bekommt `full`, Cloud-TTS bekommt `light` (Cloud kann Zahlen selbst). `auto` = piper bekommt `full`, Cloud-TTS bekommt `light` (Cloud kann Zahlen selbst).
Eigene Aussprache hinzufügen: Eigene Aussprache hinzufügen — **zwei Wege:**
**Web-UI (empfohlen):** Admin-Panel → Tab „🔤 Wörterbuch" (→ § 7.5). Kein Neustart nötig.
**Kommandozeile:**
```bash ```bash
python scripts/add_pronunciation.py "strömt:ströhmt" # Wort:Aussprache python scripts/add_pronunciation.py "strömt:ströhmt" # Wort:Aussprache
python scripts/add_pronunciation.py Mond Mohnd --verify # mit Phonem-Check python scripts/add_pronunciation.py Mond Mohnd --verify # mit Phonem-Check
python scripts/add_pronunciation.py kWh "Kilowattstunden" --section units python scripts/add_pronunciation.py kWh "Kilowattstunden" --section units
``` ```
Danach Server neu starten. Danach Server neu starten (damit der Cache geleert wird).
--- ---
@ -1185,6 +1190,57 @@ TRUSTED_AUTH_HEADER=X-Remote-User
Vollständige Anleitung: [deploy/README.md](deploy/README.md). Vollständige Anleitung: [deploy/README.md](deploy/README.md).
### 7.5 Admin-Web-Panel
> 🔧 Admin — erreichbar über den **⚙️-Button** im Web-Interface (nur für Admin-Nutzer sichtbar)
Das Admin-Panel öffnet sich als Vollbild-Overlay über dem Chat. Es enthält sieben Tabs:
#### Nutzer
Nutzer anlegen (Name eingeben → „Anlegen" → Token erscheint **einmalig** — sofort kopieren!),
umbenennen, Token zurücksetzen und löschen. Erinnerungen je Nutzer auf- und zuklappen,
neue Erinnerungen hinzufügen oder vorhandene löschen.
#### Gespräche
Nutzerliste links → Session auswählen → Gesprächs-Transkript als Chat-Bubbles ansehen.
#### Notfälle
Tabellarische Übersicht aller protokollierten Notfall-Ereignisse (Zeitpunkt, Nutzer,
Kategorie, Textausschnitt).
#### Status
Zeigt aktives Profil, Provider-Konfiguration, Laufzeit-Metriken und verfügbare Provider.
Am Ende: **⬇ voice-assistant.db herunterladen** — lädt die SQLite-Datenbank als Backup.
#### Metriken
Nutzungsstatistik je Nutzer (Anfragen, Einheiten, letzte Aktivität) als Tabelle
und CSS-Balkendiagramm.
#### Wörterbuch
Aussprache-Lexikon direkt im Browser bearbeiten — kein Kommandozeilen-Skript nötig:
1. Sprache wählen (Deutsch / Englisch).
2. Sektion wählen: **Abkürzungen**, **Einheiten**, **Begriffe / Aussprache**.
3. Vorhandene Einträge: Maus drüber → **✕** erscheint → löschen.
4. Neuer Eintrag: Schlüssel + Ersetzung eingeben → **+ Hinzufügen**.
Die Änderung greift sofort (Server-Cache wird automatisch geleert).
#### Log
Zeigt den systemd-Journal-Log des `voice-assistant.service` live im Browser:
1. **▶ Verbinden** → letzte 100 Zeilen + laufende Ausgabe erscheinen im Terminal-Fenster.
2. **■ Trennen** → Stream stoppen.
3. **Leeren** → Anzeige leeren (Log auf dem Server bleibt erhalten).
Der Log hilft, Fehler zu diagnostizieren ohne SSH-Zugang.
--- ---
## 8. Gedächtnis und Erinnerungen ## 8. Gedächtnis und Erinnerungen
@ -1659,12 +1715,26 @@ Body-Felder: `input_endpoint`, `output_endpoint`, `stt_provider`, `llm_provider`
| Methode | Pfad | Auth | Beschreibung | | Methode | Pfad | Auth | Beschreibung |
|---------|------|------|--------------| |---------|------|------|--------------|
| `POST` | `/api/admin/users` | `X-Admin-Key` | Nutzer anlegen → Token einmalig | | `POST` | `/api/admin/users` | Admin | Nutzer anlegen → Token einmalig |
| `GET` | `/api/admin/users` | `X-Admin-Key` | Alle Nutzer auflisten | | `GET` | `/api/admin/users` | Admin | Alle Nutzer auflisten |
| `PUT` | `/api/admin/users/{user_id}` | `X-Admin-Key` | Anzeigenamen aktualisieren (`{"display_name":"…"}`) | | `PUT` | `/api/admin/users/{user_id}` | Admin | Anzeigenamen aktualisieren (`{"display_name":"…"}`) |
| `DELETE` | `/api/admin/users/{user_id}` | `X-Admin-Key` | Nutzer + alle Daten löschen | | `DELETE` | `/api/admin/users/{user_id}` | Admin | Nutzer + alle Daten löschen |
| `POST` | `/api/admin/users/{user_id}/token` | `X-Admin-Key` | Neues Token ausstellen (alter Token sofort ungültig) | | `POST` | `/api/admin/users/{user_id}/token` | Admin | Neues Token ausstellen (alter Token sofort ungültig) |
| `POST` | `/api/admin/users/{user_id}/memories` | `X-Admin-Key` | Erinnerung für Nutzer vorbelegen (`{"content":"…"}`) | | `POST` | `/api/admin/users/{user_id}/memories` | Admin | Erinnerung für Nutzer vorbelegen (`{"content":"…"}`) |
| `GET` | `/api/admin/users/{user_id}/memories` | Admin | Alle Erinnerungen eines Nutzers |
| `DELETE` | `/api/admin/users/{user_id}/memories/{id}` | Admin | Eine Erinnerung löschen |
| `GET` | `/api/admin/users/{user_id}/sessions` | Admin | Sessions eines Nutzers (neueste zuerst) |
| `GET` | `/api/admin/sessions/{session_id}/messages` | Admin | Nachrichten einer Session (`?limit=200`) |
| `GET` | `/api/admin/emergency-events` | Admin | Notfall-Ereignisse (`?limit=50`) |
| `GET` | `/api/admin/users/{user_id}/usage` | Admin | Nutzungsstatistik eines Nutzers |
| `GET` | `/api/admin/usage` | Admin | Aggregierte Nutzungsstatistik aller Nutzer |
| `GET` | `/api/admin/db-export` | Admin | SQLite-Datenbank als Datei-Download (Backup) |
| `GET` | `/api/admin/pronunciation/{lang}` | Admin | Aussprache-Lexikon lesen (`lang`: `de`\|`en`) |
| `POST` | `/api/admin/pronunciation/{lang}` | Admin | Eintrag hinzufügen/überschreiben (`{"section":"…","key":"…","value":"…"}`) |
| `DELETE` | `/api/admin/pronunciation/{lang}/{section}/{key}` | Admin | Eintrag löschen |
| `WS` | `/api/admin/log` | Admin | Live-Log via WebSocket (journalctl stream) |
**Auth:** `X-Admin-Key`-Header oder SSO-Admin-Cookie (→ § 7.4).
## B.6 WebSocket ## B.6 WebSocket
@ -1714,6 +1784,7 @@ in `app/dependencies.py` + Implementierung in `app/providers/`. → [Architektur
| Begriff | Abschnitt | | Begriff | Abschnitt |
|---------|-----------| |---------|-----------|
| Admin-Web-Panel | § 7.5 |
| API-Key (OpenRouter) | § 2.3, Anhang A.2 | | API-Key (OpenRouter) | § 2.3, Anhang A.2 |
| Authentifizierung / Bearer-Token | § 7.1, § 7.3, Anhang B.4 | | Authentifizierung / Bearer-Token | § 7.1, § 7.3, Anhang B.4 |
| Audio-Geräte / Mikrofon / Lautsprecher | § 6.7 | | Audio-Geräte / Mikrofon / Lautsprecher | § 6.7 |

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.auth import is_admin_user, require_admin, require_admin_or_user
from app.config import settings from app.config import settings
@ -7,6 +13,16 @@ from app.schemas import MemoryCreate, MemoryOut, UserCreate, UserCreated, UserUp
router = APIRouter() 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") @router.get("/admin/request-headers")
async def request_headers(request: Request, key: str | None = None): 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(): async def get_all_usage():
"""Aggregierte Nutzungsstatistik aller Nutzer.""" """Aggregierte Nutzungsstatistik aller Nutzer."""
return get_store().get_all_usage() 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

View file

@ -383,6 +383,8 @@ function switchAdminTab(name) {
if (name === "emergency") loadEmergencyEvents(); if (name === "emergency") loadEmergencyEvents();
if (name === "status") loadStatus(); if (name === "status") loadStatus();
if (name === "metrics") loadMetrics(); if (name === "metrics") loadMetrics();
if (name === "words") loadWoerterbuch();
if (name === "log") { /* User muss manuell verbinden */ }
} }
document.querySelectorAll(".admin-tab").forEach((btn) => document.querySelectorAll(".admin-tab").forEach((btn) =>
@ -746,6 +748,16 @@ async function loadStatus() {
${providerRow("TTS", (cfg.available?.tts_providers || []).join(", "))} ${providerRow("TTS", (cfg.available?.tts_providers || []).join(", "))}
</dl> </dl>
</div> </div>
<!-- Datenbank-Export -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h3 class="font-semibold text-sm mb-1">Datenbank-Backup</h3>
<p class="text-xs text-slate-400 mb-3">SQLite-Datenbank als Datei herunterladen (Backup / Migration).</p>
<a href="/api/admin/db-export" download="voice-assistant.db"
class="inline-flex items-center gap-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white text-xs px-4 py-2 font-medium transition-colors">
voice-assistant.db herunterladen
</a>
</div>
`; `;
} catch (e) { } catch (e) {
container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>'; container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>';
@ -763,6 +775,19 @@ async function loadMetrics() {
if (!data.length) { container.innerHTML = '<p class="text-sm text-slate-400 py-4 text-center">Noch keine Nutzungsdaten vorhanden.</p>'; return; } if (!data.length) { container.innerHTML = '<p class="text-sm text-slate-400 py-4 text-center">Noch keine Nutzungsdaten vorhanden.</p>'; return; }
const totalReq = data.reduce((s, u) => s + (u.total_requests || 0), 0); const totalReq = data.reduce((s, u) => s + (u.total_requests || 0), 0);
const maxReq = Math.max(...data.map((u) => u.total_requests || 0), 1);
const bars = data.map((u) => {
const pct = Math.round(((u.total_requests || 0) / maxReq) * 100);
return `
<div class="flex items-center gap-3">
<span class="w-28 shrink-0 text-xs text-slate-600 dark:text-slate-300 truncate">${escHtml(u.display_name)}</span>
<div class="flex-1 h-4 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div class="h-full bg-blue-500 dark:bg-blue-600 rounded-full" style="width:${pct}%"></div>
</div>
<span class="w-10 text-right text-xs font-mono text-slate-500 dark:text-slate-400">${u.total_requests ?? 0}</span>
</div>`;
}).join("");
container.innerHTML = ` container.innerHTML = `
<div class="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700 mb-4"> <div class="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700 mb-4">
@ -794,5 +819,151 @@ async function loadMetrics() {
</tfoot> </tfoot>
</table> </table>
</div> </div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h3 class="font-semibold text-sm mb-4">Anfragen je Nutzer</h3>
<div class="space-y-2.5">${bars}</div>
</div>
`; `;
} }
// ════════════════════════════════════════
// TAB: WÖRTERBUCH
// ════════════════════════════════════════
async function loadWoerterbuch() {
const lang = ($("#words-lang") || {}).value || "de";
const container = $("#words-content");
container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
const data = await adminFetch(`/api/admin/pronunciation/${lang}`);
if (!data) { container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>'; return; }
container.innerHTML = "";
const sections = [
["abbreviations", "Abkürzungen"],
["units", "Einheiten"],
["terms", "Begriffe / Aussprache"],
];
for (const [section, label] of sections) {
container.appendChild(buildWortSection(label, section, data[section] || {}, lang));
}
}
function buildWortSection(title, section, entries, lang) {
const wrap = document.createElement("div");
wrap.className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4";
const rows = Object.entries(entries).map(([k, v]) => `
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors group">
<td class="px-3 py-2 text-xs font-mono text-slate-700 dark:text-slate-300">${escHtml(k)}</td>
<td class="px-3 py-2 text-xs text-slate-400"></td>
<td class="px-3 py-2 text-xs text-slate-700 dark:text-slate-300 font-medium">${escHtml(v)}</td>
<td class="px-3 py-2 text-right">
<button class="del-btn text-slate-300 dark:text-slate-600 hover:text-red-500 dark:hover:text-red-400 transition-colors text-xs opacity-0 group-hover:opacity-100"
data-key="${escHtml(k)}"></button>
</td>
</tr>`).join("");
wrap.innerHTML = `
<h4 class="font-semibold text-sm mb-3 flex items-center justify-between">
${escHtml(title)}
<span class="text-xs font-normal text-slate-400">${Object.keys(entries).length} Einträge</span>
</h4>
${rows ? `
<div class="overflow-x-auto mb-4">
<table class="w-full">
<tbody class="divide-y divide-slate-100 dark:divide-slate-700/60">${rows}</tbody>
</table>
</div>` : '<p class="text-xs text-slate-400 italic mb-4">Keine Einträge.</p>'}
<form class="add-form flex gap-2 flex-wrap">
<input type="text" placeholder="Schlüssel" required
class="flex-1 min-w-24 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" />
<input type="text" placeholder="Ersetzung" required
class="flex-1 min-w-24 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" />
<button type="submit"
class="rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs px-3 py-1.5 font-medium transition-colors whitespace-nowrap">+ Hinzufügen</button>
</form>
`;
wrap.querySelectorAll(".del-btn").forEach((btn) =>
btn.addEventListener("click", async () => {
const key = btn.dataset.key;
if (!confirm(`Eintrag „${key}" löschen?`)) return;
const res = await adminFetch(
`/api/admin/pronunciation/${lang}/${section}/${encodeURIComponent(key)}`, "DELETE"
);
if (res !== null) btn.closest("tr").remove();
})
);
wrap.querySelector(".add-form").addEventListener("submit", async (e) => {
e.preventDefault();
const [keyInp, valInp] = e.target.querySelectorAll("input");
const key = keyInp.value.trim();
const value = valInp.value.trim();
if (!key || !value) return;
const res = await adminFetch(`/api/admin/pronunciation/${lang}`, "POST", { section, key, value });
if (res !== null) { keyInp.value = ""; valInp.value = ""; loadWoerterbuch(); }
});
return wrap;
}
$("#load-words").addEventListener("click", loadWoerterbuch);
$("#words-lang").addEventListener("change", loadWoerterbuch);
// ════════════════════════════════════════
// TAB: LOG
// ════════════════════════════════════════
let _logWs = null;
function connectLog() {
if (_logWs) return;
const proto = location.protocol === "https:" ? "wss" : "ws";
_logWs = new WebSocket(`${proto}://${location.host}/api/admin/log`);
const output = $("#log-output");
const logStat = $("#log-status");
const btnConn = $("#log-connect");
const btnDisc = $("#log-disconnect");
btnConn.disabled = true;
btnDisc.disabled = false;
btnDisc.classList.remove("opacity-40");
logStat.textContent = "verbinde …";
logStat.className = "text-xs text-amber-400";
_logWs.onopen = () => {
logStat.textContent = "verbunden";
logStat.className = "text-xs text-emerald-400";
output.innerHTML = "";
};
_logWs.onmessage = (e) => {
const line = document.createElement("div");
line.textContent = e.data;
output.appendChild(line);
output.scrollTop = output.scrollHeight;
};
_logWs.onerror = () => {
logStat.textContent = "Verbindungsfehler";
logStat.className = "text-xs text-red-400";
};
_logWs.onclose = () => {
_logWs = null;
btnConn.disabled = false;
btnDisc.disabled = true;
btnDisc.classList.add("opacity-40");
if (!logStat.className.includes("red")) {
logStat.textContent = "getrennt";
logStat.className = "text-xs text-slate-400";
}
};
}
function disconnectLog() {
if (_logWs) { _logWs.close(); }
}
$("#log-connect").addEventListener("click", connectLog);
$("#log-disconnect").addEventListener("click", disconnectLog);
$("#log-clear").addEventListener("click", () => { $("#log-output").innerHTML = ""; });

View file

@ -36,6 +36,8 @@
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="emergency">🚨 Notfälle</button> <button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="emergency">🚨 Notfälle</button>
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="status">📡 Status</button> <button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="status">📡 Status</button>
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="metrics">📊 Metriken</button> <button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="metrics">📊 Metriken</button>
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="words">🔤 Wörterbuch</button>
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="log">📋 Log</button>
</nav> </nav>
<!-- Tab Content Container --> <!-- Tab Content Container -->
@ -124,6 +126,44 @@
</div> </div>
</div> </div>
<!-- ── Tab: Wörterbuch ── -->
<div id="admin-tab-words" class="hidden h-full overflow-y-auto p-4">
<div class="max-w-3xl mx-auto">
<div class="flex items-center justify-between mb-4 flex-wrap gap-2">
<h3 class="font-semibold text-base">Aussprache-Lexikon</h3>
<div class="flex items-center gap-2">
<select id="words-lang"
class="text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5">
<option value="de">Deutsch (de)</option>
<option value="en">Englisch (en)</option>
</select>
<button id="load-words"
class="rounded-lg border border-slate-300 dark:border-slate-600 text-sm px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Aktualisieren</button>
</div>
</div>
<p class="text-xs text-slate-400 mb-4">Wirkt nur bei lokalem TTS (Piper, Stufe „full"). <strong>Abkürzungen</strong> ersetzen ganze Token. <strong>Einheiten</strong> wirken nur direkt nach einer Zahl. <strong>Begriffe</strong> für Eigennamen, Fachwörter, Aussprache-Korrekturen.</p>
<div id="words-content" class="space-y-6">
<p class="text-sm text-slate-400">lade …</p>
</div>
</div>
</div>
<!-- ── Tab: Log ── -->
<div id="admin-tab-log" class="hidden h-full flex flex-col">
<div class="shrink-0 flex items-center gap-3 px-4 py-2.5 border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/60">
<button id="log-connect"
class="rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white text-xs px-3 py-1.5 font-medium transition-colors">▶ Verbinden</button>
<button id="log-disconnect" disabled
class="rounded-lg border border-slate-300 dark:border-slate-600 text-xs px-3 py-1.5 opacity-40 transition-colors">■ Trennen</button>
<span id="log-status" class="text-xs text-slate-400">getrennt</span>
<button id="log-clear" class="ml-auto text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">Leeren</button>
</div>
<div id="log-output"
class="flex-1 overflow-y-auto bg-slate-950 text-emerald-300 font-mono text-xs p-4 leading-relaxed whitespace-pre-wrap">
<span class="text-slate-500">— Verbinden um Log zu starten —</span>
</div>
</div>
</div><!-- /Tab Content Container --> </div><!-- /Tab Content Container -->
</div><!-- /Admin Panel --> </div><!-- /Admin Panel -->
@ -162,6 +202,6 @@
<div id="status" class="w-full max-w-3xl mx-auto mt-1 min-h-[1rem] text-xs text-slate-500 dark:text-slate-400"></div> <div id="status" class="w-full max-w-3xl mx-auto mt-1 min-h-[1rem] text-xs text-slate-500 dark:text-slate-400"></div>
</footer> </footer>
<script src="/app.js?v=8"></script> <script src="/app.js?v=9"></script>
</body> </body>
</html> </html>