feat(admin): Backend-Wechsel + Gateway-Neustart aus dem Admin-Panel — Plan-Schritt 3

- admin_llm: switch_backend() mit strikter Allowlist (backend ∈ {ollama,llamacpp},
  Modell gegen 'ollama list' + Format-Regex), detached (Self-Restart-sicher),
  niemals shell=True. restart_gateway_detached() für systemd-User-Dienst.
- switch-llm.sh: flock-Lock gegen parallele Backend-Wechsel (Exit 75).
- Endpunkte POST /api/admin/llm/backend (422 bei ungültig) und
  POST /api/admin/gateway/restart (require_admin).
- Status-Tab: Steuerung (Backend-Dropdown + Modell, Wechseln/Neustart) mit
  Poll bis das Gateway wieder antwortet; Hinweis auf systemd-Voraussetzung.
- Tests: Auth + Allowlist (Shell-Metazeichen/unbekanntes Modell -> 422). 165 grün.
- Doku §7.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-20 18:39:59 +02:00
commit 5b6f0f8ef2
7 changed files with 219 additions and 6 deletions

View file

@ -995,9 +995,66 @@ function renderLlmCard(llm) {
<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++) {
await new Promise((r) => setTimeout(r, 1500));
try {
const r = await fetch("/api/config", { cache: "no-store" });
if (r.ok) { if (msgEl) msgEl.textContent = label + " ✓"; loadStatus(); return; }
} catch (e) { /* noch nicht oben */ }
if (msgEl) msgEl.textContent = label + " … (" + (i + 1) + ")";
}
if (msgEl) msgEl.textContent = label + " — Zeitüberschreitung, bitte Status manuell prüfen.";
}
function wireLlmControls() {
const sel = $("#llm-backend-sel");
const switchBtn = $("#llm-switch-btn");
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");
});
restartBtn.addEventListener("click", async () => {
if (!confirm("Gateway-Dienst jetzt neu starten?")) return;
restartBtn.disabled = true;
msg.textContent = "starte neu …";
const res = await adminFetch("/api/admin/gateway/restart", "POST");
if (res === null) { msg.textContent = "Fehler beim Neustart"; restartBtn.disabled = false; return; }
pollGatewayBack(msg, "Neu gestartet");
});
}
async function loadStatus() {
const container = $("#status-content");
container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
@ -1074,6 +1131,7 @@ async function loadStatus() {
</a>
</div>
`;
wireLlmControls(); // 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>';