From f3f91518945e18711804cae95f907a87368ddf25 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Thu, 25 Jun 2026 02:27:18 +0200 Subject: [PATCH] =?UTF-8?q?fix(admin):=20Phase=200=20=E2=80=94=20Log=20&?= =?UTF-8?q?=20Restart=20reparieren,=20Status=20auf=20Wahrheit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wurzel-Ursache: Dienst läuft als System-Dienst, Code nutzte aber systemctl/journalctl --user → Log leer, Restart wirkungslos. - Log-WS (admin.py): --user → System-journalctl. Erfordert voice in Gruppe systemd-journal (siehe DEPLOYMENT.md 2.7.1). - Restart (admin_llm.py): --user → sudo -n systemctl restart über eng begrenzte sudoers-Regel; is-active ebenfalls auf System-Dienst. - Status (app.js): irrelevante Ollama/llama.cpp-Karte + Backend-Wechsel entfernt; zeigt jetzt echtes aktives LLM-Modell (aus Runtime-Config); Neustart in eigener "Wartung"-Karte (zieht in Phase 3 nach Konfiguration). - DEPLOYMENT.md 2.7.1: sudoers-Regel + journal-Gruppe dokumentiert. Live verifiziert: Log lesbar, Restart ohne Passwort, /health nach 3s zurück. Tests: 214 passed. Co-Authored-By: Claude Sonnet 4.6 --- DEPLOYMENT.md | 26 ++++++++++++ app/admin_llm.py | 14 ++++--- app/api/admin.py | 6 +-- app/web/app.js | 102 ++++++++++------------------------------------- 4 files changed, 58 insertions(+), 90 deletions(-) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index b5ac8ab..cab4ae8 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -932,6 +932,32 @@ sudo systemctl daemon-reload sudo systemctl enable --now voice-assistant ``` +#### 2.7.1 Admin-Rechte: Log lesen & Gateway neu starten + +Der Dienst läuft als **System**-Dienst unter dem User `voice`. Damit das Admin-Panel +(a) den Live-Log anzeigen und (b) das Gateway neu starten kann, braucht `voice` zwei +eng begrenzte Rechte. **Ohne diese beiden Schritte bleiben der Log-Tab leer und der +Neustart-Button wirkungslos.** + +```bash +# (a) Log lesen: voice darf das System-Journal lesen +sudo usermod -aG systemd-journal voice + +# (b) Neustart: voice darf AUSSCHLIESSLICH den eigenen Dienst neu starten +sudo tee /etc/sudoers.d/voice-assistant > /dev/null <<'SUDO' +# Erlaubt dem voice-User ausschließlich den Neustart des eigenen Dienstes +# (für den "Gateway neu starten"-Button im Admin-Panel). Sonst nichts. +voice ALL=(root) NOPASSWD: /usr/bin/systemctl restart voice-assistant.service +SUDO +sudo chmod 0440 /etc/sudoers.d/voice-assistant +sudo visudo -cf /etc/sudoers.d/voice-assistant # validieren + +# Dienst neu starten, damit der Prozess die neue Gruppe übernimmt +sudo systemctl restart voice-assistant +``` + +Rückgängig: `sudo gpasswd -d voice systemd-journal` bzw. `sudo rm /etc/sudoers.d/voice-assistant`. + ### 2.8 nginx-Konfiguration `/etc/nginx/sites-available/voice.jamulix.de` **Wichtig:** `X-Forwarded-For` muss leer gesetzt werden (`""`), da uvicorn diff --git a/app/admin_llm.py b/app/admin_llm.py index 8cb21cc..76f72a6 100644 --- a/app/admin_llm.py +++ b/app/admin_llm.py @@ -1,6 +1,6 @@ """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`. +Status-Abfragen sind read-only: `docker ps`, `ollama ps`, `nvidia-smi`, `systemctl is-active`. Fehlende Tools oder Fehler fuehren zu sicheren Defaults (None/[]), nie zu Exceptions. """ @@ -97,7 +97,7 @@ async def _gpus() -> list[dict]: async def _gateway_service_active() -> bool: - out = await _run(["systemctl", "--user", "is-active", "voice-assistant.service"], timeout=4.0) + out = await _run(["systemctl", "is-active", "voice-assistant.service"], timeout=4.0) return bool(out and out.strip() == "active") @@ -177,11 +177,13 @@ async def switch_backend(backend: str, model: str | None = None) -> dict: def restart_gateway_detached() -> dict: - """Startet das Gateway als systemd-User-Dienst neu (losgelöst, Self-Restart-sicher).""" - xdg = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}") + """Startet das Gateway (System-Dienst) neu — losgelöst, Self-Restart-sicher. + + Nutzt eine eng begrenzte sudo-Regel (/etc/sudoers.d/voice-assistant): + `voice ALL=(root) NOPASSWD: /usr/bin/systemctl restart voice-assistant.service`. + """ subprocess.Popen( - ["bash", "-c", - f"sleep 1; XDG_RUNTIME_DIR={xdg} systemctl --user restart voice-assistant.service"], + ["bash", "-c", "sleep 1; sudo -n systemctl restart voice-assistant.service"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, diff --git a/app/api/admin.py b/app/api/admin.py index 2aa500e..cf2ed37 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -369,7 +369,7 @@ async def admin_log_ws(websocket: WebSocket, key: str | None = None): # und der Log-Tab bliebe sonst kommentarlos leer. try: check = await asyncio.create_subprocess_exec( - "systemctl", "--user", "is-active", "voice-assistant.service", + "systemctl", "is-active", "voice-assistant.service", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, ) out, _ = await check.communicate() @@ -378,13 +378,13 @@ async def admin_log_ws(websocket: WebSocket, key: str | None = None): "⚠ Der systemd-Dienst 'voice-assistant.service' ist nicht aktiv — " "die laufende Instanz wurde vermutlich manuell gestartet (uvicorn --reload). " "Live-Logs erscheinen hier nur, wenn das Gateway als Dienst läuft " - "(systemctl --user start voice-assistant.service). Manuelle Starts loggen ins Terminal." + "(sudo systemctl start voice-assistant.service). Manuelle Starts loggen ins Terminal." ) except Exception: pass proc = await asyncio.create_subprocess_exec( - "journalctl", "--user", "-f", "-u", "voice-assistant.service", + "journalctl", "-f", "-u", "voice-assistant.service", "-n", "100", "--no-pager", "-o", "short", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, diff --git a/app/web/app.js b/app/web/app.js index 7ed07cf..c94ef0c 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -1282,61 +1282,6 @@ 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} -
- - -
- - - - - -
-

Hinweis: Wirkt vollständig nur, wenn das Gateway als systemd-Dienst läuft (sonst .env/Backend umgestellt, aber Gateway manuell neu starten).

-
`; -} - // Wartet, bis das Gateway nach einem Neustart wieder antwortet, dann Status neu laden. async function pollGatewayBack(msgEl, label) { for (let i = 0; i < 40; i++) { @@ -1350,26 +1295,10 @@ async function pollGatewayBack(msgEl, label) { if (msgEl) msgEl.textContent = label + " — Zeitüberschreitung, bitte Status manuell prüfen."; } -function wireLlmControls() { - const sel = $("#llm-backend-sel"); - const switchBtn = $("#llm-switch-btn"); +function wireRestart() { const restartBtn = $("#gw-restart-btn"); const msg = $("#llm-ctrl-msg"); - if (!switchBtn) return; - - switchBtn.addEventListener("click", async () => { - const backend = sel.value; - const model = ($("#llm-model-inp").value || "").trim(); - if (!confirm(`Backend auf „${backend}"${backend === "ollama" && model ? " (" + model + ")" : ""} umstellen?\nDie GPU des anderen Backends wird freigegeben.`)) return; - switchBtn.disabled = true; - msg.textContent = "stelle um …"; - const body = { backend }; - if (backend === "ollama" && model) body.model = model; - const res = await adminFetch("/api/admin/llm/backend", "POST", body); - if (res === null) { msg.textContent = "Fehler (Modell/Backend ungültig?)"; switchBtn.disabled = false; return; } - pollGatewayBack(msg, "Backend gewechselt"); - }); - + if (!restartBtn) return; restartBtn.addEventListener("click", async () => { if (!confirm("Gateway-Dienst jetzt neu starten?")) return; restartBtn.disabled = true; @@ -1384,11 +1313,13 @@ async function loadStatus() { const container = $("#status-content"); container.innerHTML = '

lade …

'; try { - const [cfg, met, llm] = await Promise.all([ + const [cfg, met, runtimeCfg] = await Promise.all([ fetch("/api/config").then((r) => r.json()), fetch("/api/metrics").then((r) => r.json()), - adminFetch("/api/admin/llm/status"), + adminFetch("/api/admin/config"), ]); + const cfgByKey = Object.fromEntries((runtimeCfg || []).map((d) => [d.key, d.effective_value])); + const llmModel = cfgByKey["openrouter_llm_model"] || "—"; const route = cfg.default_route || {}; const counters = met.counters || {}; const turnsTotal = counters["turns_total"] || 0; @@ -1418,14 +1349,23 @@ async function loadStatus() {

Provider (Standard-Route)

- ${providerRow("STT", route.stt_provider)} - ${providerRow("LLM", route.llm_provider)} - ${providerRow("TTS", route.tts_provider)} - ${providerRow("Sprache", route.language)} + ${providerRow("STT", route.stt_provider)} + ${providerRow("LLM", route.llm_provider)} + ${providerRow("LLM-Modell", llmModel)} + ${providerRow("TTS", route.tts_provider)} + ${providerRow("Sprache", route.language)}
- ${renderLlmCard(llm)} + +
+

Wartung

+

Startet den Gateway-Dienst neu (z. B. nach Einstellungen, die einen Neustart erfordern).

+
+ + +
+
@@ -1456,7 +1396,7 @@ async function loadStatus() {
`; - wireLlmControls(); // Buttons der LLM-Karte verdrahten (nach dem Rendern) + wireRestart(); // Buttons der LLM-Karte verdrahten (nach dem Rendern) } catch (e) { console.error("loadStatus error:", e); container.innerHTML = '

Fehler beim Laden.

';