feat(admin): LLM-/GPU-Status-Karte (read-only) — Plan-Schritt 2
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
bd20a308de
commit
97ae0a5d34
6 changed files with 216 additions and 3 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
115
app/admin_llm.py
Normal file
115
app/admin_llm.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) => `<span class="h-2.5 w-2.5 rounded-full ${on ? "bg-emerald-500" : "bg-slate-400"}"></span>`;
|
||||
const row = (label, val) =>
|
||||
`<dt class="text-slate-500 dark:text-slate-400">${label}</dt>
|
||||
<dd class="font-mono text-xs text-slate-700 dark:text-slate-300">${escHtml(val ?? "—")}</dd>`;
|
||||
|
||||
const loaded = (llm.ollama_loaded || []).map((m) =>
|
||||
`${escHtml(m.name)} <span class="text-slate-400">(${escHtml(m.processor || "")})</span>`
|
||||
).join(", ") || "—";
|
||||
|
||||
const gpuBars = (llm.gpus || []).map((g) => `
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-10 shrink-0 text-xs text-slate-500 dark:text-slate-400">GPU ${g.index}</span>
|
||||
<div class="flex-1 h-3 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div class="h-full ${g.percent > 85 ? "bg-red-500" : "bg-blue-500 dark:bg-blue-600"} rounded-full" style="width:${g.percent}%"></div>
|
||||
</div>
|
||||
<span class="w-28 text-right text-xs font-mono text-slate-500 dark:text-slate-400">${g.used_mib} / ${g.total_mib} MiB</span>
|
||||
</div>`).join("") || '<p class="text-xs text-slate-400">keine GPU-Daten (nvidia-smi)</p>';
|
||||
|
||||
return `
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
${dot(true)}<h3 class="font-semibold text-sm">LLM-Backend</h3>
|
||||
<span class="text-xs rounded-full bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 px-2 py-0.5 font-medium">${escHtml(backendLabel)}</span>
|
||||
</div>
|
||||
<dl class="grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm mb-3">
|
||||
${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")}
|
||||
</dl>
|
||||
<div class="space-y-2 pt-2 border-t border-slate-100 dark:border-slate-700">
|
||||
${gpuBars}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
const container = $("#status-content");
|
||||
container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
|
||||
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() {
|
|||
</dl>
|
||||
</div>
|
||||
|
||||
${renderLlmCard(llm)}
|
||||
|
||||
<!-- Laufzeit-Metriken -->
|
||||
<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-3">Laufzeit-Metriken</h3>
|
||||
|
|
|
|||
|
|
@ -250,6 +250,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=28"></script>
|
||||
<script src="/app.js?v=29"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
41
tests/test_admin_llm_status.py
Normal file
41
tests/test_admin_llm_status.py
Normal file
|
|
@ -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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue