From 97ae0a5d347c837732132a405ec3063827611551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Sat, 20 Jun 2026 18:33:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(admin):=20LLM-/GPU-Status-Karte=20(read-on?= =?UTF-8?q?ly)=20=E2=80=94=20Plan-Schritt=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/admin_llm.py: read-only Statusabfragen ohne sudo (docker ps, ollama ps, nvidia-smi, systemctl --user) mit sicheren Defaults bei fehlenden Tools. - GET /api/admin/llm/status (require_admin): Backend, Modell, Backend-Status, geladene Ollama-Modelle, GPU-Auslastung, Gateway-Dienst-Status. - Admin Status-Tab: LLM-Backend-Karte mit GPU-Balken. - Tests: Auth-Gate + Antwortschema (160 grün). - Doku §7.5: Status-Tab um LLM/GPU-Karte ergänzt. Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 5 +- app/admin_llm.py | 115 +++++++++++++++++++++++++++++++++ app/api/admin.py | 9 +++ app/web/app.js | 47 +++++++++++++- app/web/index.html | 2 +- tests/test_admin_llm_status.py | 41 ++++++++++++ 6 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 app/admin_llm.py create mode 100644 tests/test_admin_llm_status.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index e13a294..ccfef77 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -1755,7 +1755,10 @@ 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. +Zusätzlich eine **LLM-Backend-Karte** (read-only): aktives Backend (Ollama/llama.cpp), +Modell, ob die Backends laufen, geladene Ollama-Modelle und die **GPU-Auslastung** je +Karte als Balken. Am Ende: **⬇ voice-assistant.db herunterladen** — lädt die +SQLite-Datenbank als Backup. #### Metriken diff --git a/app/admin_llm.py b/app/admin_llm.py new file mode 100644 index 0000000..8a5acf1 --- /dev/null +++ b/app/admin_llm.py @@ -0,0 +1,115 @@ +"""Read-only Statusabfragen rund um das lokale LLM-Backend (fuer das Admin-Panel). + +Alles nur lesend, ohne sudo: `docker ps`, `ollama ps`, `nvidia-smi`, `systemctl --user`. +Fehlende Tools oder Fehler fuehren zu sicheren Defaults (None/[]), nie zu Exceptions. +""" + +from __future__ import annotations + +import asyncio +import os +import shutil + +from app.config import settings + + +async def _run(cmd: list[str], timeout: float = 6.0) -> str | None: + """Fuehrt ein Kommando aus (shell=False) und liefert stdout, oder None bei Fehler.""" + if not shutil.which(cmd[0]): + return None + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + env={**os.environ, "XDG_RUNTIME_DIR": os.environ.get( + "XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")}, + ) + out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) + if proc.returncode != 0: + return None + return out.decode("utf-8", "replace") + except (asyncio.TimeoutError, OSError): + return None + + +def _detect_backend(base_url: str) -> str: + if ":11434" in base_url: + return "ollama" + if ":8001" in base_url: + return "llamacpp" + return "unknown" + + +async def _llamacpp_running(container: str = "va_llm") -> bool: + out = await _run(["docker", "ps", "--filter", f"name=^{container}$", "--format", "{{.Names}}"]) + return bool(out and container in out) + + +async def _ollama_loaded() -> tuple[bool, list[dict]]: + """(dienst_erreichbar, [geladene Modelle]).""" + out = await _run(["ollama", "ps"]) + if out is None: + return False, [] + models: list[dict] = [] + lines = [ln for ln in out.splitlines() if ln.strip()] + for ln in lines[1:]: # Kopfzeile ueberspringen + # Spalten sind durch 2+ Leerzeichen getrennt: NAME ID SIZE PROCESSOR CONTEXT UNTIL + import re + cols = re.split(r"\s{2,}", ln.strip()) + if cols: + models.append({ + "name": cols[0], + "size": cols[2] if len(cols) > 2 else "", + "processor": cols[3] if len(cols) > 3 else "", + }) + return True, models + + +async def _gpus() -> list[dict]: + out = await _run([ + "nvidia-smi", + "--query-gpu=index,memory.used,memory.total", + "--format=csv,noheader,nounits", + ]) + if out is None: + return [] + gpus: list[dict] = [] + for ln in out.splitlines(): + parts = [p.strip() for p in ln.split(",")] + if len(parts) == 3 and parts[0].isdigit(): + used, total = int(parts[1]), int(parts[2]) + gpus.append({ + "index": int(parts[0]), + "used_mib": used, + "total_mib": total, + "percent": round(used / total * 100) if total else 0, + }) + return gpus + + +async def _gateway_service_active() -> bool: + out = await _run(["systemctl", "--user", "is-active", "voice-assistant.service"], timeout=4.0) + return bool(out and out.strip() == "active") + + +async def llm_status() -> dict: + """Aggregierter, read-only LLM-/System-Status fuer das Admin-Panel.""" + base_url = settings.local_llm_base_url + backend = _detect_backend(base_url) + llamacpp, (ollama_reachable, ollama_models), gpus, gw = await asyncio.gather( + _llamacpp_running(), + _ollama_loaded(), + _gpus(), + _gateway_service_active(), + ) + return { + "backend": backend, + "model": settings.local_llm_model, + "base_url": base_url, + "llamacpp_running": llamacpp, + "ollama_reachable": ollama_reachable, + "ollama_loaded": ollama_models, + "gpus": gpus, + "gateway_service_active": gw, + } diff --git a/app/api/admin.py b/app/api/admin.py index ece441d..a862d5a 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSo from fastapi.responses import FileResponse from pydantic import BaseModel +from app.admin_llm import llm_status from app.auth import is_admin_user, require_admin, require_admin_or_user from app.config import settings from app.dependencies import get_store @@ -187,6 +188,14 @@ async def export_db(): ) +# ── LLM-/System-Status (read-only) ────────────────────────────────────────── + +@router.get("/admin/llm/status", dependencies=[Depends(require_admin)]) +async def get_llm_status(): + """Read-only Status: aktives Backend, Modell, GPU-Auslastung, Dienste.""" + return await llm_status() + + # ── Aussprache-Lexikon CRUD ───────────────────────────────────────────────── def _read_pronunciation(lang: str) -> dict: diff --git a/app/web/app.js b/app/web/app.js index f04bd9f..f330bf6 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -956,13 +956,56 @@ async function loadEmergencyEvents() { // ════════════════════════════════════════ // TAB: STATUS // ════════════════════════════════════════ +// LLM-/GPU-Status-Karte (read-only) für den Status-Tab. +function renderLlmCard(llm) { + if (!llm) return ""; + const backendLabel = { ollama: "Ollama", llamacpp: "llama.cpp", unknown: "—" }[llm.backend] || llm.backend; + const dot = (on) => ``; + const row = (label, val) => + `
${label}
+
${escHtml(val ?? "—")}
`; + + const loaded = (llm.ollama_loaded || []).map((m) => + `${escHtml(m.name)} (${escHtml(m.processor || "")})` + ).join(", ") || "—"; + + const gpuBars = (llm.gpus || []).map((g) => ` +
+ GPU ${g.index} +
+
+
+ ${g.used_mib} / ${g.total_mib} MiB +
`).join("") || '

keine GPU-Daten (nvidia-smi)

'; + + return ` +
+
+ ${dot(true)}

