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:
@@ -0,0 +1,24 @@
|
||||
// api.js — zentraler Fetch-Wrapper + Token-Handling.
|
||||
// Einziges Modul, das localStorage nutzt (nur fuers Token-Feld — laut Konvention ok).
|
||||
|
||||
let TOKEN = localStorage.getItem("mc_token") || "";
|
||||
|
||||
export function getToken() { return TOKEN; }
|
||||
|
||||
export function setToken(t) {
|
||||
TOKEN = (t || "").trim();
|
||||
localStorage.setItem("mc_token", TOKEN);
|
||||
}
|
||||
|
||||
export function hdr() {
|
||||
const h = { "Content-Type": "application/json" };
|
||||
if (TOKEN) h["X-MC-Token"] = TOKEN;
|
||||
return h;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// nav.js — minimaler Hash-basierter View-Switch fuer die Sidebar.
|
||||
// Zeigt genau eine .view[data-view] und markiert das aktive Nav-Item.
|
||||
|
||||
import { $$ } from "./ui.js";
|
||||
|
||||
export function initNav(defaultView = "overview") {
|
||||
const items = $$(".nav-item[data-view]:not(.disabled)");
|
||||
const views = $$(".view[data-view]");
|
||||
const valid = new Set(views.map(v => v.dataset.view));
|
||||
|
||||
function show(view) {
|
||||
if (!valid.has(view)) view = defaultView;
|
||||
views.forEach(v => (v.hidden = v.dataset.view !== view));
|
||||
items.forEach(i => i.classList.toggle("active", i.dataset.view === view));
|
||||
if (location.hash !== "#/" + view) location.hash = "#/" + view;
|
||||
}
|
||||
|
||||
items.forEach(i =>
|
||||
i.addEventListener("click", e => {
|
||||
e.preventDefault();
|
||||
show(i.dataset.view);
|
||||
})
|
||||
);
|
||||
window.addEventListener("hashchange", () => show(location.hash.replace("#/", "")));
|
||||
|
||||
show(location.hash.replace("#/", "") || defaultView);
|
||||
return { show };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// ui.js — kleine DOM-Helfer, Toast, Badge, Escaping + Inline-Icon-Set.
|
||||
// Bewusst kein Icon-Font / kein CDN: SVGs als Strings, faerben via currentColor.
|
||||
|
||||
export const $ = (s, r = document) => r.querySelector(s);
|
||||
export const $$ = (s, r = document) => [...r.querySelectorAll(s)];
|
||||
|
||||
export function esc(s) {
|
||||
return String(s ?? "").replace(/[&<>]/g, c => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
||||
}
|
||||
|
||||
let _tt;
|
||||
export function toast(msg, err = false) {
|
||||
const t = $("#toast");
|
||||
if (!t) return;
|
||||
t.textContent = msg;
|
||||
t.className = "toast show" + (err ? " err" : "");
|
||||
clearTimeout(_tt);
|
||||
_tt = setTimeout(() => (t.className = "toast"), 3200);
|
||||
}
|
||||
|
||||
// Modell-Status -> Badge-HTML
|
||||
export 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>';
|
||||
}
|
||||
|
||||
// relative Zeit aus Unix-Sekunden (z.B. "2m", "13h")
|
||||
export function ago(ts) {
|
||||
if (!ts) return "";
|
||||
const s = Math.max(0, Math.floor(Date.now() / 1000 - ts));
|
||||
if (s < 60) return s + "s";
|
||||
if (s < 3600) return Math.floor(s / 60) + "m";
|
||||
if (s < 86400) return Math.floor(s / 3600) + "h";
|
||||
return Math.floor(s / 86400) + "d";
|
||||
}
|
||||
|
||||
const _svg = (p) =>
|
||||
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" ` +
|
||||
`stroke-linecap="round" stroke-linejoin="round">${p}</svg>`;
|
||||
|
||||
// Icon-Set (Stroke-Style, an die Referenz angelehnt)
|
||||
export const ICON = {
|
||||
logo: _svg('<circle cx="12" cy="12" r="3.2"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/>'),
|
||||
grid: _svg('<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>'),
|
||||
cpu: _svg('<rect x="6" y="6" width="12" height="12" rx="2"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M1 9h3M1 15h3M20 9h3M20 15h3"/>'),
|
||||
pulse: _svg('<path d="M3 12h4l2 6 4-14 2 8h6"/>'),
|
||||
server: _svg('<rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/>'),
|
||||
book: _svg('<path d="M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2z"/><path d="M19 19H6a2 2 0 0 0-2 2"/>'),
|
||||
help: _svg('<circle cx="12" cy="12" r="9"/><path d="M9.5 9.2a2.5 2.5 0 0 1 4.8 1c0 1.7-2.3 2-2.3 3.4"/><path d="M12 17h.01"/>'),
|
||||
settings: _svg('<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-2.7 1.1V21a2 2 0 1 1-4 0v-.1A1.6 1.6 0 0 0 6.6 19l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0-1.1-2.7H2a2 2 0 1 1 0-4h.1A1.6 1.6 0 0 0 4 6.6l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1A1.6 1.6 0 0 0 9 4.6V4a2 2 0 1 1 4 0v.1A1.6 1.6 0 0 0 17.4 6l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0 1.1 2.7H21a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1z"/>'),
|
||||
swap: _svg('<path d="M7 4 3 8l4 4"/><path d="M3 8h12a4 4 0 0 1 0 8h-1"/><path d="m17 20 4-4-4-4"/>'),
|
||||
monitor: _svg('<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/>'),
|
||||
layers: _svg('<path d="m12 2 9 5-9 5-9-5z"/><path d="m3 12 9 5 9-5M3 17l9 5 9-5"/>'),
|
||||
alert: _svg('<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/>'),
|
||||
gauge: _svg('<path d="M12 14 16 9"/><circle cx="12" cy="13" r="9"/><path d="M12 4v2M21 13h-2M5 13H3"/>'),
|
||||
};
|
||||
|
||||
export function icon(name) { return ICON[name] || ""; }
|
||||
@@ -0,0 +1,81 @@
|
||||
// main.js — App-Boot: Panels mounten, Nav starten, Topbar/Alert pflegen, Polling fahren.
|
||||
// Panel-Vertrag: { id, mount?(), onStatus?(s), onJobs?(jobs) }.
|
||||
|
||||
import { api, getToken, setToken } from "./core/api.js";
|
||||
import { $ } from "./core/ui.js";
|
||||
import { initNav } from "./core/nav.js";
|
||||
|
||||
import overview from "./panels/overview.js";
|
||||
import models from "./panels/models.js";
|
||||
import maintenance from "./panels/maintenance.js";
|
||||
import jobs from "./panels/jobs.js";
|
||||
|
||||
const panels = [overview, models, maintenance, jobs];
|
||||
|
||||
let lastJobs = [];
|
||||
|
||||
// ---- Topbar / Alert aus dem Status ableiten ----
|
||||
function applyStatus(s) {
|
||||
const dot = $("#swdot"), label = $("#swlabel"), alert = $("#alert");
|
||||
|
||||
if (!s) {
|
||||
dot.className = "dot off";
|
||||
label.textContent = "Backend nicht erreichbar";
|
||||
$("#top-models").textContent = "–";
|
||||
showAlert("Backend nicht erreichbar — läuft uvicorn?", false);
|
||||
} else {
|
||||
const host = s.swap_url.replace(/^https?:\/\//, "");
|
||||
dot.className = "dot " + (s.swap_ok ? "on" : "off");
|
||||
label.textContent = (s.swap_ok ? "llama-swap online · " : "llama-swap offline · ") + host;
|
||||
$("#top-models").textContent = (s.models || []).length;
|
||||
if (s.swap_ok) hideAlert();
|
||||
else showAlert(`llama-swap nicht erreichbar unter <b>${host}</b> — läuft der Dienst?`, true);
|
||||
}
|
||||
for (const p of panels) p.onStatus?.(s);
|
||||
}
|
||||
|
||||
function applyJobs(jobs) {
|
||||
lastJobs = jobs || [];
|
||||
$("#top-jobs").textContent = lastJobs.filter(j => j.state === "running" || j.state === "queued").length;
|
||||
for (const p of panels) p.onJobs?.(lastJobs);
|
||||
}
|
||||
|
||||
function showAlert(html, warn) {
|
||||
const a = $("#alert");
|
||||
a.className = "alert" + (warn ? " warn" : "");
|
||||
a.innerHTML = `<span class="a-dot"></span><span>${html}</span>`;
|
||||
a.style.display = "flex";
|
||||
}
|
||||
function hideAlert() { $("#alert").style.display = "none"; }
|
||||
|
||||
// ---- Polling ----
|
||||
async function pollStatus() {
|
||||
try { applyStatus(await api("/api/status")); }
|
||||
catch { applyStatus(null); }
|
||||
}
|
||||
async function pollJobs() {
|
||||
try { applyJobs(await api("/api/jobs")); }
|
||||
catch { /* still */ }
|
||||
}
|
||||
|
||||
// ---- Boot ----
|
||||
function bootToken() {
|
||||
const i = $("#token");
|
||||
i.value = getToken();
|
||||
i.addEventListener("change", e => { setToken(e.target.value); pollStatus(); });
|
||||
}
|
||||
function tickClock() {
|
||||
$("#clock").textContent = new Date().toTimeString().slice(0, 5);
|
||||
}
|
||||
|
||||
for (const p of panels) p.mount?.();
|
||||
initNav("overview");
|
||||
bootToken();
|
||||
tickClock();
|
||||
document.addEventListener("mc:refresh", pollStatus);
|
||||
|
||||
pollStatus();
|
||||
pollJobs();
|
||||
setInterval(tickClock, 1000);
|
||||
setInterval(pollStatus, 3000);
|
||||
setInterval(pollJobs, 1500);
|
||||
@@ -0,0 +1,67 @@
|
||||
// jobs.js — Aktivitaets-Stream ("Incident Stream"): Hintergrund-Jobs mit Live-Log.
|
||||
// Exportiert track(id), damit andere Panels einen frisch gestarteten Job auto-aufklappen.
|
||||
|
||||
import { $, esc } from "../core/ui.js";
|
||||
|
||||
const tracked = new Set();
|
||||
let JOBS = [];
|
||||
|
||||
export function track(id) {
|
||||
tracked.add(id);
|
||||
render();
|
||||
}
|
||||
|
||||
function statusBadge(state) {
|
||||
if (state === "done") return '<span class="badge b-run">fertig</span>';
|
||||
if (state === "failed") return '<span class="badge b-err">fehler</span>';
|
||||
return '<span class="badge b-load">läuft…</span>';
|
||||
}
|
||||
function dotClass(state) {
|
||||
if (state === "done") return "on";
|
||||
if (state === "failed") return "";
|
||||
return "load";
|
||||
}
|
||||
|
||||
function mount() {
|
||||
$("#ov-activity").innerHTML = `
|
||||
<div class="card-h"><h3>Aktivität</h3><span class="meta" id="job-count"></span></div>
|
||||
<div id="jobs"></div>
|
||||
<div id="jobs-empty" class="empty-c">
|
||||
<div class="e-t">Noch nichts losgemacht.</div>
|
||||
<div class="e-s">Downloads, Updates & Co. erscheinen hier mit Live-Log.</div>
|
||||
</div>`;
|
||||
|
||||
// Klicks auf Job-Kopf -> auf/zuklappen (Event-Delegation)
|
||||
$("#jobs").addEventListener("click", e => {
|
||||
const h = e.target.closest(".job-h");
|
||||
if (!h) return;
|
||||
const id = h.getAttribute("data-id");
|
||||
tracked.has(id) ? tracked.delete(id) : tracked.add(id);
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
const c = $("#jobs");
|
||||
if (!c) return;
|
||||
$("#jobs-empty").style.display = JOBS.length ? "none" : "flex";
|
||||
const failed = JOBS.filter(j => j.state === "failed").length;
|
||||
$("#job-count").textContent = JOBS.length ? (failed ? failed + " Fehler" : JOBS.length + " gesamt") : "";
|
||||
|
||||
c.innerHTML = JOBS.map(j => {
|
||||
const open = tracked.has(j.id);
|
||||
const log = open ? `<div class="log">${esc((j.log || []).join("\n"))}</div>` : "";
|
||||
return `<div class="job">
|
||||
<div class="job-h" data-id="${esc(j.id)}">
|
||||
<span class="li-dot ${dotClass(j.state)}"></span>
|
||||
<span class="mid">${esc(j.label)}</span>${statusBadge(j.state)}
|
||||
</div>${log}</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function onJobs(jobs) {
|
||||
JOBS = jobs || [];
|
||||
render();
|
||||
}
|
||||
|
||||
export default { id: "jobs", mount, onJobs };
|
||||
@@ -0,0 +1,40 @@
|
||||
// maintenance.js — Wartungs-Karte: Container aktualisieren + alle Modelle entladen.
|
||||
// Spaeter waechst hier das Server-Management an (Roadmap Feature 1).
|
||||
|
||||
import { api } from "../core/api.js";
|
||||
import { $, toast } from "../core/ui.js";
|
||||
import { track } from "./jobs.js";
|
||||
|
||||
function mount() {
|
||||
$("#wartung").innerHTML = `
|
||||
<div class="card-h"><h3>Wartung</h3></div>
|
||||
<div class="btn-row">
|
||||
<button id="w-update">Container aktualisieren</button>
|
||||
<button id="w-unload" class="danger">Alles aus dem Speicher</button>
|
||||
</div>
|
||||
<div class="hint" style="margin-top:12px">
|
||||
Update-Befehl wird per <span class="mono-sm">MC_UPDATE_CMD</span> gesetzt.
|
||||
Server-Steuerung (Dienste, OS-Updates, Reboot) folgt als eigener Bereich.
|
||||
</div>`;
|
||||
|
||||
$("#w-update").addEventListener("click", update);
|
||||
$("#w-unload").addEventListener("click", unloadAll);
|
||||
}
|
||||
|
||||
async function update() {
|
||||
try {
|
||||
const r = await api("/api/update", { method: "POST" });
|
||||
toast("Update läuft.");
|
||||
track(r.job_id);
|
||||
} catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
async function unloadAll() {
|
||||
try {
|
||||
await api("/api/unload", { method: "POST" });
|
||||
toast("Alle Modelle entladen.");
|
||||
setTimeout(() => document.dispatchEvent(new Event("mc:refresh")), 600);
|
||||
} catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
export default { id: "maintenance", mount };
|
||||
@@ -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 & 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 };
|
||||
@@ -0,0 +1,117 @@
|
||||
// overview.js — Dashboard-Kopf: Hero + Mini-Stats, KPI-Reihe, Stack-Gesundheit,
|
||||
// kompakte Modell-Liste ("Session Router"). Speist sich aus /api/status + /api/jobs.
|
||||
|
||||
import { $, icon, esc } from "../core/ui.js";
|
||||
|
||||
let S = null; // letzter Status
|
||||
let J = []; // letzte Job-Liste
|
||||
|
||||
const RUNNING = new Set(["running", "ready", "loading", "starting"]);
|
||||
|
||||
function counts() {
|
||||
const models = S?.models || [];
|
||||
return {
|
||||
total: models.length,
|
||||
running: models.filter(m => RUNNING.has(m.state)).length,
|
||||
jobsRun: J.filter(j => j.state === "running" || j.state === "queued").length,
|
||||
jobsErr: J.filter(j => j.state === "failed").length,
|
||||
swap: !!S?.swap_ok,
|
||||
};
|
||||
}
|
||||
|
||||
function mini(label, val, tone = "") {
|
||||
const v = tone ? `<b style="${tone === "bad" ? "color:var(--err)" : ""}">${val}</b>` : val;
|
||||
return `<div class="mini"><div class="l">${label}</div><div class="v">${v}</div></div>`;
|
||||
}
|
||||
|
||||
function renderHero() {
|
||||
const c = counts();
|
||||
$("#hero").innerHTML = `<div class="hero">
|
||||
<div>
|
||||
<div class="eyebrow">Übersicht</div>
|
||||
<h1>Mission Control</h1>
|
||||
<p>Steuerzentrale für deinen lokalen llama-swap-Stack — Modelle, Downloads,
|
||||
Wartung und Schnelltest an einem Ort.</p>
|
||||
</div>
|
||||
<div class="hero-stats">
|
||||
${mini("Modelle", c.total)}
|
||||
${mini("Aktiv", c.running, "on")}
|
||||
${mini("Jobs", c.jobsRun)}
|
||||
${mini("Fehler", c.jobsErr, c.jobsErr ? "bad" : "")}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function kpi(cls, title, ic, value, sub) {
|
||||
return `<div class="kpi ${cls}">
|
||||
<div class="k-h"><span class="k-t">${title}</span><span class="k-ic">${icon(ic)}</span></div>
|
||||
<div class="k-v">${value}</div>
|
||||
<div class="k-s">${sub}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderKpis() {
|
||||
const c = counts();
|
||||
$("#kpis").innerHTML =
|
||||
kpi(c.swap ? "green" : "red", "llama-swap", "swap",
|
||||
c.swap ? "Online" : "Offline", "Transport-Status") +
|
||||
kpi("blue", "Modelle", "monitor",
|
||||
`${c.running}<small>/${c.total}</small>`, "aktiv / gesamt") +
|
||||
kpi("purple", "Jobs", "layers", c.jobsRun, "laufend") +
|
||||
kpi(c.jobsErr ? "red" : "muted", "Fehler", "alert", c.jobsErr, "in der Aktivität") +
|
||||
kpi("muted", "System-Last", "gauge", "n/a", "bald · Live-Auslastung");
|
||||
}
|
||||
|
||||
function kvRow(k, v, cls = "") {
|
||||
return `<div class="kv-row"><span class="kv-k">${k}</span><span class="kv-v ${cls}">${v}</span></div>`;
|
||||
}
|
||||
|
||||
function renderHealth() {
|
||||
const c = counts();
|
||||
$("#health").innerHTML = `
|
||||
<div class="card-h"><h3>Stack-Gesundheit</h3>
|
||||
<span class="meta ${c.swap ? "ok" : ""}">${c.swap ? "Connected" : "Offline"}</span></div>
|
||||
<div class="kv">
|
||||
${kvRow("llama-swap", c.swap ? "Connected" : "Offline", c.swap ? "ok" : "bad")}
|
||||
${kvRow("Modelle (gesamt)", c.total)}
|
||||
${kvRow("Aktiv", c.running, c.running ? "ok" : "")}
|
||||
${kvRow("Jobs (laufend)", c.jobsRun)}
|
||||
${kvRow("Fehler", c.jobsErr, c.jobsErr ? "bad" : "")}
|
||||
${kvRow("Auslastung (RAM/GPU/Disk)", "folgt", "na")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function modelRow(m) {
|
||||
const on = RUNNING.has(m.state);
|
||||
const dot = m.state === "loading" || m.state === "starting" ? "load" : on ? "on" : "";
|
||||
const state = on ? (m.state === "loading" ? "lädt…" : "geladen") : "bereit";
|
||||
return `<div class="li">
|
||||
<span class="li-dot ${dot}"></span>
|
||||
<div class="li-main">
|
||||
<div class="li-id">${esc(m.name)}</div>
|
||||
<div class="li-sub">TTL ${m.ttl ?? "—"}${typeof m.ttl === "number" ? "s" : ""}</div>
|
||||
</div>
|
||||
<div class="li-right">
|
||||
<div class="li-meta">${m.port ?? "auto"}</div>
|
||||
<div class="li-time">${state}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderModels() {
|
||||
const models = S?.models || [];
|
||||
$("#ov-models").innerHTML = `
|
||||
<div class="card-h"><h3>Modelle</h3><span class="meta">${models.length || ""}</span></div>
|
||||
${models.length
|
||||
? `<div class="list">${models.map(modelRow).join("")}</div>`
|
||||
: `<div class="empty-c"><div class="e-t">Keine Modelle konfiguriert</div>
|
||||
<div class="e-s">Hol dir unter „Modelle" eins von HuggingFace.</div></div>`}`;
|
||||
}
|
||||
|
||||
function renderAll() { renderHero(); renderKpis(); renderHealth(); renderModels(); }
|
||||
|
||||
function mount() { renderAll(); }
|
||||
function onStatus(s) { S = s; renderAll(); }
|
||||
function onJobs(jobs) { J = jobs || []; renderHero(); renderKpis(); renderHealth(); }
|
||||
|
||||
export default { id: "overview", mount, onStatus, onJobs };
|
||||
Reference in New Issue
Block a user