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:
parent
b38992cf28
commit
366d71ba26
4 changed files with 424 additions and 11 deletions
|
|
@ -44,7 +44,7 @@ funktioniert trotzdem, die Ausgabe ist dann unformatiert.
|
|||
6. [Einstellungen und Konfiguration](#6-einstellungen-und-konfiguration)
|
||||
|
||||
**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)
|
||||
9. [Resilienz, Fallbacks und Metriken](#9-resilienz-fallbacks-und-metriken)
|
||||
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) |
|
||||
| **Stimme ▾** | TTS-Provider wählen: leer = Server-Default, `chatterbox` = neuronale Stimme, `openrouter` = Cloud-TTS |
|
||||
| **☀️ / 🌙** | 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 |
|
||||
|
||||
#### 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`
|
||||
— `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
|
||||
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 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).
|
||||
|
||||
### 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
|
||||
|
|
@ -1659,12 +1715,26 @@ Body-Felder: `input_endpoint`, `output_endpoint`, `stt_provider`, `llm_provider`
|
|||
|
||||
| Methode | Pfad | Auth | Beschreibung |
|
||||
|---------|------|------|--------------|
|
||||
| `POST` | `/api/admin/users` | `X-Admin-Key` | Nutzer anlegen → Token einmalig |
|
||||
| `GET` | `/api/admin/users` | `X-Admin-Key` | Alle Nutzer auflisten |
|
||||
| `PUT` | `/api/admin/users/{user_id}` | `X-Admin-Key` | Anzeigenamen aktualisieren (`{"display_name":"…"}`) |
|
||||
| `DELETE` | `/api/admin/users/{user_id}` | `X-Admin-Key` | 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}/memories` | `X-Admin-Key` | Erinnerung für Nutzer vorbelegen (`{"content":"…"}`) |
|
||||
| `POST` | `/api/admin/users` | Admin | Nutzer anlegen → Token einmalig |
|
||||
| `GET` | `/api/admin/users` | Admin | Alle Nutzer auflisten |
|
||||
| `PUT` | `/api/admin/users/{user_id}` | Admin | Anzeigenamen aktualisieren (`{"display_name":"…"}`) |
|
||||
| `DELETE` | `/api/admin/users/{user_id}` | Admin | Nutzer + alle Daten löschen |
|
||||
| `POST` | `/api/admin/users/{user_id}/token` | Admin | Neues Token ausstellen (alter Token sofort ungültig) |
|
||||
| `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
|
||||
|
||||
|
|
@ -1714,6 +1784,7 @@ in `app/dependencies.py` + Implementierung in `app/providers/`. → [Architektur
|
|||
|
||||
| Begriff | Abschnitt |
|
||||
|---------|-----------|
|
||||
| Admin-Web-Panel | § 7.5 |
|
||||
| API-Key (OpenRouter) | § 2.3, Anhang A.2 |
|
||||
| Authentifizierung / Bearer-Token | § 7.1, § 7.3, Anhang B.4 |
|
||||
| Audio-Geräte / Mikrofon / Lautsprecher | § 6.7 |
|
||||
|
|
|
|||
133
app/api/admin.py
133
app/api/admin.py
|
|
@ -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
|
||||
|
|
|
|||
171
app/web/app.js
171
app/web/app.js
|
|
@ -383,6 +383,8 @@ function switchAdminTab(name) {
|
|||
if (name === "emergency") loadEmergencyEvents();
|
||||
if (name === "status") loadStatus();
|
||||
if (name === "metrics") loadMetrics();
|
||||
if (name === "words") loadWoerterbuch();
|
||||
if (name === "log") { /* User muss manuell verbinden */ }
|
||||
}
|
||||
|
||||
document.querySelectorAll(".admin-tab").forEach((btn) =>
|
||||
|
|
@ -746,6 +748,16 @@ async function loadStatus() {
|
|||
${providerRow("TTS", (cfg.available?.tts_providers || []).join(", "))}
|
||||
</dl>
|
||||
</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) {
|
||||
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; }
|
||||
|
||||
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 = `
|
||||
<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>
|
||||
</table>
|
||||
</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 = ""; });
|
||||
|
|
|
|||
|
|
@ -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="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="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>
|
||||
|
||||
<!-- Tab Content Container -->
|
||||
|
|
@ -124,6 +126,44 @@
|
|||
</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><!-- /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>
|
||||
</footer>
|
||||
|
||||
<script src="/app.js?v=8"></script>
|
||||
<script src="/app.js?v=9"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue