fix(admin): Phase 0 — Log & Restart reparieren, Status auf Wahrheit
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 <noreply@anthropic.com>
This commit is contained in:
parent
7a0cf0abb4
commit
f3f9151894
4 changed files with 58 additions and 90 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
102
app/web/app.js
102
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) => `<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>
|
||||
|
||||
<!-- Steuerung (schreibend) -->
|
||||
<div class="mt-4 pt-3 border-t border-slate-100 dark:border-slate-700 flex flex-wrap items-center gap-2">
|
||||
<select id="llm-backend-sel" class="text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5">
|
||||
<option value="ollama" ${llm.backend === "ollama" ? "selected" : ""}>Ollama</option>
|
||||
<option value="llamacpp" ${llm.backend === "llamacpp" ? "selected" : ""}>llama.cpp</option>
|
||||
</select>
|
||||
<input id="llm-model-inp" type="text" placeholder="Modell (Ollama)" value="${escHtml(llm.backend === "ollama" ? llm.model : "")}"
|
||||
class="text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5 flex-1 min-w-32 font-mono" />
|
||||
<button id="llm-switch-btn" class="rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm px-3 py-1.5 font-medium transition-colors">Backend wechseln</button>
|
||||
<button id="gw-restart-btn" class="rounded-lg border border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 text-sm px-3 py-1.5 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors">Gateway neu starten</button>
|
||||
<span id="llm-ctrl-msg" class="text-xs text-slate-400"></span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-slate-400">Hinweis: Wirkt vollständig nur, wenn das Gateway als systemd-Dienst läuft (sonst .env/Backend umgestellt, aber Gateway manuell neu starten).</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 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 = '<p class="text-sm text-slate-400">lade …</p>';
|
||||
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() {
|
|||
<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">Provider (Standard-Route)</h3>
|
||||
<dl class="grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm">
|
||||
${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)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
${renderLlmCard(llm)}
|
||||
<!-- Wartung -->
|
||||
<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-1">Wartung</h3>
|
||||
<p class="text-xs text-slate-400 mb-3">Startet den Gateway-Dienst neu (z. B. nach Einstellungen, die einen Neustart erfordern).</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button id="gw-restart-btn" class="rounded-lg border border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 text-sm px-3 py-1.5 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors">Gateway neu starten</button>
|
||||
<span id="llm-ctrl-msg" class="text-xs text-slate-400"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Laufzeit-Metriken -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
|
|
@ -1456,7 +1396,7 @@ async function loadStatus() {
|
|||
</a>
|
||||
</div>
|
||||
`;
|
||||
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 = '<p class="text-sm text-red-500">Fehler beim Laden.</p>';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue