From b38992cf2887bf37a31c13e284581d583f5e4830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 19 Jun 2026 01:04:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(admin):=20vollst=C3=A4ndiges=20Admin-Panel?= =?UTF-8?q?=20in=20der=20Web-UI=20(Phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ersetzt das rudimentäre Nutzer-Dropdown durch ein vollwertiges Admin-Panel-Overlay mit 5 Tabs: Nutzer: Anlegen (Token einmalig), Umbenennen, Token-Reset, Löschen, Erinnerungen verwalten (anzeigen/add/delete) Gespräche: Nutzer → Sessions → Transkript (Chat-Bubble-Ansicht) Notfälle: Tabelle aller emergency_events mit Zeitstempel/Kategorie Status: Gateway-Status, Provider-Config, Laufzeit-Metriken Metriken: Nutzungsstatistik pro Nutzer + Gesamtsumme Backend: 7 neue Admin-Endpunkte in admin.py, 5 neue Store-Methoden. Design: Tailwind CSS, Tag/Nacht-Modus, responsiv. Co-Authored-By: Claude Sonnet 4.6 --- app/api/admin.py | 51 +++++ app/store.py | 67 ++++++ app/web/app.js | 496 +++++++++++++++++++++++++++++++++++++++++++-- app/web/index.html | 130 ++++++++++-- 4 files changed, 710 insertions(+), 34 deletions(-) diff --git a/app/api/admin.py b/app/api/admin.py index 2f43c1a..e070b8e 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -101,3 +101,54 @@ async def list_users(): } for u in get_store().list_users() ] + + +@router.get("/admin/users/{user_id}/memories", dependencies=[Depends(require_admin)]) +async def get_user_memories(user_id: str): + """Gibt alle Erinnerungen eines Nutzers zurueck.""" + store = get_store() + if store.get_user(user_id) is None: + raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.") + return [ + {"id": m.id, "content": m.content, "created_at": m.created_at} + for m in store.get_memories(user_id) + ] + + +@router.delete("/admin/users/{user_id}/memories/{memory_id}", dependencies=[Depends(require_admin)]) +async def delete_user_memory(user_id: str, memory_id: int): + """Loescht eine einzelne Erinnerung eines Nutzers.""" + deleted = get_store().delete_memory(user_id, memory_id) + if not deleted: + raise HTTPException(status_code=404, detail="Erinnerung nicht gefunden.") + return {"deleted": memory_id} + + +@router.get("/admin/users/{user_id}/sessions", dependencies=[Depends(require_admin)]) +async def list_user_sessions(user_id: str): + """Listet alle Sessions eines Nutzers (neueste zuerst).""" + return get_store().list_sessions_for_user(user_id) + + +@router.get("/admin/sessions/{session_id}/messages", dependencies=[Depends(require_admin)]) +async def get_session_messages(session_id: str, limit: int = 200): + """Gibt alle Nachrichten einer Session zurueck (Gespraechs-Browser).""" + return get_store().get_messages_for_session(session_id, limit) + + +@router.get("/admin/emergency-events", dependencies=[Depends(require_admin)]) +async def list_emergency_events(limit: int = 50): + """Listet alle protokollierten Notfall-Ereignisse (neueste zuerst).""" + return get_store().list_emergency_events(limit) + + +@router.get("/admin/users/{user_id}/usage", dependencies=[Depends(require_admin)]) +async def get_user_usage(user_id: str): + """Nutzungsstatistik eines Nutzers (letzte 30 Tage).""" + return get_store().get_usage_for_user(user_id) + + +@router.get("/admin/usage", dependencies=[Depends(require_admin)]) +async def get_all_usage(): + """Aggregierte Nutzungsstatistik aller Nutzer.""" + return get_store().get_all_usage() diff --git a/app/store.py b/app/store.py index 4489fc6..4c50f11 100644 --- a/app/store.py +++ b/app/store.py @@ -137,6 +137,21 @@ class Store(ABC): def log_emergency(self, user_id: str, category: str, snippet: str) -> None: """Protokolliert ein erkanntes Notfall-Signal (sensibel!).""" + @abstractmethod + def list_sessions_for_user(self, user_id: str) -> list[dict]: ... + + @abstractmethod + def get_messages_for_session(self, session_id: str, limit: int = 200) -> list[dict]: ... + + @abstractmethod + def list_emergency_events(self, limit: int = 50) -> list[dict]: ... + + @abstractmethod + def get_usage_for_user(self, user_id: str) -> list[dict]: ... + + @abstractmethod + def get_all_usage(self) -> list[dict]: ... + class SQLiteStore(Store): def __init__(self, db_path: str): @@ -484,3 +499,55 @@ class SQLiteStore(Store): " VALUES (?, ?, ?, ?)", (user_id, category, snippet, _now()), ) + + # ----- Admin-Abfragen --------------------------------------------------- + def list_sessions_for_user(self, user_id: str) -> list[dict]: + with self._connect() as conn: + rows = conn.execute( + "SELECT s.id, s.created_at, s.updated_at," + " (SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS msg_count" + " FROM sessions s WHERE s.user_id = ?" + " ORDER BY s.updated_at DESC LIMIT 50", + (user_id,), + ).fetchall() + return [dict(r) for r in rows] + + def get_messages_for_session(self, session_id: str, limit: int = 200) -> list[dict]: + with self._connect() as conn: + rows = conn.execute( + "SELECT role, content, created_at FROM messages" + " WHERE session_id = ? ORDER BY id LIMIT ?", + (session_id, limit), + ).fetchall() + return [dict(r) for r in rows] + + def list_emergency_events(self, limit: int = 50) -> list[dict]: + with self._connect() as conn: + rows = conn.execute( + "SELECT e.id, e.user_id, e.category, e.snippet, e.created_at," + " COALESCE(u.display_name, e.user_id) AS display_name" + " FROM emergency_events e LEFT JOIN users u ON u.id = e.user_id" + " ORDER BY e.id DESC LIMIT ?", + (limit,), + ).fetchall() + return [dict(r) for r in rows] + + def get_usage_for_user(self, user_id: str) -> list[dict]: + with self._connect() as conn: + rows = conn.execute( + "SELECT day, requests, units FROM usage" + " WHERE user_id = ? ORDER BY day DESC LIMIT 30", + (user_id,), + ).fetchall() + return [dict(r) for r in rows] + + def get_all_usage(self) -> list[dict]: + with self._connect() as conn: + rows = conn.execute( + "SELECT u.user_id, COALESCE(usr.display_name, u.user_id) AS display_name," + " SUM(u.requests) AS total_requests, SUM(u.units) AS total_units," + " MAX(u.day) AS last_active" + " FROM usage u LEFT JOIN users usr ON usr.id = u.user_id" + " GROUP BY u.user_id ORDER BY total_requests DESC", + ).fetchall() + return [dict(r) for r in rows] diff --git a/app/web/app.js b/app/web/app.js index 5075e5c..3938b7c 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -61,24 +61,7 @@ async function ensureSession() { if (!sessionId) sessionId = "web-anon"; } -async function loadUsers() { - const ul = $("#user-list"); - ul.innerHTML = "
  • lade …
  • "; - try { - const res = await fetch("/api/admin/users"); - if (!res.ok) { ul.innerHTML = "
  • kein Zugriff
  • "; return; } - const users = await res.json(); - ul.innerHTML = ""; - for (const u of users) { - const li = document.createElement("li"); - li.textContent = (u.external_id || u.display_name) + " (" + u.user_id.slice(0, 8) + ")"; - ul.appendChild(li); - } - if (!users.length) ul.innerHTML = "
  • keine Nutzer
  • "; - } catch (e) { - ul.innerHTML = "
  • Fehler
  • "; - } -} +// loadUsers() ersetzt durch Admin-Panel (siehe unten) // ---------- Nachrichten-UI ---------- // Nach dem naechsten Layout ans Ende scrollen -> die neueste Antwort ist sichtbar. @@ -312,8 +295,7 @@ micBtn.addEventListener("click", () => { } }); -$("#reload-users").addEventListener("click", loadUsers); -$("#admin-toggle").addEventListener("click", () => $("#admin").classList.toggle("hidden")); +$("#admin-toggle").addEventListener("click", openAdminPanel); // Tag/Nacht: Umschalter (manuell) + automatisch dem System folgen, solange nichts gewählt. const themeBtn = $("#theme-toggle"); @@ -340,3 +322,477 @@ window.addEventListener("resize", scrollToBottom); promptEl.addEventListener("focus", () => setTimeout(scrollToBottom, 300)); loadMe(); + +// ═══════════════════════════════════════════════════════════════ +// ADMIN PANEL +// ═══════════════════════════════════════════════════════════════ + +// ---------- Hilfsfunktionen ---------- +function escHtml(str) { + if (!str) return ""; + return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function fmtDate(iso) { + if (!iso) return "—"; + try { + return new Date(iso).toLocaleString("de-DE", { + day: "2-digit", month: "2-digit", year: "2-digit", + hour: "2-digit", minute: "2-digit", + }); + } catch { return iso; } +} + +async function adminFetch(url, method = "GET", body = null) { + try { + const opts = { method, headers: { "Content-Type": "application/json" } }; + if (body) opts.body = JSON.stringify(body); + const res = await fetch(url, opts); + if (method === "DELETE" && res.status === 204) return {}; + const data = await res.json().catch(() => null); + if (!res.ok) { console.error("Admin API", res.status, data); return null; } + return data; + } catch (e) { console.error("adminFetch", e); return null; } +} + +// ---------- Panel öffnen / schließen ---------- +function openAdminPanel() { + $("#admin-panel").classList.remove("hidden"); + switchAdminTab("users"); +} + +$("#admin-close").addEventListener("click", () => $("#admin-panel").classList.add("hidden")); + +// ---------- Tab-Umschalter ---------- +function switchAdminTab(name) { + document.querySelectorAll('[id^="admin-tab-"]').forEach((el) => el.classList.add("hidden")); + $("#admin-tab-" + name).classList.remove("hidden"); + + document.querySelectorAll(".admin-tab").forEach((btn) => { + const active = btn.dataset.tab === name; + btn.classList.toggle("border-blue-600", active); + btn.classList.toggle("text-blue-600", active); + btn.classList.toggle("dark:border-blue-400", active); + btn.classList.toggle("dark:text-blue-400", active); + btn.classList.toggle("border-transparent", !active); + btn.classList.toggle("text-slate-500", !active); + }); + + if (name === "users") loadAdminUsers(); + if (name === "chat") loadChatUsers(); + if (name === "emergency") loadEmergencyEvents(); + if (name === "status") loadStatus(); + if (name === "metrics") loadMetrics(); +} + +document.querySelectorAll(".admin-tab").forEach((btn) => + btn.addEventListener("click", () => switchAdminTab(btn.dataset.tab)) +); +$("#load-emergency").addEventListener("click", loadEmergencyEvents); +$("#load-status").addEventListener("click", loadStatus); +$("#load-metrics").addEventListener("click", loadMetrics); + +// ════════════════════════════════════════ +// TAB: NUTZER +// ════════════════════════════════════════ +async function loadAdminUsers() { + const container = $("#admin-user-cards"); + container.innerHTML = '

    lade …

    '; + const users = await adminFetch("/api/admin/users"); + if (!users) { container.innerHTML = '

    Fehler beim Laden.

    '; return; } + if (!users.length) { container.innerHTML = '

    Noch keine Nutzer.

    '; return; } + container.innerHTML = ""; + users.forEach((u) => container.appendChild(buildUserCard(u))); +} + +$("#create-user-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const nameEl = $("#new-user-name"); + const name = nameEl.value.trim(); + if (!name) return; + const data = await adminFetch("/api/admin/users", "POST", { display_name: name }); + if (data?.token) { + $("#new-token-value").textContent = data.token; + $("#new-token-box").classList.remove("hidden"); + nameEl.value = ""; + loadAdminUsers(); + } +}); + +function buildUserCard(u) { + const card = document.createElement("div"); + card.className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4"; + + const badgeSSO = u.external_id + ? `SSO` : ""; + + card.innerHTML = ` +
    +
    +
    + ${escHtml(u.display_name)} + ${badgeSSO} +
    +

    ${u.user_id.slice(0, 8)}…${u.external_id ? " · " + escHtml(u.external_id) : ""}

    +

    seit ${fmtDate(u.created_at)}

    +
    +
    + + + +
    +
    + + + + + + + + +
    + + +
    + `; + + const uid = u.user_id; + + // Umbenennen + card.querySelector(".btn-rename").addEventListener("click", () => + card.querySelector(".rename-box").classList.toggle("hidden") + ); + card.querySelector(".rename-cancel").addEventListener("click", () => + card.querySelector(".rename-box").classList.add("hidden") + ); + card.querySelector(".rename-box form").addEventListener("submit", async (e) => { + e.preventDefault(); + const name = card.querySelector(".rename-inp").value.trim(); + if (!name) return; + const res = await adminFetch(`/api/admin/users/${uid}`, "PUT", { display_name: name }); + if (res) { + card.querySelector(".user-dn").textContent = name; + card.querySelector(".rename-box").classList.add("hidden"); + } + }); + + // Token-Reset + card.querySelector(".btn-token").addEventListener("click", async () => { + if (!confirm(`Token für „${u.display_name}" zurücksetzen?\nDer alte Token wird sofort ungültig.`)) return; + const data = await adminFetch(`/api/admin/users/${uid}/token`, "POST"); + if (data?.token) { + card.querySelector(".token-val").textContent = data.token; + card.querySelector(".token-box").classList.remove("hidden"); + } + }); + + // Löschen + card.querySelector(".btn-delete").addEventListener("click", async () => { + if (!confirm(`Nutzer „${u.display_name}" und alle Daten unwiderruflich löschen?`)) return; + const res = await adminFetch(`/api/admin/users/${uid}`, "DELETE"); + if (res !== null) card.remove(); + }); + + // Erinnerungen auf-/zuklappen + const memBtn = card.querySelector(".btn-mem"); + const memSection = card.querySelector(".mem-section"); + const memArrow = card.querySelector(".mem-arrow"); + memBtn.addEventListener("click", async () => { + const open = !memSection.classList.contains("hidden"); + memSection.classList.toggle("hidden", open); + memArrow.textContent = open ? "▶" : "▼"; + if (!open) await renderMemories(uid, card.querySelector(".mem-list")); + }); + + // Erinnerung hinzufügen + card.querySelector(".mem-add-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const inp = e.target.querySelector("input"); + const content = inp.value.trim(); + if (!content) return; + const res = await adminFetch(`/api/admin/users/${uid}/memories`, "POST", { content }); + if (res) { inp.value = ""; await renderMemories(uid, card.querySelector(".mem-list")); } + }); + + return card; +} + +async function renderMemories(uid, container) { + container.innerHTML = '

    lade …

    '; + const mems = await adminFetch(`/api/admin/users/${uid}/memories`); + if (!mems?.length) { + container.innerHTML = '

    Keine Erinnerungen vorhanden.

    '; + return; + } + container.innerHTML = ""; + mems.forEach((m) => { + const row = document.createElement("div"); + row.className = "flex items-start gap-2 rounded-lg bg-slate-50 dark:bg-slate-700/50 px-3 py-2 text-xs"; + row.innerHTML = ` + ${escHtml(m.content)} + + `; + row.querySelector("button").addEventListener("click", async () => { + await adminFetch(`/api/admin/users/${uid}/memories/${m.id}`, "DELETE"); + row.remove(); + }); + container.appendChild(row); + }); +} + +// ════════════════════════════════════════ +// TAB: GESPRÄCHE +// ════════════════════════════════════════ +let _chatUser = null; + +async function loadChatUsers() { + const list = $("#chat-user-list"); + list.innerHTML = '
  • lade …
  • '; + const users = await adminFetch("/api/admin/users"); + if (!users) return; + list.innerHTML = ""; + users.forEach((u) => { + const li = document.createElement("li"); + li.className = "px-3 py-2.5 text-sm cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex flex-col"; + li.innerHTML = `${escHtml(u.display_name)} + ${u.user_id.slice(0,8)}…`; + li.addEventListener("click", () => { + document.querySelectorAll("#chat-user-list li").forEach((el) => + el.classList.remove("bg-blue-50", "dark:bg-blue-900/20", "text-blue-700", "dark:text-blue-300") + ); + li.classList.add("bg-blue-50", "dark:bg-blue-900/20"); + _chatUser = u; + loadChatSessions(u); + }); + list.appendChild(li); + }); +} + +async function loadChatSessions(u) { + const panel = $("#chat-right-panel"); + panel.innerHTML = `

    lade Sessions für ${escHtml(u.display_name)} …

    `; + const sessions = await adminFetch(`/api/admin/users/${u.user_id}/sessions`); + if (!sessions?.length) { + panel.innerHTML = '

    Keine Sessions gefunden.

    '; + return; + } + panel.innerHTML = ` +
    +

    Sessions · ${escHtml(u.display_name)}

    +
    +
    + `; + const list = panel.querySelector("#session-list"); + sessions.forEach((s) => { + const row = document.createElement("div"); + row.className = "px-4 py-3 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"; + row.innerHTML = ` +

    ${escHtml(s.id)}

    +

    ${fmtDate(s.updated_at)} · ${s.msg_count} Nachrichten

    + `; + row.addEventListener("click", () => loadTranscript(s.id)); + list.appendChild(row); + }); +} + +async function loadTranscript(sessionId) { + const panel = $("#chat-right-panel"); + panel.innerHTML = `

    lade Transkript …

    `; + const msgs = await adminFetch(`/api/admin/sessions/${sessionId}/messages`); + if (!msgs?.length) { + panel.innerHTML = `
    + +

    Keine Nachrichten.

    `; + panel.querySelector(".back-btn").addEventListener("click", () => _chatUser && loadChatSessions(_chatUser)); + return; + } + + const bubbles = msgs.map((m) => { + const isUser = m.role === "user"; + return `
    +
    + ${escHtml(m.content)}
    `; + }).join(""); + + panel.innerHTML = ` +
    + + ${escHtml(sessionId)} +
    +
    ${bubbles}
    + `; + panel.querySelector(".back-btn").addEventListener("click", () => _chatUser && loadChatSessions(_chatUser)); +} + +// ════════════════════════════════════════ +// TAB: NOTFÄLLE +// ════════════════════════════════════════ +async function loadEmergencyEvents() { + const container = $("#emergency-content"); + container.innerHTML = '

    lade …

    '; + const events = await adminFetch("/api/admin/emergency-events"); + if (!events) { container.innerHTML = '

    Fehler beim Laden.

    '; return; } + if (!events.length) { container.innerHTML = '

    Keine Notfall-Ereignisse protokolliert.

    '; return; } + container.innerHTML = ` +
    + + + + + + + + + + + ${events.map((ev) => ` + + + + + + `).join("")} + +
    ZeitpunktNutzerKategorieTextausschnitt
    ${fmtDate(ev.created_at)}${escHtml(ev.display_name)} + ${escHtml(ev.category)} + ${escHtml(ev.snippet)}
    +
    `; +} + +// ════════════════════════════════════════ +// TAB: STATUS +// ════════════════════════════════════════ +async function loadStatus() { + const container = $("#status-content"); + container.innerHTML = '

    lade …

    '; + try { + const [cfg, met] = await Promise.all([ + fetch("/api/config").then((r) => r.json()), + fetch("/api/metrics").then((r) => r.json()), + ]); + const route = cfg.default_route || {}; + const counters = met.counters || {}; + const turnsTotal = counters["turns_total"] || 0; + const httpTotal = Object.entries(counters) + .filter(([k]) => k.startsWith("http_requests_total")) + .reduce((s, [, v]) => s + v, 0); + + const providerRow = (label, val) => + `
    ${label}
    +
    ${escHtml(val || "—")}
    `; + + container.innerHTML = ` + +
    +
    + +

    Gateway

    + läuft +
    +
    + ${providerRow("Profil", cfg.profile)} + ${providerRow("Umgebung", cfg.app_env)} +
    +
    + + +
    +

    Provider (Standard-Route)

    +
    + ${providerRow("STT", route.stt_provider)} + ${providerRow("LLM", route.llm_provider)} + ${providerRow("TTS", route.tts_provider)} + ${providerRow("Sprache", route.language)} +
    +
    + + +
    +

    Laufzeit-Metriken

    +
    + ${providerRow("Turns gesamt", turnsTotal)} + ${providerRow("HTTP-Anfragen", httpTotal)} +
    +
    + + +
    +

    Verfügbare Provider

    +
    + ${providerRow("STT", (cfg.available?.stt_providers || []).join(", "))} + ${providerRow("LLM", (cfg.available?.llm_providers || []).join(", "))} + ${providerRow("TTS", (cfg.available?.tts_providers || []).join(", "))} +
    +
    + `; + } catch (e) { + container.innerHTML = '

    Fehler beim Laden.

    '; + } +} + +// ════════════════════════════════════════ +// TAB: METRIKEN +// ════════════════════════════════════════ +async function loadMetrics() { + const container = $("#metrics-content"); + container.innerHTML = '

    lade …

    '; + const data = await adminFetch("/api/admin/usage"); + if (!data) { container.innerHTML = '

    Fehler beim Laden.

    '; return; } + if (!data.length) { container.innerHTML = '

    Noch keine Nutzungsdaten vorhanden.

    '; return; } + + const totalReq = data.reduce((s, u) => s + (u.total_requests || 0), 0); + + container.innerHTML = ` +
    + + + + + + + + + + + ${data.map((u) => ` + + + + + + `).join("")} + + + + + + + + + +
    NutzerAnfragen gesamtEinheitenLetzte Aktivität
    ${escHtml(u.display_name)}${u.total_requests ?? 0}${u.total_units ?? 0}${u.last_active || "—"}
    Gesamt${totalReq}
    +
    + `; +} diff --git a/app/web/index.html b/app/web/index.html index 72af00c..e87c003 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -7,7 +7,6 @@ +