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 …
'; + 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 = ` +${u.user_id.slice(0, 8)}…${u.external_id ? " · " + escHtml(u.external_id) : ""}
+seit ${fmtDate(u.created_at)}
+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 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)}
+${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.
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 = ` +| Zeitpunkt | +Nutzer | +Kategorie | +Textausschnitt | +
|---|---|---|---|
| ${fmtDate(ev.created_at)} | +${escHtml(ev.display_name)} | ++ ${escHtml(ev.category)} + | +${escHtml(ev.snippet)} | +
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) => + `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 = ` +| Nutzer | +Anfragen gesamt | +Einheiten | +Letzte Aktivität | +
|---|---|---|---|
| ${escHtml(u.display_name)} | +${u.total_requests ?? 0} | +${u.total_units ?? 0} | +${u.last_active || "—"} | +
| Gesamt | +${totalReq} | ++ | + |