This repository has been archived on 2026-07-22. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
mission-control/static/js/panels/server.js
T
Hitonabi 82d15d82db fix(phase-a): Kontext-Cap, Download-State, Recipe-Edit, OS-Badge, Swap-Flash
A1: 32768-Kontext-Cap entfernt (install-recipe, install-model, register) →
    max_ctx_for() liefert nun bis zu 128k auf Strix Halo; behebt "context
    size exceeded" bei externen Tools.
A2: Download-State jetzt im Status-Endpoint sichtbar: Modelle zeigen
    "↓ Download X%" statt "bereit" während Job läuft (Backend + Frontend).
A3: PUT /api/cookbook/user-recipe/{id} + Edit-Button (✎) für eigene Setups.
    Download-Modal setzt Kontext-Input automatisch auf optimal.
A4: /api/updates liefert apt_cache_age_h; Badge zeigt Tooltip + ⚠ wenn >24h.
A5: Swap-Flash: Topbar-Text pulst kurz teal wenn Modell den State wechselt.
A6: LLM-Engine-Update fragt jetzt per confirmModal nach (Konsistenz).
A7: Event-Delegation statt per-render addEventListener in models.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 21:33:03 +02:00

243 lines
14 KiB
JavaScript

// server.js — Server & Wartung (v3): Dienste, OS-Updates, Reboot, Live-Konsole.
// Selbsterklärende Aktionszeilen + heikle Aktionen mit Klartext-Bestätigung.
import { api, getToken } from "../core/api.js";
import { $, esc, toast, icon, confirmModal, promptModal } from "../core/ui.js";
import { track } from "./jobs.js";
function refreshSoon() { document.dispatchEvent(new Event("mc:refresh")); }
function row(id, ic, tone, title, desc) {
return `<button class="qa${tone === "red" ? " qa-danger" : ""}" id="${id}">
<span class="qa-ic ${tone}">${icon(ic)}</span>
<span class="qa-main"><span class="qa-t">${title}</span><span class="qa-s">${desc}</span></span>
<span class="qa-arrow">${icon("chevron")}</span></button>`;
}
function mount() {
$("#wartung").outerHTML = `<div id="wartung" style="display:flex;flex-direction:column;gap:var(--sp-4)">
<div class="pagehead"><div>
<h1>Server &amp; Wartung</h1>
<div class="sub">Dienste steuern, Updates einspielen und live mitlesen — ganz ohne SSH oder Terminal.</div>
</div></div>
<div class="card" id="w-swap-card">
<div class="card-h"><h3>LLM-Engine (llama-swap)</h3>
<span class="chip" id="w-swap-state" style="margin-left:auto">prüfe…</span></div>
<div class="card-sub">Das Herzstück: llama-swap lädt bei jeder Anfrage automatisch das passende Modell und entlädt die anderen — es läuft immer nur <b>eines</b> gleichzeitig.</div>
<div class="kv" style="margin-bottom:6px">
<div class="kv-row"><span class="kv-k">Adresse (für deine Tools)</span><span class="kv-v mono-sm" id="w-swap-url">—</span></div>
<div class="kv-row"><span class="kv-k">Aktuell geladen</span><span class="kv-v" id="w-swap-loaded">—</span></div>
<div class="kv-row"><span class="kv-k">Konfigurierte Modelle</span><span class="kv-v" id="w-swap-count">—</span></div>
</div>
<div class="hint" style="margin:8px 0 12px"><b>Wie ansteuern?</b> In deinem Tool (Zed/OpenCode) die obige Adresse als OpenAI-Basis-URL eintragen und einen <b>Modellnamen oder eine Rolle</b> angeben (siehe „Verbinden"). Eine einzige Anfrage genügt — das Modell wird automatisch geweckt.</div>
<div class="btn-row" style="display:flex;align-items:center;gap:10px"><button class="ghost" id="w-swap-check">Verbindung prüfen</button><span id="w-swap-check-res" class="mono-sm"></span></div>
</div>
<div class="card">
<div class="card-h"><h3>Dienste &amp; Applikation</h3></div>
<div class="card-sub">Diese Aktionen sind harmlos und passwortlos — es geht nichts dabei verloren.</div>
${row("w-restart-swap", "refresh", "teal", "LLM-Engine neustarten", "Startet llama-swap neu. Geladene Modelle werden kurz entladen (~5 Sek), laden danach automatisch wieder.")}
${row("w-restart-mc", "refresh", "blue", "Dashboard neustarten", "Startet diese Oberfläche neu. Sie trennt kurz und verbindet automatisch wieder.")}
${row("w-self-update", "download", "teal", "Mission Control aktualisieren", "Holt die neueste Dashboard-Version aus Gitea und startet automatisch neu (~10 Sek).")}
${row("w-update", "bolt", "blue", "LLM-Engine aktualisieren", "Lädt die neueste llama.cpp-Version (ROCm) für deine GPU und installiert sie.")}
${row("w-unload", "database", "amber", "Grafikspeicher leeren", "Wirft alle aktuell geladenen Modelle aus dem VRAM. Fragt vorher nach.")}
</div>
<div class="card">
<div class="card-h"><h3>Betriebssystem (Bosgame)</h3></div>
<div class="card-sub">Tiefe Eingriffe — das Dashboard fragt einmalig nach deinem sudo-Passwort.</div>
${row("w-os-update", "refresh", "amber", "OS-Updates installieren", "Führt apt update &amp; upgrade aus. Kann ein paar Minuten dauern — Fortschritt in der Aktivität.")}
${row("w-reboot", "bolt", "red", "Server neustarten (Reboot)", "Bootet den ganzen Bosgame neu. Alles ist für ~1 Minute offline, auch dieses Dashboard.")}
</div>
<div class="card" id="w-update-card" style="display:none">
<div class="card-h"><h3>Aktueller Vorgang</h3>
<button class="ghost" id="w-update-cancel" style="margin-left:auto;display:none;padding:3px 12px">Abbrechen</button>
<span id="w-update-badge" style="margin-left:10px"></span></div>
<div class="card-sub" id="w-update-label">—</div>
<div id="w-update-bar" class="bar" style="display:none;margin-bottom:10px"><i style="width:0%"></i></div>
<div id="w-update-log" class="log" style="max-height:280px"></div>
</div>
<div class="card">
<div class="card-h"><h3>Live-Konsole</h3>
<select id="w-console-sel" style="margin:0 0 0 auto;width:240px">
<option value="llama-swap">LLM-Engine (llama-swap)</option>
<option value="mission-control">Dashboard (mission-control)</option>
<option value="system">System (ganzer Server)</option>
</select>
</div>
<div class="card-sub">Live mitlesen, was der Dienst gerade tut.</div>
<div id="w-console" class="console">Verbinde…</div>
</div>
</div>`;
$("#w-restart-swap").addEventListener("click", () => restartService("llama-swap",
"LLM-Engine neustarten?", "Die Engine startet neu. Geladene Modelle werden kurz entladen (~5 Sekunden), laden danach automatisch wieder."));
$("#w-restart-mc").addEventListener("click", () => restartService("mission-control",
"Dashboard neustarten?", "Diese Oberfläche trennt sich kurz und verbindet automatisch wieder. Laufende Downloads laufen weiter."));
$("#w-self-update").addEventListener("click", selfUpdate);
$("#w-update").addEventListener("click", update);
$("#w-unload").addEventListener("click", unloadAll);
$("#w-os-update").addEventListener("click", osUpdate);
$("#w-reboot").addEventListener("click", rebootServer);
$("#w-console-sel").addEventListener("change", connectConsole);
$("#w-update-cancel").addEventListener("click", cancelActive);
$("#w-swap-check").addEventListener("click", checkSwap);
if (lastStatus) renderSwap(lastStatus);
connectConsole();
}
// ---- llama-swap-Transparenz ----
let lastStatus = null;
function renderSwap(s) {
const chip = $("#w-swap-state"); if (!chip) return;
const ok = !!s?.swap_ok;
chip.textContent = ok ? "● erreichbar" : "● offline";
chip.style.cssText = "margin-left:auto;" + (ok
? "background:rgba(63,185,80,.12);border-color:rgba(63,185,80,.25);color:#7ee29a"
: "background:rgba(248,81,73,.12);border-color:rgba(248,81,73,.3);color:#ff9b95");
$("#w-swap-url").textContent = (s?.swap_url || "—") + "/v1";
const models = (s?.models || []);
const loaded = models.find(m => m.state === "running" || m.state === "loading");
$("#w-swap-loaded").innerHTML = loaded
? `<b>${esc(loaded.name)}</b> <span class="text-mut">(${esc(loaded.state)})</span>`
: `<span class="text-mut">nichts geladen — lädt bei der nächsten Anfrage</span>`;
const real = models.filter(m => !m.incomplete).length;
$("#w-swap-count").textContent = real ? `${real} bereit` : "keine";
}
async function checkSwap() {
const res = $("#w-swap-check-res"); res.textContent = "prüfe…"; res.style.color = "var(--mut)";
try {
const r = await api("/api/integration/test");
if (r.ok) { res.style.color = "#7ee29a"; res.textContent = `✓ Engine antwortet — ${r.models.length} Modell(e): ${r.models.join(", ")}`; }
else { res.style.color = "#ff9b95"; res.textContent = "✗ keine Verbindung: " + r.error; }
} catch (e) { res.style.color = "#ff9b95"; res.textContent = "✗ " + e.message; }
}
function onStatus(s) { lastStatus = s; renderSwap(s); }
async function cancelActive() {
if (!activeJob) return;
if (!await confirmModal({ title: activeJob.label + " abbrechen?", body: "Der laufende Vorgang wird gestoppt. Bei einem Download bleibt eine unvollständige Datei zurück (kannst du später neu starten).", confirmLabel: "Abbrechen", danger: true })) return;
try { await api(`/api/jobs/${activeJob.id}/cancel`, { method: "POST" }); toast("Abbruch angefordert…"); }
catch (e) { toast(e.message, true); }
}
let ws = null;
function connectConsole() {
if (ws) { ws.close(); ws = null; }
const svc = $("#w-console-sel").value, out = $("#w-console");
out.textContent = "Verbinde mit " + svc + "…\n";
const proto = location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${proto}//${location.host}/api/logs/${svc}?token=${encodeURIComponent(getToken())}`);
ws.onmessage = e => { out.textContent += e.data; out.scrollTop = out.scrollHeight; };
ws.onclose = () => { out.textContent += "\n— Verbindung getrennt —"; };
}
async function restartService(name, title, body) {
if (!await confirmModal({ title, body, confirmLabel: "Neustarten" })) return;
try { await api(`/api/service/${name}/restart`, { method: "POST" }); toast("Neustart ausgelöst: " + name); setTimeout(refreshSoon, 2000); }
catch (e) { toast(e.message, true); }
}
async function unloadAll() {
if (!await confirmModal({ title: "Grafikspeicher leeren?", body: "Alle geladenen Modelle werden entladen. Sie laden beim nächsten Aufruf automatisch neu — es geht nichts verloren.", confirmLabel: "Leeren" })) return;
try { await api("/api/unload", { method: "POST" }); toast("Grafikspeicher geleert."); setTimeout(refreshSoon, 600); }
catch (e) { toast(e.message, true); }
}
async function selfUpdate() {
if (!await confirmModal({ title: "Mission Control aktualisieren?", body: "Holt die neueste Dashboard-Version aus Gitea und startet automatisch neu. Die Seite trennt kurz und verbindet von selbst wieder (~10 Sek)." })) return;
try { const r = await api("/api/self-update", { method: "POST" }); toast("Update läuft — Dashboard startet gleich neu."); track(r.job_id); }
catch (e) { toast(e.message, true); }
}
// ---- Live-Verlauf laufender Vorgaenge (Updates) direkt auf der Server-Seite ----
let activeJob = null; // { id, label }
const notified = new Set(); // Job-IDs, fuer die schon eine Abschluss-Meldung kam
function watchJob(id, label) {
activeJob = { id, label };
const card = $("#w-update-card");
if (!card) return;
card.style.display = "";
$("#w-update-label").textContent = label + " läuft…";
$("#w-update-badge").innerHTML = '<span class="badge b-load">läuft…</span>';
$("#w-update-log").textContent = "Starte…";
card.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
function onJobs(jobs) {
if (!activeJob) return;
const j = (jobs || []).find(x => x.id === activeJob.id);
const card = $("#w-update-card");
if (!j || !card) return;
card.style.display = "";
const logEl = $("#w-update-log");
if (logEl) { logEl.textContent = (j.log || []).join("\n"); logEl.scrollTop = logEl.scrollHeight; }
const done = j.state === "done", failed = j.state === "failed", canceled = j.state === "canceled";
const terminal = done || failed || canceled;
const badge = $("#w-update-badge");
if (badge) badge.innerHTML = done ? '<span class="badge b-run">✓ fertig</span>'
: failed ? '<span class="badge b-err">✗ fehlgeschlagen</span>'
: canceled ? '<span class="badge">abgebrochen</span>'
: '<span class="badge b-load">läuft…</span>';
// Fortschrittsbalken: exakter Backend-Wert (Download) bevorzugt, sonst %-Angabe im Log.
let pval = null;
if (!terminal) {
if (typeof j.progress === "number") {
pval = j.progress;
} else {
const last = (j.log || [])[j.log.length - 1] || "";
const pm = last.match(/(\d+(?:\.\d+)?)\s*%/);
if (pm) pval = parseFloat(pm[1]);
}
}
const bar = $("#w-update-bar");
if (bar) {
if (pval != null) { bar.style.display = ""; bar.querySelector("i").style.width = Math.min(100, pval) + "%"; }
else bar.style.display = "none";
}
$("#w-update-cancel").style.display = terminal ? "none" : "";
if (terminal) {
$("#w-update-label").textContent = activeJob.label +
(done ? " — erfolgreich abgeschlossen." : canceled ? " — abgebrochen." : " — fehlgeschlagen (siehe Protokoll).");
if (!notified.has(j.id)) {
notified.add(j.id);
toast(done ? "✓ " + activeJob.label + " abgeschlossen."
: canceled ? activeJob.label + " abgebrochen."
: "✗ " + activeJob.label + " fehlgeschlagen.", failed);
if (done) setTimeout(refreshSoon, 800);
}
}
}
async function update() {
if (!await confirmModal({ title: "LLM-Engine aktualisieren?", body: "Lädt die neueste llama.cpp-ROCm-Version und installiert sie. Die Engine ist während des Updates kurz nicht erreichbar." })) return;
try { const r = await api("/api/update", { method: "POST" }); toast("LLM-Engine-Update läuft."); track(r.job_id); watchJob(r.job_id, "LLM-Engine-Update"); }
catch (e) { toast(e.message, true); }
}
async function osUpdate() {
if (!await confirmModal({ title: "OS-Updates installieren?", body: "Führt <code>apt update &amp; upgrade</code> aus. Das kann ein paar Minuten dauern; der Fortschritt erscheint in der Aktivität." })) return;
const pwd = await promptModal({ title: "sudo-Passwort", body: "Für die System-Updates wird einmalig dein sudo-Passwort gebraucht.", placeholder: "sudo-Passwort", password: true, confirmLabel: "Installieren" });
if (!pwd) return;
try { const r = await api("/api/os-update", { method: "POST", body: JSON.stringify({ password: pwd }) }); toast("OS-Update gestartet."); track(r.job_id); watchJob(r.job_id, "OS-Update"); }
catch (e) { toast(e.message, true); }
}
async function rebootServer() {
if (!await confirmModal({ title: "Server wirklich neu starten?", body: "Der ganze Bosgame startet physisch neu. Alles ist für ~1 Minute offline — auch dieses Dashboard.", confirmLabel: "Reboot", danger: true })) return;
const pwd = await promptModal({ title: "sudo-Passwort", body: "Für den Reboot wird einmalig dein sudo-Passwort gebraucht.", placeholder: "sudo-Passwort", password: true, confirmLabel: "Jetzt neustarten", danger: true });
if (!pwd) return;
try { await api("/api/reboot", { method: "POST", body: JSON.stringify({ password: pwd }) }); toast("Reboot ausgelöst — bis gleich."); }
catch (e) { toast(e.message, true); }
}
export default { id: "server", mount, onJobs, onStatus };