Files
mission-control/static/index.html
T

249 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<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>
</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 class="grid">
<!-- MODELLE -->
<div class="card full">
<h2>Modelle &amp; 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>
</div>
<!-- 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>
<input id="rg-path" class="mono-sm" readonly>
<button class="primary" onclick="register()">In Config eintragen</button>
</div>
</div>
<!-- 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>
<!-- AKTIVITAET -->
<div class="card full">
<h2>Aktivität</h2>
<div id="jobs"></div>
<div id="jobs-empty" class="empty">Noch nichts losgemacht.</div>
</div>
</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,"&lt;")}</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);
</script>
</body>
</html>