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
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 = ""; });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue