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

@ -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 = ""; });

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="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>