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:
+58
-227
@@ -4,245 +4,76 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Mission Control</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0d1117; --panel:#151b23; --panel2:#1b222c; --line:rgba(255,255,255,.08);
|
||||
--line2:rgba(255,255,255,.14); --tx:#d7dee7; --mut:#8b97a5;
|
||||
--on:#46c06a; --warn:#e0a32e; --err:#e5534b; --act:#4493e0;
|
||||
--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
|
||||
--sans:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--tx);font-family:var(--sans);font-size:15px;line-height:1.5}
|
||||
a{color:var(--act)}
|
||||
.wrap{max-width:1040px;margin:0 auto;padding:0 20px 64px}
|
||||
header{display:flex;align-items:center;gap:16px;padding:20px 0 18px;border-bottom:1px solid var(--line);
|
||||
position:sticky;top:0;background:var(--bg);z-index:5;flex-wrap:wrap}
|
||||
.brand{font-weight:600;letter-spacing:.2px;font-size:18px}
|
||||
.brand b{color:var(--on)}
|
||||
.pill{display:inline-flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12.5px;
|
||||
padding:5px 11px;border:1px solid var(--line);border-radius:999px;color:var(--mut);background:var(--panel)}
|
||||
.dot{width:8px;height:8px;border-radius:50%;background:var(--mut)}
|
||||
.dot.on{background:var(--on);box-shadow:0 0 0 0 rgba(70,192,106,.5);animation:pulse 2.2s infinite}
|
||||
.dot.off{background:var(--err)}
|
||||
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(70,192,106,.45)}70%{box-shadow:0 0 0 7px rgba(70,192,106,0)}100%{box-shadow:0 0 0 0 rgba(70,192,106,0)}}
|
||||
.spacer{flex:1}
|
||||
.tokin{font-family:var(--mono);font-size:12.5px;background:var(--panel);border:1px solid var(--line);
|
||||
color:var(--tx);border-radius:8px;padding:6px 9px;width:130px}
|
||||
h2{font-size:14px;font-weight:600;letter-spacing:.4px;text-transform:uppercase;color:var(--mut);margin:0 0 12px}
|
||||
.grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:22px}
|
||||
@media(max-width:780px){.grid{grid-template-columns:1fr}}
|
||||
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:18px}
|
||||
.card.full{grid-column:1/-1}
|
||||
table{width:100%;border-collapse:collapse;font-size:14px}
|
||||
th{text-align:left;font-weight:500;color:var(--mut);font-size:12px;text-transform:uppercase;
|
||||
letter-spacing:.4px;padding:0 10px 9px}
|
||||
td{padding:11px 10px;border-top:1px solid var(--line)}
|
||||
.mid{font-family:var(--mono);font-size:13px;color:#e8eef5}
|
||||
.badge{font-family:var(--mono);font-size:11.5px;padding:3px 9px;border-radius:6px;display:inline-block}
|
||||
.b-run{background:rgba(70,192,106,.14);color:var(--on)}
|
||||
.b-idle{background:rgba(139,151,165,.14);color:var(--mut)}
|
||||
.b-load{background:rgba(224,163,46,.16);color:var(--warn)}
|
||||
.port{font-family:var(--mono);color:var(--mut);font-size:13px}
|
||||
.empty{color:var(--mut);font-size:14px;padding:14px 4px}
|
||||
label{display:block;font-size:12.5px;color:var(--mut);margin:0 0 5px}
|
||||
input,textarea,select{width:100%;background:var(--panel2);border:1px solid var(--line);color:var(--tx);
|
||||
border-radius:8px;padding:9px 11px;font-family:var(--mono);font-size:13px;margin-bottom:12px}
|
||||
textarea{resize:vertical;min-height:64px;font-family:var(--sans)}
|
||||
.row{display:flex;gap:10px}.row>div{flex:1}
|
||||
button{font-family:var(--sans);font-size:13.5px;font-weight:500;border:1px solid var(--line2);
|
||||
background:var(--panel2);color:var(--tx);border-radius:8px;padding:9px 15px;cursor:pointer}
|
||||
button:hover{border-color:var(--act)}
|
||||
button.primary{background:var(--act);border-color:var(--act);color:#fff}
|
||||
button.primary:hover{filter:brightness(1.08)}
|
||||
button.ghost{padding:5px 11px;font-size:12.5px}
|
||||
button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.reply{margin-top:12px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;
|
||||
padding:12px;white-space:pre-wrap;font-size:14px;min-height:20px;color:var(--tx)}
|
||||
.log{font-family:var(--mono);font-size:12px;line-height:1.65;background:#0a0e13;border:1px solid var(--line);
|
||||
border-radius:8px;padding:12px;max-height:240px;overflow:auto;white-space:pre-wrap;color:#aeb9c4}
|
||||
.job{border:1px solid var(--line);border-radius:8px;margin-bottom:8px;overflow:hidden}
|
||||
.job-h{display:flex;align-items:center;gap:10px;padding:10px 12px;cursor:pointer}
|
||||
.job-h .mid{flex:1}
|
||||
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1b222c;
|
||||
border:1px solid var(--line2);border-radius:10px;padding:11px 16px;font-size:13.5px;
|
||||
opacity:0;transition:.25s;pointer-events:none;max-width:90vw}
|
||||
.toast.show{opacity:1}
|
||||
.toast.err{border-color:var(--err);color:#ffb4ae}
|
||||
.hint{font-size:12px;color:var(--mut);margin:-4px 0 12px}
|
||||
.mono-sm{font-family:var(--mono);font-size:11.5px;color:var(--mut)}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/css/base.css">
|
||||
<link rel="stylesheet" href="/static/css/components.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<span class="brand">Mission <b>Control</b></span>
|
||||
<span class="pill"><span id="hdot" class="dot"></span><span id="hlabel">verbinde…</span></span>
|
||||
<span class="spacer"></span>
|
||||
<input id="token" class="tokin" placeholder="Token (optional)" autocomplete="off">
|
||||
</header>
|
||||
<div id="app">
|
||||
|
||||
<div class="grid">
|
||||
<!-- MODELLE -->
|
||||
<div class="card full">
|
||||
<h2>Modelle & Ports</h2>
|
||||
<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 unten eins rein. 👇</div>
|
||||
<!-- Sidebar: Bereichs-Navigation. Platzhalter-Items sind die kommenden Roadmap-Bereiche. -->
|
||||
<aside class="sidebar">
|
||||
<div class="side-logo" id="logo"></div>
|
||||
<nav class="side-nav">
|
||||
<a class="nav-item" data-view="overview" title="Übersicht" data-ic="grid"></a>
|
||||
<a class="nav-item" data-view="models" title="Modelle" data-ic="cpu"></a>
|
||||
<span class="nav-item disabled" title="Aktivität — siehe Übersicht" data-ic="pulse"></span>
|
||||
<span class="nav-item disabled" title="Server (bald)" data-ic="server"></span>
|
||||
<span class="nav-item disabled" title="Cookbook (bald)" data-ic="book"></span>
|
||||
<span class="nav-item disabled" title="Guides (bald)" data-ic="help"></span>
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
<span class="nav-item disabled" title="Einstellungen (bald)" data-ic="settings"></span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- DOWNLOAD -->
|
||||
<div class="card">
|
||||
<h2>Modell holen</h2>
|
||||
<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" onclick="pull()">Modell herunterladen</button>
|
||||
<div id="register-box" style="display:none;margin-top:16px;border-top:1px solid var(--line);padding-top:14px">
|
||||
<h2>Einpflegen</h2>
|
||||
<div class="row">
|
||||
<div><label>Alias</label><input id="rg-alias"></div>
|
||||
<div><label>Kontext</label><input id="rg-ctx" value="8192"></div>
|
||||
<div class="main">
|
||||
<!-- Topbar -->
|
||||
<header class="topbar">
|
||||
<span class="status-pill"><span id="swdot" class="dot"></span><span id="swlabel">verbinde…</span></span>
|
||||
<span class="spacer"></span>
|
||||
<span class="top-stat">Modelle<b id="top-models">–</b></span>
|
||||
<span class="top-stat">Jobs<b id="top-jobs">0</b></span>
|
||||
<span class="top-clock" id="clock">--:--</span>
|
||||
<input id="token" class="tokin" placeholder="Token" autocomplete="off">
|
||||
</header>
|
||||
|
||||
<!-- Alert-Banner (wird per JS ein-/ausgeblendet) -->
|
||||
<div id="alert" class="alert" style="display:none"></div>
|
||||
|
||||
<!-- Content: genau eine .view ist sichtbar (Hash-Routing) -->
|
||||
<main class="content">
|
||||
|
||||
<section class="view" data-view="overview">
|
||||
<div id="hero"></div>
|
||||
<div class="grid kpis" id="kpis"></div>
|
||||
<div class="grid grid-3">
|
||||
<div class="card" id="health"></div>
|
||||
<div class="card" id="ov-models"></div>
|
||||
<div class="card" id="ov-activity"></div>
|
||||
</div>
|
||||
<input id="rg-path" class="mono-sm" readonly>
|
||||
<button class="primary" onclick="register()">In Config eintragen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" id="wartung"></div>
|
||||
</section>
|
||||
|
||||
<!-- WARTUNG + TEST -->
|
||||
<div class="card">
|
||||
<h2>Wartung</h2>
|
||||
<button onclick="update()">Container aktualisieren</button>
|
||||
<button onclick="unloadAll()">Alles aus dem Speicher</button>
|
||||
<div class="hint" style="margin-top:10px">Update-Befehl wird per <span class="mono-sm">MC_UPDATE_CMD</span> gesetzt.</div>
|
||||
<h2 style="margin-top:18px">Schnelltest</h2>
|
||||
<select id="chat-model"></select>
|
||||
<textarea id="chat-msg" placeholder="Schreib was, um ein Modell zu wecken…"></textarea>
|
||||
<button class="primary" onclick="sendChat()" id="chat-btn">Senden</button>
|
||||
<div id="chat-reply" class="reply" style="display:none"></div>
|
||||
</div>
|
||||
<section class="view" data-view="models" hidden>
|
||||
<div class="grid grid-2">
|
||||
<div class="card" id="m-download"></div>
|
||||
<div class="card" id="m-chat"></div>
|
||||
</div>
|
||||
<div class="card" id="m-table"></div>
|
||||
</section>
|
||||
|
||||
<!-- AKTIVITAET -->
|
||||
<div class="card full">
|
||||
<h2>Aktivität</h2>
|
||||
<div id="jobs"></div>
|
||||
<div id="jobs-empty" class="empty">Noch nichts losgemacht.</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
let TOKEN = localStorage.getItem("mc_token") || "";
|
||||
$("#token").value = TOKEN;
|
||||
$("#token").addEventListener("change", e => { TOKEN = e.target.value.trim(); localStorage.setItem("mc_token", TOKEN); refresh(); });
|
||||
|
||||
function hdr(){ return TOKEN ? {"Content-Type":"application/json","X-MC-Token":TOKEN} : {"Content-Type":"application/json"}; }
|
||||
async function api(path, opts={}){
|
||||
const r = await fetch(path, {headers: hdr(), ...opts});
|
||||
const data = await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(data.error || ("HTTP "+r.status));
|
||||
return data;
|
||||
}
|
||||
let _tt;
|
||||
function toast(msg, err=false){
|
||||
const t = $("#toast"); t.textContent = msg; t.className = "toast show" + (err?" err":"");
|
||||
clearTimeout(_tt); _tt = setTimeout(()=>t.className="toast",3200);
|
||||
}
|
||||
|
||||
function badge(state){
|
||||
if(state==="running" || state==="ready") return '<span class="badge b-run">geladen</span>';
|
||||
if(state==="loading" || state==="starting") return '<span class="badge b-load">lädt…</span>';
|
||||
return '<span class="badge b-idle">bereit</span>';
|
||||
}
|
||||
|
||||
async function refresh(){
|
||||
let s;
|
||||
try{ s = await api("/api/status"); }
|
||||
catch(e){ $("#hdot").className="dot off"; $("#hlabel").textContent="Backend nicht erreichbar"; return; }
|
||||
const ok = s.swap_ok;
|
||||
$("#hdot").className = "dot " + (ok?"on":"off");
|
||||
$("#hlabel").textContent = (ok?"llama-swap online · ":"llama-swap offline · ") + s.swap_url.replace(/^https?:\/\//,"");
|
||||
|
||||
const tb = $("#models"); tb.innerHTML = "";
|
||||
$("#models-empty").style.display = s.models.length ? "none" : "block";
|
||||
const sel = $("#chat-model"); const cur = sel.value; sel.innerHTML = "";
|
||||
for(const m of s.models){
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td class="mid">${m.name}</td><td>${badge(m.state)}</td>
|
||||
<td class="port">${m.port ?? "auto"}</td>
|
||||
<td style="text-align:right"><button class="ghost" onclick="unloadOne('${m.name}')">Entladen</button></td>`;
|
||||
tb.appendChild(tr);
|
||||
sel.insertAdjacentHTML("beforeend", `<option>${m.name}</option>`);
|
||||
}
|
||||
if(cur) sel.value = cur;
|
||||
}
|
||||
|
||||
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";
|
||||
trackJob(r.job_id);
|
||||
}catch(e){ toast(e.message, true); }
|
||||
}
|
||||
|
||||
async function register(){
|
||||
const alias = $("#rg-alias").value.trim(), model_path = $("#rg-path").value, 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."); refresh();
|
||||
}catch(e){ toast(e.message, true); }
|
||||
}
|
||||
|
||||
async function unloadOne(m){ try{ await api("/api/unload?model="+encodeURIComponent(m),{method:"POST"}); toast("Entladen: "+m); setTimeout(refresh,600);}catch(e){toast(e.message,true);} }
|
||||
async function unloadAll(){ try{ await api("/api/unload",{method:"POST"}); toast("Alle Modelle entladen."); setTimeout(refresh,600);}catch(e){toast(e.message,true);} }
|
||||
async function update(){ try{ const r = await api("/api/update",{method:"POST"}); toast("Update läuft."); trackJob(r.job_id);}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"; refresh();
|
||||
}
|
||||
|
||||
// --- Jobs ---
|
||||
const tracked = new Set();
|
||||
function trackJob(id){ tracked.add(id); renderJobs(); }
|
||||
async function renderJobs(){
|
||||
let jobs;
|
||||
try{ jobs = await api("/api/jobs"); }catch(e){ return; }
|
||||
$("#jobs-empty").style.display = jobs.length ? "none" : "block";
|
||||
const c = $("#jobs"); c.innerHTML = "";
|
||||
for(const j of jobs){
|
||||
const open = tracked.has(j.id);
|
||||
const st = j.state==="done" ? '<span class="badge b-run">fertig</span>'
|
||||
: j.state==="failed" ? '<span class="badge" style="background:rgba(229,83,75,.16);color:#e5534b">fehler</span>'
|
||||
: '<span class="badge b-load">läuft…</span>';
|
||||
const div = document.createElement("div"); div.className="job";
|
||||
div.innerHTML = `<div class="job-h" onclick="toggleJob('${j.id}')">
|
||||
<span class="mid">${j.label}</span>${st}</div>
|
||||
${open ? `<div class="log">${(j.log||[]).join("\n").replace(/</g,"<")}</div>` : ""}`;
|
||||
c.appendChild(div);
|
||||
}
|
||||
}
|
||||
function toggleJob(id){ tracked.has(id) ? tracked.delete(id) : tracked.add(id); renderJobs(); }
|
||||
|
||||
refresh(); renderJobs();
|
||||
setInterval(refresh, 3000);
|
||||
setInterval(renderJobs, 1500);
|
||||
<!-- Icons in die Nav/Logo einsetzen, bevor das Haupt-Modul laedt -->
|
||||
<script type="module">
|
||||
import { ICON } from "/static/js/core/ui.js";
|
||||
document.getElementById("logo").innerHTML = ICON.logo;
|
||||
document.querySelectorAll(".nav-item[data-ic]").forEach(n => (n.innerHTML = ICON[n.dataset.ic] || ""));
|
||||
</script>
|
||||
<script type="module" src="/static/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user