LLM-Backend

+ ${escHtml(backendLabel)} +
+
+ ${row("Modell", llm.model)} + ${row("URL", llm.base_url)} + ${row("Ollama erreichbar", llm.ollama_reachable ? "ja" : "nein")} + ${row("llama.cpp läuft", llm.llamacpp_running ? "ja" : "nein")} + ${row("Geladene Modelle", loaded)} + ${row("Gateway-Dienst", llm.gateway_service_active ? "aktiv (systemd)" : "Vordergrund/aus")} +
+
+ ${gpuBars} +
+
`; +} + async function loadStatus() { const container = $("#status-content"); container.innerHTML = '

lade …

'; try { - const [cfg, met] = await Promise.all([ + const [cfg, met, llm] = await Promise.all([ fetch("/api/config").then((r) => r.json()), fetch("/api/metrics").then((r) => r.json()), + adminFetch("/api/admin/llm/status"), ]); const route = cfg.default_route || {}; const counters = met.counters || {}; @@ -1000,6 +1043,8 @@ async function loadStatus() { + ${renderLlmCard(llm)} +

Laufzeit-Metriken

diff --git a/app/web/index.html b/app/web/index.html index f17d0f3..f715f87 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -250,6 +250,6 @@
- + diff --git a/tests/test_admin_llm_status.py b/tests/test_admin_llm_status.py new file mode 100644 index 0000000..fd7843c --- /dev/null +++ b/tests/test_admin_llm_status.py @@ -0,0 +1,41 @@ +"""Tests für den read-only LLM-/System-Status (Admin-Panel). + +Getestet: GET /api/admin/llm/status — Auth-Gate + Antwortschema. Die Status-Helfer +rufen externe Tools (docker/ollama/nvidia-smi/systemctl) auf; fehlen sie in der +Testumgebung, liefern sie sichere Defaults -> der Endpunkt bleibt 200. +""" + +import pytest +from fastapi.testclient import TestClient + +from app.main import app +from app.config import settings + +client = TestClient(app) +ADMIN = "test-admin-key" +ADM_HDR = {"X-Admin-Key": ADMIN} + + +@pytest.fixture(autouse=True) +def _setup(monkeypatch): + monkeypatch.setattr(settings, "admin_api_key", ADMIN) + monkeypatch.setattr(settings, "auth_enabled", False) + yield + + +def test_status_requires_admin(): + # Ohne Admin-Key (Auth aus -> anonym, kein Admin) -> 401. + assert client.get("/api/admin/llm/status").status_code == 401 + + +def test_status_schema(): + resp = client.get("/api/admin/llm/status", headers=ADM_HDR) + assert resp.status_code == 200 + data = resp.json() + for key in ("backend", "model", "base_url", "llamacpp_running", + "ollama_reachable", "ollama_loaded", "gpus", "gateway_service_active"): + assert key in data, key + assert isinstance(data["ollama_loaded"], list) + assert isinstance(data["gpus"], list) + # backend wird aus der base_url abgeleitet. + assert data["backend"] in ("ollama", "llamacpp", "unknown")