Mission Control v2 – Schritt 1: SoC-Refactor + Design 2.0

Architektur auf Separation of Concerns umgestellt – ohne Build-Schritt,
ohne neues Framework, ohne DB (KISS bleibt). Endpoint-URLs unveraendert,
daher 1:1-kompatibel zum bisherigen Stand.

Backend (Top-Level-Helfer + ein Router je Bereich):
- app.py auf duennen Einstieg reduziert (FastAPI + include_router + static)
- config/auth/jobengine/llamaswap als getrennte Helfer-Module
- Endpoints in routers/{models,jobs,maintenance}.py

Frontend (native ES-Module statt Single-File):
- index.html = Huelle: Sidebar-Nav, Topbar, Alert-Banner, Hash-Routing
- css/{base,components}.css – Tokens + Komponenten
- js/core/{api,ui,nav}.js + js/panels/{overview,models,maintenance,jobs}.js + main.js
- Panel-Vertrag: { id, mount?(), onStatus?(s), onJobs?(jobs) }
- Optik an docs/mission-control-overview.png angelehnt (Hero, KPI-Kacheln,
  Listen, Aktivitaets-Stream, getoente Karten)

Doku: CLAUDE.md + README auf die neue Struktur aktualisiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-20 20:46:45 +02:00
parent e46380d99f
commit 364939466f
23 changed files with 1218 additions and 486 deletions
+123
View File
@@ -0,0 +1,123 @@
// models.js — "Modelle"-Ansicht: Download + Einpflegen, Schnelltest-Chat, Modell-Tabelle.
import { api } from "../core/api.js";
import { $, badge, esc, toast } from "../core/ui.js";
import { track } from "./jobs.js";
function refreshSoon() { document.dispatchEvent(new Event("mc:refresh")); }
function mount() {
$("#m-download").innerHTML = `
<div class="card-h"><h3>Modell holen</h3></div>
<label>HuggingFace-Repo</label>
<input id="dl-repo" placeholder="unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF">
<label>Datei (GGUF)</label>
<input id="dl-file" placeholder="Q4_K_M/Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf">
<button class="primary" id="dl-btn">Modell herunterladen</button>
<div id="register-box" style="display:none;margin-top:16px;border-top:1px solid var(--line);padding-top:14px">
<div class="card-h"><h3>Einpflegen</h3></div>
<div class="row">
<div><label>Alias</label><input id="rg-alias"></div>
<div><label>Kontext</label><input id="rg-ctx" value="8192"></div>
</div>
<input id="rg-path" class="mono-sm" readonly>
<button class="primary" id="rg-btn">In Config eintragen</button>
</div>`;
$("#m-chat").innerHTML = `
<div class="card-h"><h3>Schnelltest</h3></div>
<label>Modell</label>
<select id="chat-model"></select>
<label>Nachricht</label>
<textarea id="chat-msg" placeholder="Schreib was, um ein Modell zu wecken…"></textarea>
<button class="primary" id="chat-btn">Senden</button>
<div id="chat-reply" class="reply" style="display:none"></div>`;
$("#m-table").innerHTML = `
<div class="card-h"><h3>Modelle &amp; Ports</h3><span class="meta" id="m-count"></span></div>
<table>
<thead><tr><th>Modell</th><th>Status</th><th>Port</th><th style="text-align:right">Aktion</th></tr></thead>
<tbody id="models"></tbody>
</table>
<div id="models-empty" class="empty" style="display:none">Noch keine Modelle konfiguriert — zieh dir oben eins rein. 👇</div>`;
$("#dl-btn").addEventListener("click", pull);
$("#rg-btn").addEventListener("click", register);
$("#chat-btn").addEventListener("click", sendChat);
}
function onStatus(s) {
const models = s?.models || [];
const tb = $("#models");
if (!tb) return;
tb.innerHTML = "";
$("#models-empty").style.display = models.length ? "none" : "block";
$("#m-count").textContent = models.length ? models.length + " konfiguriert" : "";
const sel = $("#chat-model");
const cur = sel.value;
sel.innerHTML = "";
for (const m of models) {
const tr = document.createElement("tr");
tr.innerHTML = `<td class="mid">${esc(m.name)}</td><td>${badge(m.state)}</td>
<td class="port">${m.port ?? "auto"}</td>
<td style="text-align:right"><button class="ghost" data-unload="${esc(m.name)}">Entladen</button></td>`;
tb.appendChild(tr);
sel.insertAdjacentHTML("beforeend", `<option>${esc(m.name)}</option>`);
}
if (cur) sel.value = cur;
tb.querySelectorAll("[data-unload]").forEach(b =>
b.addEventListener("click", () => unloadOne(b.getAttribute("data-unload")))
);
}
async function pull() {
const repo = $("#dl-repo").value.trim(), file = $("#dl-file").value.trim();
if (!repo || !file) return toast("Repo und Datei angeben.", true);
try {
const r = await api("/api/download", { method: "POST", body: JSON.stringify({ repo, file }) });
toast("Download gestartet.");
const stem = file.split("/").pop().replace(/\.gguf$/i, "");
$("#rg-alias").value = stem;
$("#rg-path").value = r.expected_path;
$("#register-box").style.display = "block";
track(r.job_id);
} catch (e) { toast(e.message, true); }
}
async function register() {
const alias = $("#rg-alias").value.trim();
const model_path = $("#rg-path").value;
const ctx = parseInt($("#rg-ctx").value) || 8192;
if (!alias) return toast("Alias angeben.", true);
try {
await api("/api/register", { method: "POST", body: JSON.stringify({ alias, model_path, ctx }) });
toast("Eingepflegt — llama-swap lädt neu.");
refreshSoon();
} catch (e) { toast(e.message, true); }
}
async function unloadOne(m) {
try {
await api("/api/unload?model=" + encodeURIComponent(m), { method: "POST" });
toast("Entladen: " + m);
setTimeout(refreshSoon, 600);
} catch (e) { toast(e.message, true); }
}
async function sendChat() {
const model = $("#chat-model").value, message = $("#chat-msg").value.trim();
if (!model) return toast("Kein Modell vorhanden.", true);
if (!message) return;
const btn = $("#chat-btn"); btn.disabled = true; btn.textContent = "…";
const box = $("#chat-reply"); box.style.display = "block";
box.textContent = "(wecke Modell, kann beim Swap kurz dauern…)";
try {
const r = await api("/api/chat", { method: "POST", body: JSON.stringify({ model, message }) });
box.textContent = r.reply;
} catch (e) { box.textContent = "Fehler: " + e.message; }
btn.disabled = false; btn.textContent = "Senden";
refreshSoon();
}
export default { id: "models", mount, onStatus };