Compare commits
2 Commits
8f63c4969a
...
1aea0f558e
| Author | SHA1 | Date | |
|---|---|---|---|
| 1aea0f558e | |||
| 52b0a3bff5 |
@@ -24,6 +24,18 @@ from routers import jobs, maintenance, models, system, cookbook
|
||||
|
||||
app = FastAPI(title="Mission Control")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def _no_cache_static(request, call_next):
|
||||
"""UI + statische Module immer revalidieren lassen (304 wenn unveraendert),
|
||||
damit Aenderungen nach einem rsync sofort wirken und kein Stale-JS haengen bleibt."""
|
||||
response = await call_next(request)
|
||||
path = request.url.path
|
||||
if path == "/" or path.startswith("/static"):
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
app.include_router(models.router)
|
||||
app.include_router(jobs.router)
|
||||
app.include_router(maintenance.router)
|
||||
|
||||
+66
-47
@@ -1,31 +1,35 @@
|
||||
/* =========================================================================
|
||||
base.css — Design-Tokens, Reset, App-Layout (Sidebar + Topbar + Content)
|
||||
Optik orientiert an docs/mission-control-overview.png (GitHub-Dark-Familie).
|
||||
v3: dichtes Control-Plane-Design, EINE Akzentfarbe (Teal), Klartext-tauglich.
|
||||
Orientierung: docs/mission-control-overview.png
|
||||
========================================================================= */
|
||||
|
||||
:root{
|
||||
/* Flächen */
|
||||
--bg:#0a0d12; --bg2:#0d1117; --panel:#10151c; --panel2:#151b23; --inset:#0a0e13;
|
||||
--line:rgba(255,255,255,.07); --line2:rgba(255,255,255,.13);
|
||||
--bg:#0b0e13; --bg2:#0d1117; --panel:#141a24; --tile:#11161e; --panel2:#11161e; --inset:#0a0e13;
|
||||
--line:rgba(255,255,255,.08); --line2:rgba(255,255,255,.14);
|
||||
/* Text */
|
||||
--tx:#d7dee7; --mut:#8b97a5; --dim:#5c6773;
|
||||
/* Akzente */
|
||||
--on:#3fb950; --act:#4493e0; --purple:#a371f7; --warn:#e0a32e; --err:#e5534b;
|
||||
--teal:#2dd4bf;
|
||||
/* Tints (für getönte Karten) */
|
||||
--tx:#e6edf3; --mut:#8b97a5; --dim:#5d6b79;
|
||||
/* EINE Akzentfarbe für alles Klickbare/Aktive */
|
||||
--accent:#2dd4bf; --accent-ink:#06231f; --teal:#2dd4bf;
|
||||
/* Status-/Datenfarben (NUR für Bedeutung, nicht für Buttons) */
|
||||
--on:#3fb950; --act:#4493e0; --purple:#a371f7; --warn:#e0a32e; --err:#f0573f;
|
||||
/* Kompat-Aliase (von Phase-B-Panels referenziert) */
|
||||
--hi:var(--accent); --red:var(--err); --red-dim:rgba(240,87,63,.12);
|
||||
/* Tints (getönte Karten, Legacy) */
|
||||
--t-green:rgba(63,185,80,.07); --b-green:rgba(63,185,80,.22);
|
||||
--t-blue:rgba(68,147,224,.07); --b-blue:rgba(68,147,224,.22);
|
||||
--t-purple:rgba(163,113,247,.08);--b-purple:rgba(163,113,247,.24);
|
||||
--t-red:rgba(229,83,75,.08); --b-red:rgba(229,83,75,.24);
|
||||
--t-red:rgba(240,87,63,.08); --b-red:rgba(240,87,63,.24);
|
||||
/* Schrift */
|
||||
--mono:ui-monospace,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
|
||||
--sans:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
/* Typografie Skala */
|
||||
--text-xs: 11.5px; --text-sm: 13px; --text-base: 14px; --text-lg: 16px; --text-xl: 20px; --text-2xl: 24px;
|
||||
/* Spacing Skala */
|
||||
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 20px; --sp-6: 24px; --sp-8: 32px; --sp-10: 40px;
|
||||
/* Typografie-Skala */
|
||||
--text-xs:11.5px; --text-sm:13px; --text-base:14px; --text-lg:16px; --text-xl:20px; --text-2xl:24px;
|
||||
/* Spacing-Skala */
|
||||
--sp-1:4px; --sp-2:8px; --sp-3:12px; --sp-4:16px; --sp-5:20px; --sp-6:24px; --sp-8:32px; --sp-10:40px;
|
||||
/* Maße */
|
||||
--side:62px; --radius:6px;
|
||||
--side:212px; --radius:12px; --radius-sm:8px;
|
||||
}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
@@ -35,66 +39,77 @@ body{
|
||||
font-family:var(--sans);font-size:var(--text-base);line-height:1.5;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
a{color:var(--act);text-decoration:none}
|
||||
::selection{background:rgba(68,147,224,.32)}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
::selection{background:rgba(45,212,191,.28)}
|
||||
|
||||
/* ---- App-Shell: feste Sidebar + scrollender Main ---- */
|
||||
/* ---- App-Shell: feste, beschriftete Sidebar + scrollender Main ---- */
|
||||
#app{display:flex;min-height:100vh}
|
||||
|
||||
.sidebar{
|
||||
width:var(--side);flex:0 0 var(--side);
|
||||
background:var(--bg2);border-right:1px solid var(--line);
|
||||
display:flex;flex-direction:column;align-items:center;
|
||||
padding:var(--sp-4) 0;gap:var(--sp-2);position:sticky;top:0;height:100vh;
|
||||
display:flex;flex-direction:column;
|
||||
padding:var(--sp-4) var(--sp-3);gap:2px;position:sticky;top:0;height:100vh;
|
||||
}
|
||||
.side-logo{
|
||||
width:34px;height:34px;border-radius:9px;margin-bottom:10px;
|
||||
display:grid;place-items:center;color:var(--teal);
|
||||
background:rgba(45,212,191,.10);border:1px solid rgba(45,212,191,.22);
|
||||
.brand{display:flex;align-items:center;gap:10px;padding:4px 8px 16px}
|
||||
.brand-logo{
|
||||
width:30px;height:30px;border-radius:9px;flex:0 0 auto;
|
||||
display:grid;place-items:center;color:var(--accent-ink);background:var(--accent);
|
||||
}
|
||||
.side-nav{display:flex;flex-direction:column;gap:4px;flex:1}
|
||||
.side-foot{margin-top:auto}
|
||||
.brand-logo svg{width:18px;height:18px}
|
||||
.brand-tx{font-size:15px;font-weight:500;letter-spacing:.2px}
|
||||
.side-nav{display:flex;flex-direction:column;gap:2px;flex:1}
|
||||
.side-foot{margin-top:auto;padding-top:var(--sp-2);border-top:1px solid var(--line)}
|
||||
.nav-cap{font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);padding:8px 10px 6px}
|
||||
.nav-item{
|
||||
width:40px;height:40px;border-radius:var(--radius);display:grid;place-items:center;
|
||||
color:var(--mut);cursor:pointer;border:1px solid transparent;transition:.15s;
|
||||
display:flex;align-items:center;gap:11px;
|
||||
padding:9px 11px;border-radius:10px;color:var(--mut);cursor:pointer;
|
||||
border:1px solid transparent;transition:.13s;font-size:13.5px;user-select:none;
|
||||
}
|
||||
.nav-item:hover{color:var(--tx);background:var(--panel)}
|
||||
.nav-item.active{
|
||||
color:var(--teal);background:rgba(45,212,191,.12);border-color:rgba(45,212,191,.22);
|
||||
}
|
||||
.nav-item.active{color:var(--tx);background:rgba(45,212,191,.12);border-color:rgba(45,212,191,.20);font-weight:500}
|
||||
.nav-item.active .ni-ic{color:var(--accent)}
|
||||
.ni-ic{display:grid;place-items:center;color:inherit;flex:0 0 auto}
|
||||
.ni-ic svg{width:18px;height:18px}
|
||||
.nav-item.disabled{opacity:.34;cursor:not-allowed}
|
||||
.nav-item.disabled:hover{background:transparent;color:var(--mut)}
|
||||
.nav-item svg{width:20px;height:20px}
|
||||
|
||||
/* ---- Main-Spalte ---- */
|
||||
.main{flex:1;min-width:0;display:flex;flex-direction:column}
|
||||
|
||||
.topbar{
|
||||
position:sticky;top:0;z-index:20;
|
||||
display:flex;align-items:center;gap:var(--sp-4);flex-wrap:wrap;
|
||||
padding:var(--sp-3) var(--sp-6);background:rgba(10,13,18,.86);backdrop-filter:blur(8px);
|
||||
display:flex;align-items:center;gap:var(--sp-3);flex-wrap:wrap;
|
||||
padding:var(--sp-3) var(--sp-6);background:rgba(11,14,19,.86);backdrop-filter:blur(8px);
|
||||
border-bottom:1px solid var(--line);
|
||||
}
|
||||
.spacer{flex:1}
|
||||
.status-pill{
|
||||
display:inline-flex;align-items:center;gap:var(--sp-2);font-family:var(--mono);font-size:var(--text-xs);
|
||||
padding:var(--sp-1) var(--sp-3);border:1px solid var(--line);border-radius:999px;color:var(--mut);background:var(--panel);
|
||||
padding:5px 12px;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);flex:0 0 auto}
|
||||
.dot.on{background:var(--on);box-shadow:0 0 0 0 rgba(63,185,80,.5);animation:pulse 2.2s infinite}
|
||||
.dot.off{background:var(--err)}
|
||||
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(63,185,80,.45)}70%{box-shadow:0 0 0 7px rgba(63,185,80,0)}100%{box-shadow:0 0 0 0 rgba(63,185,80,0)}}
|
||||
.top-stat{font-size:var(--text-sm);color:var(--mut)}
|
||||
.top-stat b{color:var(--tx);font-family:var(--mono);font-weight:600;margin-left:4px}
|
||||
.top-clock{font-family:var(--mono);font-size:var(--text-sm);color:var(--act)}
|
||||
.tokin{
|
||||
font-family:var(--mono);font-size:var(--text-sm);background:var(--panel);border:1px solid var(--line);
|
||||
color:var(--tx);border-radius:var(--radius);padding:var(--sp-2) var(--sp-3);width:128px;
|
||||
.top-stat b{color:var(--tx);font-family:var(--mono);font-weight:500;margin-left:4px}
|
||||
.top-clock{font-family:var(--mono);font-size:var(--text-sm);color:var(--accent)}
|
||||
|
||||
/* Security-Chip (zeigt LAN/Token-Status) */
|
||||
.sec-chip{
|
||||
display:inline-flex;align-items:center;gap:7px;font-size:var(--text-xs);
|
||||
padding:5px 11px;border-radius:999px;border:1px solid var(--line);
|
||||
background:var(--panel);color:var(--mut);
|
||||
}
|
||||
.tokin:focus{outline:none;border-color:var(--act)}
|
||||
.sec-chip.ok{background:rgba(63,185,80,.12);border-color:rgba(63,185,80,.25);color:#7ee29a}
|
||||
.sec-chip .ti, .sec-chip svg{width:14px;height:14px}
|
||||
|
||||
.icon-btn{color:var(--dim);cursor:pointer;display:grid;place-items:center;background:none;border:0;padding:6px}
|
||||
.icon-btn:hover{color:var(--tx);border-color:transparent}
|
||||
.icon-btn svg{width:18px;height:18px}
|
||||
|
||||
/* ---- Content-Bereich ---- */
|
||||
.content{padding:var(--sp-5) var(--sp-6) var(--sp-10);max-width:1300px;margin:0 auto;width:100%}
|
||||
.content{padding:var(--sp-5) var(--sp-6) var(--sp-10);max-width:1320px;margin:0 auto;width:100%}
|
||||
.view[hidden]{display:none}
|
||||
.view{display:flex;flex-direction:column;gap:var(--sp-4)}
|
||||
|
||||
@@ -103,18 +118,22 @@ a{color:var(--act);text-decoration:none}
|
||||
.grid-3{grid-template-columns:repeat(auto-fit, minmax(300px, 1fr))}
|
||||
.grid-2{grid-template-columns:repeat(auto-fit, minmax(400px, 1fr))}
|
||||
.kpis{grid-template-columns:repeat(5,1fr)}
|
||||
.tiles{display:grid;gap:var(--sp-3);grid-template-columns:repeat(auto-fit, minmax(165px, 1fr))}
|
||||
.split{display:grid;gap:var(--sp-4);grid-template-columns:1.05fr 1fr}
|
||||
@media(max-width:1180px){.grid-3{grid-template-columns:1fr 1fr}.kpis{grid-template-columns:repeat(2,1fr)}}
|
||||
@media(max-width:760px){.grid-3,.grid-2,.kpis{grid-template-columns:1fr}}
|
||||
@media(max-width:900px){.split{grid-template-columns:1fr}}
|
||||
@media(max-width:760px){.grid-3,.grid-2,.kpis{grid-template-columns:1fr}
|
||||
.sidebar{width:60px;flex-basis:60px}
|
||||
.ni-tx,.brand-tx{display:none}
|
||||
.nav-item{justify-content:center}.brand{justify-content:center}}
|
||||
|
||||
/* Alert-Banner */
|
||||
.alert{
|
||||
margin:var(--sp-4) var(--sp-6) 0;padding:var(--sp-3) var(--sp-4);border-radius:var(--radius);
|
||||
background:rgba(229,83,75,.08);
|
||||
border:1px solid rgba(229,83,75,.2);color:#ffcdc8;
|
||||
background:rgba(240,87,63,.10);border:1px solid rgba(240,87,63,.26);color:#ffcdc8;
|
||||
display:flex;align-items:center;gap:var(--sp-3);font-size:var(--text-sm);
|
||||
}
|
||||
.alert .a-dot{width:8px;height:8px;border-radius:50%;background:var(--err);flex:0 0 auto}
|
||||
.alert b{color:#ffe2de}
|
||||
.alert.warn{background:rgba(224,163,46,.08);
|
||||
border-color:rgba(224,163,46,.2);color:#f3dca6}
|
||||
.alert.warn{background:rgba(224,163,46,.10);border-color:rgba(224,163,46,.26);color:#f3dca6}
|
||||
.alert.warn .a-dot{background:var(--warn)}
|
||||
|
||||
+129
-109
@@ -1,73 +1,81 @@
|
||||
/* =========================================================================
|
||||
components.css — wiederverwendbare Bausteine (Karten, KPIs, Listen, Forms)
|
||||
components.css — wiederverwendbare Bausteine.
|
||||
v3: Teal-Primary, Metrik-Kacheln, Fit-Ampel, Modal, Quickstart.
|
||||
Legacy-Klassen bleiben erhalten, bis alle Panels migriert sind.
|
||||
========================================================================= */
|
||||
|
||||
/* ---- Karte (Grundbaustein) ---- */
|
||||
.card{
|
||||
background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);
|
||||
padding:var(--sp-3) var(--sp-4);
|
||||
padding:var(--sp-4) var(--sp-5);
|
||||
}
|
||||
.card-h{display:flex;align-items:center;gap:10px;margin:0 0 14px}
|
||||
.card-h h3{font-size:15px;font-weight:600;margin:0;flex:1;color:var(--tx)}
|
||||
.card-h{display:flex;align-items:center;gap:10px;margin:0 0 4px}
|
||||
.card-h h3{font-size:15px;font-weight:500;margin:0;flex:1;color:var(--tx)}
|
||||
.card-h .meta{font-family:var(--mono);font-size:12px;color:var(--mut)}
|
||||
.card-h .meta.ok{color:var(--on)}
|
||||
/* Klartext-Einzeiler unter einer Sektionsüberschrift */
|
||||
.card-sub{font-size:var(--text-xs);color:var(--mut);margin:0 0 14px}
|
||||
|
||||
/* ---- Hero (Overview-Kopf) ---- */
|
||||
.hero{
|
||||
background:var(--panel);
|
||||
border:1px solid var(--line);border-radius:var(--radius);
|
||||
padding:var(--sp-4) var(--sp-5);display:flex;justify-content:space-between;gap:var(--sp-6);flex-wrap:wrap;
|
||||
}
|
||||
.hero .eyebrow{font-size:11.5px;letter-spacing:.22em;text-transform:uppercase;color:var(--mut)}
|
||||
.hero h1{font-size:26px;font-weight:650;margin:8px 0 6px}
|
||||
.hero p{margin:0;color:var(--mut);font-size:14px;max-width:60ch}
|
||||
.hero-stats{display:grid;grid-template-columns:1fr 1fr;gap:12px;align-content:start}
|
||||
.mini{
|
||||
min-width:128px;border:1px solid var(--line);border-radius:10px;
|
||||
padding:11px 14px;background:var(--bg2);
|
||||
}
|
||||
.mini .l{font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--mut)}
|
||||
.mini .v{font-family:var(--mono);font-size:15px;margin-top:4px;color:var(--tx)}
|
||||
.mini .v b{color:var(--on);font-weight:600}
|
||||
/* ---- Seiten-Kopf (Hero, Klartext-Urteil) ---- */
|
||||
.pagehead{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:2px}
|
||||
.pagehead h1{font-size:var(--text-xl);font-weight:500;margin:0;letter-spacing:.2px}
|
||||
.pagehead .sub{font-size:var(--text-sm);color:var(--mut);margin-top:4px}
|
||||
|
||||
/* ---- KPI-Kacheln ---- */
|
||||
.kpi{
|
||||
position:relative;border-radius:var(--radius);padding:var(--sp-3) var(--sp-4);
|
||||
border:1px solid var(--line);background:var(--panel);overflow:hidden;
|
||||
}
|
||||
/* Legacy-Hero (Overview alt) — bleibt für nicht migrierte Nutzung */
|
||||
.hero{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);
|
||||
padding:var(--sp-4) var(--sp-5);display:flex;justify-content:space-between;gap:var(--sp-6);flex-wrap:wrap}
|
||||
.hero .eyebrow{font-size:11.5px;letter-spacing:.18em;text-transform:uppercase;color:var(--mut)}
|
||||
.hero h1{font-size:22px;font-weight:500;margin:8px 0 6px}
|
||||
.hero p{margin:0;color:var(--mut);font-size:14px;max-width:64ch}
|
||||
|
||||
/* ---- Metrik-Kachel ---- */
|
||||
.tile{background:var(--tile);border:1px solid var(--line);border-radius:var(--radius);padding:14px 15px}
|
||||
.tile .t-l{font-size:12px;color:var(--mut)}
|
||||
.tile .t-v{font-family:var(--mono);font-size:20px;margin-top:6px;color:var(--tx);line-height:1.1}
|
||||
.tile .t-v small{font-size:13px;color:var(--mut)}
|
||||
.tile .t-s{font-size:11.5px;color:var(--mut);margin-top:3px}
|
||||
.tile .t-v.ok{color:var(--on)} .tile .t-v.warn{color:var(--warn)} .tile .t-v.bad{color:var(--err)}
|
||||
.tile .t-s.ok{color:#7ee29a}
|
||||
|
||||
/* ---- Chip (Status/Kontext) ---- */
|
||||
.chip{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--mut);
|
||||
background:var(--tile);border:1px solid var(--line);border-radius:999px;padding:6px 12px;white-space:nowrap}
|
||||
.chip svg{width:14px;height:14px}
|
||||
|
||||
/* ---- KPI-Kacheln (Legacy, Activity-Panel) ---- */
|
||||
.kpi{position:relative;border-radius:var(--radius);padding:var(--sp-3) var(--sp-4);
|
||||
border:1px solid var(--line);background:var(--panel);overflow:hidden}
|
||||
.kpi .k-h{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--sp-2)}
|
||||
.kpi .k-t{font-size:var(--text-sm);color:var(--mut)}
|
||||
.kpi .k-ic{color:var(--mut);opacity:.85}
|
||||
.kpi .k-ic svg{width:18px;height:18px}
|
||||
.kpi .k-v{font-family:var(--mono);font-size:var(--text-2xl);font-weight:600;line-height:1.1;margin:var(--sp-3) 0 var(--sp-1);color:var(--tx)}
|
||||
.kpi .k-v{font-family:var(--mono);font-size:var(--text-2xl);font-weight:500;line-height:1.1;margin:var(--sp-3) 0 var(--sp-1);color:var(--tx)}
|
||||
.kpi .k-v small{font-size:var(--text-base);color:var(--mut);font-weight:400}
|
||||
.kpi .k-s{font-size:var(--text-xs);color:var(--mut)}
|
||||
/* Farb-Varianten (flach) */
|
||||
.kpi.green {border-top:2px solid var(--on)}
|
||||
.kpi.green .k-v,.kpi.green .k-ic{color:var(--on)}
|
||||
.kpi.blue {border-top:2px solid var(--act)}
|
||||
.kpi.blue .k-v,.kpi.blue .k-ic{color:var(--act)}
|
||||
.kpi.purple{border-top:2px solid var(--purple)}
|
||||
.kpi.purple .k-v,.kpi.purple .k-ic{color:var(--purple)}
|
||||
.kpi.red {border-top:2px solid var(--err)}
|
||||
.kpi.red .k-v,.kpi.red .k-ic{color:var(--err)}
|
||||
.kpi.green{border-top:2px solid var(--on)} .kpi.green .k-v,.kpi.green .k-ic{color:var(--on)}
|
||||
.kpi.blue{border-top:2px solid var(--act)} .kpi.blue .k-v,.kpi.blue .k-ic{color:var(--act)}
|
||||
.kpi.purple{border-top:2px solid var(--purple)} .kpi.purple .k-v,.kpi.purple .k-ic{color:var(--purple)}
|
||||
.kpi.red{border-top:2px solid var(--err)} .kpi.red .k-v,.kpi.red .k-ic{color:var(--err)}
|
||||
.kpi.muted .k-v{color:var(--dim)}
|
||||
|
||||
/* ---- Key-Value-Liste (Health-Signale) ---- */
|
||||
/* ---- Key-Value-Liste + Balken (System-Gesundheit) ---- */
|
||||
.kv{display:flex;flex-direction:column}
|
||||
.kv-row{display:flex;align-items:center;justify-content:space-between;gap:12px;
|
||||
padding:11px 2px;border-top:1px solid var(--line)}
|
||||
.kv-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 2px;border-top:1px solid var(--line)}
|
||||
.kv-row:first-child{border-top:0}
|
||||
.kv-row .kv-k{color:var(--mut);font-size:13.5px}
|
||||
.kv-row .kv-v{font-family:var(--mono);font-size:13.5px;color:var(--tx)}
|
||||
.kv-v.ok{color:var(--on)} .kv-v.bad{color:var(--err)} .kv-v.na{color:var(--dim)}
|
||||
.bar{height:4px;border-radius:3px;background:var(--panel2);margin-top:8px;overflow:hidden}
|
||||
.bar > i{display:block;height:100%;background:var(--on);border-radius:3px;transition:width .4s}
|
||||
.bar.blue > i{background:var(--act)} .bar.warn > i{background:var(--warn)}
|
||||
.bar{height:6px;border-radius:4px;background:var(--inset);margin-top:8px;overflow:hidden}
|
||||
.bar > i{display:block;height:100%;background:var(--accent);border-radius:4px;transition:width .4s}
|
||||
.bar.blue > i{background:var(--act)} .bar.warn > i{background:var(--warn)} .bar.bad > i{background:var(--err)}
|
||||
/* benannte Mess-Zeile mit Label + Wert + Balken */
|
||||
.meter{margin-top:14px} .meter:first-child{margin-top:0}
|
||||
.meter-h{display:flex;justify-content:space-between;font-size:12.5px}
|
||||
.meter-h .mk{color:#aeb9c4} .meter-h .mv{font-family:var(--mono);color:var(--mut)}
|
||||
|
||||
/* ---- Listen-Items (Modelle / "Session Router") ---- */
|
||||
/* ---- Listen-Items (Modelle / Stack) ---- */
|
||||
.list{display:flex;flex-direction:column}
|
||||
.li{display:flex;align-items:center;gap:12px;padding:12px 4px;border-top:1px solid var(--line)}
|
||||
.li{display:flex;align-items:center;gap:12px;padding:11px 4px;border-top:1px solid var(--line)}
|
||||
.li:first-child{border-top:0}
|
||||
.li .li-dot{width:9px;height:9px;border-radius:50%;background:var(--dim);flex:0 0 auto}
|
||||
.li .li-dot.on{background:var(--on)} .li .li-dot.load{background:var(--warn)}
|
||||
@@ -78,10 +86,23 @@
|
||||
.li .li-meta{font-family:var(--mono);font-size:12px;color:var(--mut)}
|
||||
.li .li-time{font-family:var(--mono);font-size:11.5px;color:var(--dim);margin-top:2px}
|
||||
|
||||
/* ---- Quickstart (geführte Aktionen) ---- */
|
||||
.qa{display:flex;align-items:center;gap:11px;padding:11px 0;border-top:1px solid var(--line);
|
||||
width:100%;background:none;text-align:left;cursor:pointer;color:var(--tx);font-family:var(--sans)}
|
||||
.qa:first-of-type{border-top:0}
|
||||
.qa:hover{border-color:var(--line)} .qa:hover .qa-t{color:var(--accent)}
|
||||
.qa-ic{width:32px;height:32px;border-radius:9px;display:grid;place-items:center;flex:0 0 auto}
|
||||
.qa-ic svg{width:17px;height:17px}
|
||||
.qa-ic.teal{background:rgba(45,212,191,.13);color:var(--accent)}
|
||||
.qa-ic.amber{background:rgba(224,163,46,.13);color:var(--warn)}
|
||||
.qa-ic.blue{background:rgba(68,147,224,.13);color:var(--act)}
|
||||
.qa-main{flex:1;min-width:0}
|
||||
.qa-t{font-size:13.5px} .qa-s{font-size:11.5px;color:var(--mut);margin-top:1px}
|
||||
.qa-arrow{color:var(--dim)} .qa-arrow svg{width:16px;height:16px}
|
||||
|
||||
/* ---- Tabelle ---- */
|
||||
table{width:100%;border-collapse:collapse;font-size:14px}
|
||||
th{text-align:left;font-weight:500;color:var(--mut);font-size:11.5px;text-transform:uppercase;
|
||||
letter-spacing:.06em;padding:0 10px 10px}
|
||||
th{text-align:left;font-weight:400;color:var(--mut);font-size:11.5px;text-transform:uppercase;letter-spacing:.06em;padding:0 10px 10px}
|
||||
td{padding:12px 10px;border-top:1px solid var(--line)}
|
||||
.mid{font-family:var(--mono);font-size:13px;color:#e8eef5}
|
||||
.port{font-family:var(--mono);color:var(--mut);font-size:13px}
|
||||
@@ -91,21 +112,33 @@ td{padding:12px 10px;border-top:1px solid var(--line)}
|
||||
.b-run{background:rgba(63,185,80,.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)}
|
||||
.b-err{background:rgba(229,83,75,.16);color:var(--err)}
|
||||
.b-err{background:rgba(240,87,63,.16);color:var(--err)}
|
||||
.b-ok{background:rgba(63,185,80,.14);color:var(--on)}
|
||||
/* Capability-Tags */
|
||||
.tag{font-size:11px;border-radius:6px;padding:2px 8px}
|
||||
.tag.code{color:#86b9ff;background:rgba(68,147,224,.13)}
|
||||
.tag.text{color:#9aa7b4;background:rgba(139,151,165,.13)}
|
||||
.tag.img{color:#c9a6f5;background:rgba(163,113,247,.14)}
|
||||
|
||||
/* ---- Fit-Ampel (Cookbook) ---- */
|
||||
.fit-badge{font-size:11.5px;border-radius:7px;padding:3px 9px;white-space:nowrap}
|
||||
.fit-badge.ok{background:rgba(63,185,80,.14);color:#7ee29a}
|
||||
.fit-badge.warn{background:rgba(224,163,46,.15);color:#f0c570}
|
||||
.fit-badge.bad{background:rgba(240,87,63,.14);color:#f3a08f}
|
||||
.legend{display:flex;gap:14px;flex-wrap:wrap;font-size:11.5px;color:var(--mut)}
|
||||
.legend span{display:flex;align-items:center;gap:6px}
|
||||
.legend i{width:8px;height:8px;border-radius:50%;display:inline-block}
|
||||
|
||||
/* ---- Empty-States ---- */
|
||||
.empty{color:var(--mut);font-size:13.5px;padding:14px 4px}
|
||||
.empty-c{display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
text-align:center;padding:44px 16px;color:var(--mut)}
|
||||
.empty-c .e-t{font-size:14px}
|
||||
.empty-c .e-s{font-size:12.5px;color:var(--dim);margin-top:6px}
|
||||
.empty-c{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:44px 16px;color:var(--mut)}
|
||||
.empty-c .e-t{font-size:14px} .empty-c .e-s{font-size:12.5px;color:var(--dim);margin-top:6px}
|
||||
|
||||
/* ---- Forms ---- */
|
||||
label{display:block;font-size:12px;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}
|
||||
input:focus,textarea:focus,select:focus{outline:none;border-color:var(--act)}
|
||||
border-radius:var(--radius-sm);padding:9px 11px;font-family:var(--mono);font-size:13px;margin-bottom:12px}
|
||||
input:focus,textarea:focus,select:focus{outline:none;border-color:var(--accent)}
|
||||
textarea{resize:vertical;min-height:64px;font-family:var(--sans)}
|
||||
.row{display:flex;gap:10px}.row>div{flex:1}
|
||||
.hint{font-size:12px;color:var(--mut);margin:-4px 0 12px}
|
||||
@@ -113,78 +146,65 @@ textarea{resize:vertical;min-height:64px;font-family:var(--sans)}
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
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;transition:.15s}
|
||||
button:hover{border-color:var(--act)}
|
||||
button.primary{background:var(--act);border-color:var(--act);color:#fff}
|
||||
button.primary:hover{filter:brightness(1.08)}
|
||||
background:var(--panel2);color:var(--tx);border-radius:var(--radius-sm);padding:9px 15px;cursor:pointer;transition:.15s}
|
||||
button:hover{border-color:var(--accent)}
|
||||
button.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-ink)}
|
||||
button.primary:hover{filter:brightness(1.06)}
|
||||
button.danger{background:var(--err);border-color:var(--err);color:#fff}
|
||||
button.danger:hover{filter:brightness(1.08)}
|
||||
button.ghost{padding:6px 11px;font-size:12.5px}
|
||||
button.danger:hover{filter:brightness(1.06)}
|
||||
button.warn{background:var(--warn);border-color:var(--warn);color:#3a2a05}
|
||||
button.ghost{padding:6px 11px;font-size:12.5px;background:none}
|
||||
button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.btn-row{display:flex;gap:10px;flex-wrap:wrap}
|
||||
|
||||
/* ---- Reply / Log ---- */
|
||||
.reply{margin-top:12px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;
|
||||
/* ---- Reply / Log / Konsole ---- */
|
||||
.reply{margin-top:12px;background:var(--panel2);border:1px solid var(--line);border-radius:var(--radius-sm);
|
||||
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:var(--inset);border:1px solid var(--line);
|
||||
border-radius:8px;padding:12px;max-height:240px;overflow:auto;white-space:pre-wrap;color:#aeb9c4}
|
||||
border-radius:var(--radius-sm);padding:12px;max-height:240px;overflow:auto;white-space:pre-wrap;color:#aeb9c4}
|
||||
.console{background:var(--inset);border:1px solid var(--line);border-radius:var(--radius-sm);
|
||||
font-family:var(--mono);font-size:12px;line-height:1.6;color:#9fe6c4;padding:13px;height:400px;overflow:auto;white-space:pre-wrap}
|
||||
.job{border:1px solid var(--line);border-radius:10px;margin-bottom:8px;overflow:hidden;background:var(--bg2)}
|
||||
.job-h{display:flex;align-items:center;gap:10px;padding:11px 13px;cursor:pointer}
|
||||
.job-h .mid{flex:1}
|
||||
|
||||
/* ---- Modal ---- */
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.62);z-index:100;display:flex;align-items:center;justify-content:center;padding:20px}
|
||||
.modal-card{background:var(--panel);border:1px solid var(--line2);border-radius:var(--radius);
|
||||
width:100%;max-width:440px;padding:var(--sp-5) var(--sp-5);position:relative}
|
||||
.modal-card h3{margin:0 0 8px;font-size:17px;font-weight:500}
|
||||
.modal-card p{margin:0 0 18px;color:var(--mut);font-size:13.5px;line-height:1.55}
|
||||
.modal-actions{display:flex;gap:10px;justify-content:flex-end}
|
||||
|
||||
/* ---- Toast ---- */
|
||||
.toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:var(--panel2);
|
||||
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;z-index:50}
|
||||
opacity:0;transition:.25s;pointer-events:none;max-width:90vw;z-index:120}
|
||||
.toast.show{opacity:1}
|
||||
.toast.err{border-color:var(--err);color:#ffb4ae}
|
||||
|
||||
/* ---- Accordion (Guides) ---- */
|
||||
.guide-acc { border-bottom: 1px solid var(--line); }
|
||||
.guide-acc:last-child { border-bottom: none; }
|
||||
.guide-acc summary {
|
||||
padding: 16px 20px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.guide-acc summary::-webkit-details-marker { display: none; }
|
||||
.guide-acc summary::after { content: "▼"; font-size: 10px; color: var(--mut); transition: transform 0.2s; }
|
||||
.guide-acc[open] summary::after { transform: rotate(180deg); }
|
||||
.guide-acc .acc-body { padding: 0 20px 20px 20px; }
|
||||
.guide-acc{border-bottom:1px solid var(--line)}
|
||||
.guide-acc:last-child{border-bottom:none}
|
||||
.guide-acc summary{padding:16px 20px;font-weight:500;cursor:pointer;list-style:none;display:flex;justify-content:space-between;align-items:center}
|
||||
.guide-acc summary::-webkit-details-marker{display:none}
|
||||
.guide-acc summary::after{content:"▼";font-size:10px;color:var(--mut);transition:transform .2s}
|
||||
.guide-acc[open] summary::after{transform:rotate(180deg)}
|
||||
.guide-acc .acc-body{padding:0 20px 20px 20px}
|
||||
|
||||
/* ---- Utilities & Layout Helpers ---- */
|
||||
.flex { display: flex; }
|
||||
.flex-col { display: flex; flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-2 { gap: var(--sp-2); }
|
||||
.gap-3 { gap: var(--sp-3); }
|
||||
.mt-2 { margin-top: var(--sp-2); }
|
||||
.mt-3 { margin-top: var(--sp-3); }
|
||||
.mt-4 { margin-top: var(--sp-4); }
|
||||
.mt-6 { margin-top: var(--sp-6); }
|
||||
.mb-2 { margin-bottom: var(--sp-2); }
|
||||
.mb-4 { margin-bottom: var(--sp-4); }
|
||||
.text-sm { font-size: var(--text-sm); }
|
||||
.text-xs { font-size: var(--text-xs); }
|
||||
.text-mut { color: var(--mut); }
|
||||
.text-act { color: var(--act); }
|
||||
/* ---- Utilities ---- */
|
||||
.flex{display:flex}.flex-col{display:flex;flex-direction:column}
|
||||
.items-center{align-items:center}.justify-between{justify-content:space-between}
|
||||
.gap-2{gap:var(--sp-2)}.gap-3{gap:var(--sp-3)}
|
||||
.mt-2{margin-top:var(--sp-2)}.mt-3{margin-top:var(--sp-3)}.mt-4{margin-top:var(--sp-4)}.mt-6{margin-top:var(--sp-6)}
|
||||
.mb-2{margin-bottom:var(--sp-2)}.mb-4{margin-bottom:var(--sp-4)}
|
||||
.text-sm{font-size:var(--text-sm)}.text-xs{font-size:var(--text-xs)}
|
||||
.text-mut{color:var(--mut)}.text-act{color:var(--act)}.text-accent{color:var(--accent)}
|
||||
|
||||
/* ---- Interaktive Karten (A11y) ---- */
|
||||
.card-btn {
|
||||
display: block; width: 100%; text-align: left;
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
|
||||
padding: var(--sp-3) var(--sp-4); cursor: pointer; transition: border-color 0.15s, background 0.15s;
|
||||
color: var(--tx); font-family: var(--sans);
|
||||
}
|
||||
.card-btn:hover, .card-btn:focus {
|
||||
border-color: var(--act);
|
||||
background: var(--bg2);
|
||||
outline: none;
|
||||
}
|
||||
.card-btn h3 { margin: 0; font-size: var(--text-lg); font-weight: 600; }
|
||||
.card-btn p { margin: var(--sp-2) 0 0; font-size: var(--text-sm); color: var(--mut); }
|
||||
/* ---- Interaktive Karten (Legacy Quick-Actions) ---- */
|
||||
.card-btn{display:block;width:100%;text-align:left;background:var(--panel);border:1px solid var(--line);
|
||||
border-radius:var(--radius);padding:var(--sp-3) var(--sp-4);cursor:pointer;transition:border-color .15s,background .15s;
|
||||
color:var(--tx);font-family:var(--sans)}
|
||||
.card-btn:hover,.card-btn:focus{border-color:var(--accent);background:var(--bg2);outline:none}
|
||||
.card-btn h3{margin:0;font-size:var(--text-lg);font-weight:500}
|
||||
.card-btn p{margin:var(--sp-2) 0 0;font-size:var(--text-sm);color:var(--mut)}
|
||||
|
||||
+30
-31
@@ -10,19 +10,19 @@
|
||||
<body>
|
||||
<div id="app">
|
||||
|
||||
<!-- Sidebar: Bereichs-Navigation. Platzhalter-Items sind die kommenden Roadmap-Bereiche. -->
|
||||
<!-- Sidebar: beschriftete Bereichs-Navigation -->
|
||||
<aside class="sidebar">
|
||||
<div class="side-logo" id="logo"></div>
|
||||
<div class="brand"><span class="brand-logo" id="logo"></span><span class="brand-tx">Mission Control</span></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>
|
||||
<a class="nav-item" data-view="activity" title="Aktivität" data-ic="pulse"></a>
|
||||
<a class="nav-item" data-view="server" title="Server" data-ic="server"></a>
|
||||
<a class="nav-item" data-view="cookbook" title="Cookbook" data-ic="book"></a>
|
||||
<a class="nav-item" data-view="guides" title="Guides" data-ic="help"></a>
|
||||
<a class="nav-item" data-view="overview"><span class="ni-ic" data-ic="grid"></span><span class="ni-tx">Übersicht</span></a>
|
||||
<a class="nav-item" data-view="models"><span class="ni-ic" data-ic="cpu"></span><span class="ni-tx">Modelle</span></a>
|
||||
<a class="nav-item" data-view="activity"><span class="ni-ic" data-ic="pulse"></span><span class="ni-tx">Aktivität</span></a>
|
||||
<a class="nav-item" data-view="server"><span class="ni-ic" data-ic="server"></span><span class="ni-tx">Server</span></a>
|
||||
<a class="nav-item" data-view="cookbook"><span class="ni-ic" data-ic="book"></span><span class="ni-tx">Cookbook</span></a>
|
||||
<a class="nav-item" data-view="guides"><span class="ni-ic" data-ic="help"></span><span class="ni-tx">Guides</span></a>
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
<span class="nav-item" title="Einstellungen" data-ic="settings" id="nav-settings"></span>
|
||||
<span class="nav-item" id="nav-settings"><span class="ni-ic" data-ic="settings"></span><span class="ni-tx">Einstellungen</span></span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
<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" id="top-active-text" style="color:var(--teal)">Kein Modell geladen</span>
|
||||
<span class="top-stat" id="top-active-text">Kein Modell geladen</span>
|
||||
<span class="sec-chip" id="sec-chip"><span data-ic="shield"></span><span id="sec-chip-tx">Nur im Heimnetz</span></span>
|
||||
</header>
|
||||
|
||||
<!-- Alert-Banner (wird per JS ein-/ausgeblendet) -->
|
||||
@@ -41,18 +42,18 @@
|
||||
<main class="content">
|
||||
|
||||
<section class="view" data-view="overview">
|
||||
<div id="hero"></div>
|
||||
|
||||
<div class="grid grid-3 mt-6" id="ov-quick"></div>
|
||||
|
||||
<div class="grid grid-2 mt-6" style="align-items: start;">
|
||||
<div class="card" id="ov-models"></div>
|
||||
<div class="card" id="ov-recent-jobs"></div>
|
||||
<div id="ov-hero"></div>
|
||||
<div class="tiles" id="ov-tiles"></div>
|
||||
<div class="split">
|
||||
<div class="card" id="ov-health"></div>
|
||||
<div class="card" id="ov-quickstart"></div>
|
||||
</div>
|
||||
<div class="card" id="ov-stack"></div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="activity" hidden>
|
||||
<div class="grid kpis" id="act-kpis"></div>
|
||||
<div id="act-head"></div>
|
||||
<div class="tiles" id="act-kpis"></div>
|
||||
<div class="card" id="act-sys"></div>
|
||||
<div class="card" id="v-activity"></div>
|
||||
</section>
|
||||
@@ -62,9 +63,8 @@
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="models" hidden>
|
||||
<div class="grid grid-2" id="m-top-grid">
|
||||
<div class="card" id="m-chat" style="grid-column: span 2;"></div>
|
||||
</div>
|
||||
<div id="m-head"></div>
|
||||
<div class="card" id="m-chat"></div>
|
||||
<div class="card" id="m-table"></div>
|
||||
</section>
|
||||
|
||||
@@ -81,26 +81,25 @@
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div id="settings-modal" style="display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.8); z-index:100; align-items:center; justify-content:center;">
|
||||
<div class="card" style="width:100%; max-width:400px; position:relative">
|
||||
<div id="settings-modal" style="display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.62); z-index:100; align-items:center; justify-content:center;">
|
||||
<div class="card" style="width:100%; max-width:420px; position:relative">
|
||||
<button id="sm-close" class="ghost" style="position:absolute; top:12px; right:12px;">Schließen</button>
|
||||
<h2 style="margin-top:0">Einstellungen</h2>
|
||||
|
||||
<div class="mt-6">
|
||||
<label>API Token (Authentifizierung)</label>
|
||||
<input id="token" class="tokin" placeholder="Optionales API Token..." autocomplete="off">
|
||||
<div class="meta text-xs mt-2">Wird für die WebSockets und API Calls genutzt, falls der Server geschützt ist.</div>
|
||||
<h3 style="margin-top:0;font-weight:500">Einstellungen</h3>
|
||||
<div class="mt-4">
|
||||
<label>Zugangs-Token (optional)</label>
|
||||
<input id="token" class="tokin" placeholder="Nur nötig, wenn der Server geschützt ist…" autocomplete="off">
|
||||
<div class="hint mt-2">Schützt den Zugriff. Wird für API-Aufrufe und Live-Verbindungen mitgeschickt.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<!-- Icons in die Nav/Logo einsetzen, bevor das Haupt-Modul laedt -->
|
||||
<!-- Icons in Nav/Logo/Chip 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] || ""));
|
||||
document.querySelectorAll("[data-ic]").forEach(n => (n.innerHTML = ICON[n.dataset.ic] || ""));
|
||||
</script>
|
||||
<script type="module" src="/static/js/main.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -58,6 +58,84 @@ export const ICON = {
|
||||
compass: _svg('<circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/>'),
|
||||
code: _svg('<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>'),
|
||||
eye: _svg('<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>'),
|
||||
refresh: _svg('<path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/><path d="M3 21v-5h5"/>'),
|
||||
file: _svg('<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 17h5"/>'),
|
||||
database: _svg('<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5"/><path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/>'),
|
||||
thermo: _svg('<path d="M14 14.76V5a2 2 0 0 0-4 0v9.76a4 4 0 1 0 4 0z"/>'),
|
||||
shield: _svg('<path d="M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7z"/><path d="m9 12 2 2 4-4"/>'),
|
||||
info: _svg('<circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/>'),
|
||||
chevron: _svg('<path d="m9 6 6 6-6 6"/>'),
|
||||
bolt: _svg('<path d="M13 2 3 14h7l-1 8 10-12h-7z"/>'),
|
||||
x: _svg('<path d="M18 6 6 18M6 6l12 12"/>'),
|
||||
check: _svg('<path d="M20 6 9 17l-5-5"/>'),
|
||||
download: _svg('<path d="M12 3v12M7 10l5 5 5-5"/><path d="M5 21h14"/>'),
|
||||
clock: _svg('<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>'),
|
||||
};
|
||||
|
||||
export function icon(name) { return ICON[name] || ""; }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Format-Helfer (Klartext-Zahlen)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function fmtBytes(b) {
|
||||
if (!b && b !== 0) return "–";
|
||||
const gb = b / 1024 / 1024 / 1024;
|
||||
if (gb >= 1) return gb.toFixed(1) + " GB";
|
||||
return Math.round(b / 1024 / 1024) + " MB";
|
||||
}
|
||||
export function fmtPct(n) { return Math.round(n || 0) + " %"; }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bestätigungs-Dialog (ersetzt nacktes window.confirm) — gibt Promise<boolean>.
|
||||
// Für heikle Aktionen mit Klartext-Konsequenz.
|
||||
// ---------------------------------------------------------------------------
|
||||
export function confirmModal({ title, body, confirmLabel = "Bestätigen", cancelLabel = "Abbrechen", danger = false }) {
|
||||
return new Promise(resolve => {
|
||||
const ov = document.createElement("div");
|
||||
ov.className = "modal-overlay";
|
||||
ov.innerHTML = `<div class="modal-card" role="dialog" aria-modal="true">
|
||||
<h3>${esc(title)}</h3>
|
||||
<p>${body}</p>
|
||||
<div class="modal-actions">
|
||||
<button data-x="0">${esc(cancelLabel)}</button>
|
||||
<button class="${danger ? "danger" : "primary"}" data-x="1">${esc(confirmLabel)}</button>
|
||||
</div></div>`;
|
||||
const done = v => { ov.remove(); resolve(v); };
|
||||
ov.addEventListener("click", e => {
|
||||
if (e.target === ov) return done(false);
|
||||
const b = e.target.closest("[data-x]");
|
||||
if (b) done(b.getAttribute("data-x") === "1");
|
||||
});
|
||||
document.body.appendChild(ov);
|
||||
ov.querySelector('[data-x="1"]').focus();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Eingabe-Dialog (ersetzt window.prompt) — gibt Promise<string|null>.
|
||||
// password:true blendet die Eingabe aus (für sudo-Passwort).
|
||||
// ---------------------------------------------------------------------------
|
||||
export function promptModal({ title, body = "", placeholder = "", password = false, confirmLabel = "Weiter", danger = false }) {
|
||||
return new Promise(resolve => {
|
||||
const ov = document.createElement("div");
|
||||
ov.className = "modal-overlay";
|
||||
ov.innerHTML = `<div class="modal-card" role="dialog" aria-modal="true">
|
||||
<h3>${esc(title)}</h3>
|
||||
${body ? `<p>${body}</p>` : ""}
|
||||
<input type="${password ? "password" : "text"}" placeholder="${esc(placeholder)}" autocomplete="off" style="margin-bottom:18px">
|
||||
<div class="modal-actions">
|
||||
<button data-x="0">Abbrechen</button>
|
||||
<button class="${danger ? "danger" : "primary"}" data-x="1">${esc(confirmLabel)}</button>
|
||||
</div></div>`;
|
||||
const inp = ov.querySelector("input");
|
||||
const done = v => { ov.remove(); resolve(v); };
|
||||
ov.addEventListener("click", e => {
|
||||
if (e.target === ov) return done(null);
|
||||
const b = e.target.closest("[data-x]");
|
||||
if (b) done(b.getAttribute("data-x") === "1" ? (inp.value || "") : null);
|
||||
});
|
||||
inp.addEventListener("keydown", e => { if (e.key === "Enter") done(inp.value || ""); if (e.key === "Escape") done(null); });
|
||||
document.body.appendChild(ov);
|
||||
inp.focus();
|
||||
});
|
||||
}
|
||||
|
||||
+196
-379
@@ -1,453 +1,270 @@
|
||||
// cookbook.js — "App-Store" für Modelle (v3): Hardware-Fit als Ampel, Klartext-Urteile.
|
||||
// Backend: /api/cookbook/{analyze,evaluate} (hw_math), /api/download, /api/register.
|
||||
|
||||
import { api } from "../core/api.js";
|
||||
import { $, esc, icon, toast } from "../core/ui.js";
|
||||
import { track } from "./jobs.js";
|
||||
|
||||
const CURATED_MODELS = [
|
||||
{
|
||||
id: "qwen-coder-32b",
|
||||
name: "Qwen 2.5 Coder 32B",
|
||||
repo: "unsloth/Qwen2.5-Coder-32B-Instruct-GGUF",
|
||||
file: "Qwen2.5-Coder-32B-Instruct-Q4_K_M.gguf",
|
||||
desc: "Top-Tier lokales Coder-Modell. Braucht ca. 24GB VRAM.",
|
||||
params_b: 32, quant: "Q4_K_M", ctx: 32768, alias: "coder"
|
||||
},
|
||||
{
|
||||
id: "qwen-coder-7b",
|
||||
name: "Qwen 2.5 Coder 7B",
|
||||
repo: "unsloth/Qwen2.5-Coder-7B-Instruct-GGUF",
|
||||
file: "Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf",
|
||||
desc: "Schneller Coder. Perfekte Balance aus Speed und Qualität.",
|
||||
params_b: 7, quant: "Q4_K_M", ctx: 32768, alias: "coder-fast"
|
||||
},
|
||||
{
|
||||
id: "llama3-vision-11b",
|
||||
name: "Llama 3.2 Vision 11B",
|
||||
repo: "unsloth/Llama-3.2-11B-Vision-Instruct-GGUF",
|
||||
file: "Llama-3.2-11B-Vision-Instruct-Q4_K_M.gguf",
|
||||
desc: "Modell für Bilderkennung und multimodale Tasks.",
|
||||
params_b: 11, quant: "Q4_K_M", ctx: 8192, alias: "vision"
|
||||
},
|
||||
{
|
||||
id: "qwen-general-7b",
|
||||
name: "Qwen 2.5 7B",
|
||||
repo: "unsloth/Qwen2.5-7B-Instruct-GGUF",
|
||||
file: "Qwen2.5-7B-Instruct-Q4_K_M.gguf",
|
||||
desc: "Hervorragendes Generalist/Scout Modell.",
|
||||
params_b: 7, quant: "Q4_K_M", ctx: 8192, alias: "scout"
|
||||
}
|
||||
const CURATED = [
|
||||
{ name: "Qwen 2.5 Coder 7B", repo: "unsloth/Qwen2.5-Coder-7B-Instruct-GGUF", file: "Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf",
|
||||
desc: "Schnell und locker auf deiner Kiste — ideal zum Programmieren.", params_b: 7, quant: "Q4_K_M", ctx: 32768, alias: "coder-fast" },
|
||||
{ name: "Qwen 2.5 Coder 32B", repo: "unsloth/Qwen2.5-Coder-32B-Instruct-GGUF", file: "Qwen2.5-Coder-32B-Instruct-Q4_K_M.gguf",
|
||||
desc: "Mehr Können, etwas langsamer — der starke Allround-Coder.", params_b: 32, quant: "Q4_K_M", ctx: 32768, alias: "coder" },
|
||||
{ name: "Llama 3.2 Vision 11B", repo: "unsloth/Llama-3.2-11B-Vision-Instruct-GGUF", file: "Llama-3.2-11B-Vision-Instruct-Q4_K_M.gguf",
|
||||
desc: "Versteht Bilder und Text — gut für multimodale Aufgaben.", params_b: 11, quant: "Q4_K_M", ctx: 8192, alias: "vision" },
|
||||
{ name: "Qwen 2.5 7B", repo: "unsloth/Qwen2.5-7B-Instruct-GGUF", file: "Qwen2.5-7B-Instruct-Q4_K_M.gguf",
|
||||
desc: "Hervorragender Allrounder — schnell und vielseitig.", params_b: 7, quant: "Q4_K_M", ctx: 8192, alias: "scout" },
|
||||
];
|
||||
|
||||
// Lokale Mathe entfernt. Wir nutzen jetzt das Backend.
|
||||
const FILTERS = [
|
||||
{ id: "", label: "Alle" }, { id: "coder", label: "Coder" }, { id: "scout", label: "Scout" },
|
||||
{ id: "vision", label: "Vision" }, { id: "manager", label: "Manager" }, { id: "reviewer", label: "Reviewer" },
|
||||
];
|
||||
|
||||
let lastSys = null;
|
||||
let currentResults = [];
|
||||
let currentAnalysis = null;
|
||||
let activeFilter = "";
|
||||
|
||||
const FILTERS = [
|
||||
{ id: "", label: "Alle" },
|
||||
{ id: "vision", label: "Vision" },
|
||||
{ id: "scout", label: "Scout" },
|
||||
{ id: "coder", label: "Coder" },
|
||||
{ id: "manager", label: "Manager" },
|
||||
{ id: "reviewer", label: "Reviewer" }
|
||||
];
|
||||
const fitCls = lvl => lvl === "perfect" ? "ok" : lvl === "marginal" ? "warn" : "bad";
|
||||
|
||||
function mount() {
|
||||
const c = $(".view[data-view='cookbook']");
|
||||
|
||||
const filtersHtml = FILTERS.map(f =>
|
||||
`<button class="ghost filter-btn ${activeFilter === f.id ? 'active' : ''}" style="margin-right:8px; border-radius:16px; padding:6px 16px; ${activeFilter === f.id ? 'background:var(--hi); color:var(--bg)' : 'background:var(--bg); border:1px solid var(--line)'}" onclick="window.setFilter('${f.id}')">${f.label}</button>`
|
||||
).join("");
|
||||
|
||||
c.innerHTML = `
|
||||
<div class="card" style="padding-bottom:32px">
|
||||
<div style="max-width:600px; margin:0 auto; text-align:center;">
|
||||
<h2 style="margin-top:20px; margin-bottom:8px">App Store für Modelle</h2>
|
||||
<p class="meta" style="margin-bottom:24px">Entdecke Modelle live auf HuggingFace, Hardware-Fit inklusive.</p>
|
||||
|
||||
<div style="display:flex; gap:12px; position:relative">
|
||||
<input id="cb-search" class="tokin" style="flex:1; padding:12px 18px; font-size:16px; border-radius:12px;" placeholder="Nach Modellen suchen (z.B. Llama 3)...">
|
||||
<button class="primary" id="cb-btn-search" style="border-radius:12px; padding:0 24px;">Suchen</button>
|
||||
</div>
|
||||
|
||||
<div id="cb-filters" style="margin-top:20px; display:flex; justify-content:center; flex-wrap:wrap; gap:8px">
|
||||
${filtersHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pagehead"><div>
|
||||
<h1>Modell-Cookbook</h1>
|
||||
<div class="sub">Finde ein KI-Modell, das auf deinen Mini-PC passt — den Hardware-Check rechnen wir für dich aus.</div>
|
||||
</div></div>
|
||||
|
||||
<div class="card-h" style="margin-top:24px"><h3 id="cb-section-title">Kuratierte Empfehlungen</h3></div>
|
||||
<div class="flex gap-3" style="margin:4px 0 6px">
|
||||
<input id="cb-search" placeholder="Modell suchen, z.B. „Llama 3"…" style="margin:0;flex:1">
|
||||
<button class="primary" id="cb-btn-search" style="white-space:nowrap">Suchen</button>
|
||||
</div>
|
||||
<div id="cb-filters" class="flex gap-2" style="flex-wrap:wrap"></div>
|
||||
|
||||
<div class="card-h" style="margin-top:18px;align-items:center">
|
||||
<h3 id="cb-section-title">Empfohlen für deine Hardware</h3>
|
||||
<span class="chip" id="cb-hw">deine Hardware</span>
|
||||
</div>
|
||||
<div class="legend mb-4">
|
||||
<span><i style="background:var(--on)"></i>passt locker</span>
|
||||
<span><i style="background:var(--warn)"></i>läuft, aber knapp</span>
|
||||
<span><i style="background:var(--err)"></i>zu groß für deinen Speicher</span>
|
||||
</div>
|
||||
<div class="grid grid-3" id="cb-grid"></div>
|
||||
|
||||
<!-- Modal für HuggingFace Modell Details -->
|
||||
<div id="cb-modal" style="display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.8); z-index:100; align-items:center; justify-content:center;">
|
||||
<div class="card" style="width:100%; max-width:600px; max-height:80vh; overflow-y:auto; position:relative">
|
||||
<button id="cb-modal-close" class="ghost" style="position:absolute; top:12px; right:12px;">Schließen</button>
|
||||
<h2 id="cb-m-title" style="margin-top:0">Modell</h2>
|
||||
<p class="meta" id="cb-m-repo">repo/name</p>
|
||||
|
||||
<div style="margin-top:24px">
|
||||
<label>Wähle eine Quantisierung (GGUF-Datei)</label>
|
||||
<select id="cb-m-files" class="tokin" style="width:100%; margin-top:8px; padding:12px"></select>
|
||||
<div id="cb-m-loading" class="meta" style="margin-top:8px; display:none">Lade Dateien...</div>
|
||||
<div id="cb-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-card" style="max-width:560px">
|
||||
<button id="cb-modal-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
|
||||
<h3 id="cb-m-title">Modell</h3>
|
||||
<p class="mono-sm" id="cb-m-repo" style="margin:-4px 0 16px">repo/name</p>
|
||||
|
||||
<label>Quantisierung (GGUF-Datei) wählen</label>
|
||||
<select id="cb-m-files"></select>
|
||||
<div class="hint" id="cb-m-loading" style="display:none">Lade Dateien von HuggingFace…</div>
|
||||
|
||||
<div class="row" style="margin-top:4px">
|
||||
<div><label>Alias (Rolle)</label><input id="cb-m-alias" placeholder="z.B. coder"></div>
|
||||
<div><label>Kontext-Größe</label><input id="cb-m-ctx" type="number" value="8192"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:24px; display:flex; gap:12px">
|
||||
<div style="flex:1">
|
||||
<label>Alias (Rolle)</label>
|
||||
<input id="cb-m-alias" class="tokin" style="width:100%; margin-top:8px" placeholder="z.B. coder">
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label>Context-Size</label>
|
||||
<input id="cb-m-ctx" class="tokin" style="width:100%; margin-top:8px" value="8192" type="number">
|
||||
</div>
|
||||
<div id="cb-m-fit" class="tile" style="display:flex;justify-content:space-between;align-items:center;margin:8px 0 18px">
|
||||
<div><div style="font-size:13px">Ressourcen-Check</div>
|
||||
<div class="hint" id="cb-m-fit-text" style="margin:4px 0 0">Berechne…</div></div>
|
||||
<span id="cb-m-fit-badge"></span>
|
||||
</div>
|
||||
|
||||
<div id="cb-m-fit-container" style="margin-top:24px; padding:16px; background:var(--bg); border:1px solid var(--line); border-radius:8px; display:flex; justify-content:space-between; align-items:center;">
|
||||
<div>
|
||||
<div style="font-weight:600; font-size:14px; margin-bottom:4px;">Ressourcen-Check</div>
|
||||
<div class="meta" id="cb-m-fit-text">Berechne...</div>
|
||||
</div>
|
||||
<div id="cb-m-fit-badge"></div>
|
||||
</div>
|
||||
|
||||
<button class="primary" id="cb-m-download" style="width:100%; margin-top:24px; padding:12px">Herunterladen & Einpflegen</button>
|
||||
<button class="primary" id="cb-m-download" style="width:100%">Herunterladen & Einpflegen</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
</div>`;
|
||||
|
||||
renderFilters();
|
||||
$("#cb-btn-search").addEventListener("click", doSearch);
|
||||
$("#cb-search").addEventListener("keydown", e => { if (e.key === "Enter") doSearch(); });
|
||||
$("#cb-modal-close").addEventListener("click", () => $("#cb-modal").style.display = "none");
|
||||
$("#cb-modal").addEventListener("click", e => { if (e.target.id === "cb-modal") $("#cb-modal").style.display = "none"; });
|
||||
$("#cb-m-download").addEventListener("click", doDownload);
|
||||
|
||||
$("#cb-m-files").addEventListener("change", updateLiveFit);
|
||||
$("#cb-m-ctx").addEventListener("change", reanalyzeCtx);
|
||||
|
||||
renderHwChip();
|
||||
renderCurated();
|
||||
}
|
||||
|
||||
// Aktuelle Analyse-Daten vom Backend
|
||||
function renderFilters() {
|
||||
$("#cb-filters").innerHTML = FILTERS.map(f =>
|
||||
`<button class="${activeFilter === f.id ? "primary" : "ghost"}" data-f="${f.id}" style="border-radius:999px">${f.label}</button>`
|
||||
).join("");
|
||||
$("#cb-filters").querySelectorAll("[data-f]").forEach(b =>
|
||||
b.addEventListener("click", () => { activeFilter = b.getAttribute("data-f"); renderFilters(); doSearch(); }));
|
||||
}
|
||||
|
||||
function updateLiveFit() {
|
||||
const file = $("#cb-m-files").value;
|
||||
if (!currentAnalysis || !file) {
|
||||
$("#cb-m-fit-container").style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const fData = currentAnalysis.files.find(f => f.filename === file);
|
||||
if (!fData) return;
|
||||
|
||||
const fit = fData.fit;
|
||||
const cls = fit.level === "perfect" ? "b-run" : (fit.level === "marginal" ? "b-load" : "b-err");
|
||||
|
||||
$("#cb-m-fit-container").style.display = "flex";
|
||||
$("#cb-m-fit-text").innerHTML = `Geschätzter Bedarf: <b>~${fit.req_gb.toFixed(1)} GB RAM/VRAM</b> <br><small class="meta">${currentAnalysis.params_b}B Params · ${fData.quant} · ~${Math.round(fit.tps)} t/s</small>`;
|
||||
$("#cb-m-fit-badge").innerHTML = `<span class="badge ${cls}">${fit.text}</span>`;
|
||||
|
||||
// Wenn "too_tight", machen wir den Download-Button gelb zur Warnung, erlauben ihn aber
|
||||
const btn = $("#cb-m-download");
|
||||
if (fit.level === "too_tight") {
|
||||
btn.className = "primary warn";
|
||||
btn.innerHTML = "Trotzdem herunterladen (OOM Risiko!)";
|
||||
} else {
|
||||
btn.className = "primary";
|
||||
btn.innerHTML = "Herunterladen & Einpflegen";
|
||||
function renderHwChip() {
|
||||
const el = $("#cb-hw"); if (!el) return;
|
||||
if (lastSys?.ram?.total) {
|
||||
el.innerHTML = `${icon("cpu")}${Math.round(lastSys.ram.total / 1024 ** 3)} GB Speicher`;
|
||||
}
|
||||
}
|
||||
|
||||
async function reanalyzeCtx() {
|
||||
if (!currentAnalysis) return;
|
||||
const ctx = parseInt($("#cb-m-ctx").value) || 8192;
|
||||
const repo = currentAnalysis.repo;
|
||||
const file = $("#cb-m-files").value;
|
||||
|
||||
$("#cb-m-fit-text").innerHTML = "Berechne neues Context-Limit...";
|
||||
// ---- Karten ----
|
||||
function metricLine(fit) { return `~${fit.req_gb.toFixed(1)} GB · ~${Math.round(fit.tps)} Tok/s`; }
|
||||
|
||||
function curatedCard(m, i, fit) {
|
||||
return `<div class="card" style="display:flex;flex-direction:column;cursor:pointer" data-cur="${i}">
|
||||
<div class="flex justify-between items-center"><h3 style="margin:0;font-size:15px;font-weight:500">${esc(m.name)}</h3>
|
||||
<span class="fit-badge ${fitCls(fit.level)}">${esc(fit.text)}</span></div>
|
||||
<div class="text-sm text-mut" style="margin:12px 0;flex:1;line-height:1.5">${esc(m.desc)}</div>
|
||||
<div class="flex justify-between items-center text-xs text-mut" style="border-top:1px solid var(--line);padding-top:11px">
|
||||
<span class="mono-sm">${metricLine(fit)}</span><span class="mono-sm">${esc(m.quant)}</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function renderCurated() {
|
||||
$("#cb-section-title").textContent = "Empfohlen für deine Hardware";
|
||||
const grid = $("#cb-grid"); if (!grid) return;
|
||||
grid.innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Berechne Hardware-Fit…</div>`;
|
||||
try {
|
||||
const res = await api("/api/cookbook/analyze", {
|
||||
method: "POST", body: JSON.stringify({ repo_id: repo, ctx })
|
||||
});
|
||||
currentAnalysis = res;
|
||||
// Auswahl beibehalten
|
||||
$("#cb-m-files").value = file;
|
||||
updateLiveFit();
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
window.setFilter = (filterId) => {
|
||||
activeFilter = filterId;
|
||||
const buttons = document.querySelectorAll('.filter-btn');
|
||||
buttons.forEach(b => {
|
||||
b.style.background = 'var(--bg)';
|
||||
b.style.color = '';
|
||||
b.style.border = '1px solid var(--line)';
|
||||
});
|
||||
|
||||
const idx = FILTERS.findIndex(f => f.id === filterId);
|
||||
if (idx !== -1 && buttons[idx]) {
|
||||
buttons[idx].style.background = 'var(--hi)';
|
||||
buttons[idx].style.color = 'var(--bg)';
|
||||
buttons[idx].style.border = 'none';
|
||||
let html = "";
|
||||
for (let i = 0; i < CURATED.length; i++) {
|
||||
const m = CURATED[i];
|
||||
const fit = await api("/api/cookbook/evaluate", { method: "POST", body: JSON.stringify({ params_b: m.params_b, quant: m.quant, ctx: m.ctx }) });
|
||||
html += curatedCard(m, i, fit);
|
||||
}
|
||||
grid.innerHTML = html;
|
||||
grid.querySelectorAll("[data-cur]").forEach(el => el.addEventListener("click", () => openCurated(+el.getAttribute("data-cur"))));
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<div class="alert err" style="grid-column:1/-1">Konnte Empfehlungen nicht laden: ${esc(e.message)}</div>`;
|
||||
}
|
||||
|
||||
doSearch();
|
||||
};
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
let q = $("#cb-search").value.trim();
|
||||
|
||||
if (activeFilter) {
|
||||
q = q ? (q + " " + activeFilter) : activeFilter;
|
||||
}
|
||||
|
||||
if (activeFilter) q = q ? q + " " + activeFilter : activeFilter;
|
||||
if (!q) return renderCurated();
|
||||
|
||||
const btn = $("#cb-btn-search");
|
||||
btn.disabled = true; btn.textContent = "Lade...";
|
||||
$("#cb-section-title").textContent = "Suchergebnisse für: " + esc(q);
|
||||
$("#cb-grid").innerHTML = `<div class="meta" style="grid-column:1/-1; text-align:center; padding:40px">Suche auf HuggingFace...</div>`;
|
||||
|
||||
const btn = $("#cb-btn-search"); btn.disabled = true; btn.textContent = "Lade…";
|
||||
$("#cb-section-title").textContent = "Suchergebnisse: " + q;
|
||||
const grid = $("#cb-grid");
|
||||
grid.innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Suche auf HuggingFace…</div>`;
|
||||
try {
|
||||
const url = `https://huggingface.co/api/models?search=${encodeURIComponent(q)}&filter=gguf&sort=downloads&direction=-1&limit=12`;
|
||||
const r = await fetch(url);
|
||||
const data = await r.json();
|
||||
currentResults = data;
|
||||
renderResults(data);
|
||||
currentResults = await r.json();
|
||||
renderResults(currentResults);
|
||||
} catch (e) {
|
||||
$("#cb-grid").innerHTML = `<div class="alert err" style="grid-column:1/-1">${esc(e.message)}</div>`;
|
||||
grid.innerHTML = `<div class="alert err" style="grid-column:1/-1">${esc(e.message)}</div>`;
|
||||
}
|
||||
btn.disabled = false; btn.textContent = "Suchen";
|
||||
}
|
||||
|
||||
function renderResults(results) {
|
||||
const grid = $("#cb-grid");
|
||||
if (!results || results.length === 0) {
|
||||
grid.innerHTML = `<div class="meta" style="grid-column:1/-1; text-align:center; padding:40px">Keine GGUF-Modelle gefunden.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!results?.length) { grid.innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Keine GGUF-Modelle gefunden.</div>`; return; }
|
||||
grid.innerHTML = results.map((m, i) => `
|
||||
<div class="card" style="display:flex; flex-direction:column; cursor:pointer" onclick="window.openModelModal(${i})">
|
||||
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:8px;">
|
||||
<div>
|
||||
<h3 style="margin:0; font-size:16px; word-break:break-all">${esc(m.id.split('/').pop())}</h3>
|
||||
<div class="meta" style="font-size:12px; margin-top:4px">${esc(m.author)}</div>
|
||||
</div>
|
||||
<span id="cb-s-badge-${i}" class="badge b-load" style="white-space:nowrap;">Analysiere...</span>
|
||||
<div class="card" style="display:flex;flex-direction:column;cursor:pointer" data-res="${i}">
|
||||
<div class="flex justify-between" style="align-items:flex-start;gap:8px">
|
||||
<div style="min-width:0"><h3 style="margin:0;font-size:14.5px;font-weight:500;word-break:break-word">${esc(m.id.split("/").pop())}</h3>
|
||||
<div class="text-xs text-mut" style="margin-top:3px">${esc(m.author || "")}</div></div>
|
||||
<span class="fit-badge warn" id="cb-b-${i}">prüfe…</span>
|
||||
</div>
|
||||
<div style="flex:1; margin-top:16px;"></div>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:16px; font-size:12px" class="meta">
|
||||
<span id="cb-s-metrics-${i}">Berechne Hardware-Fit...</span>
|
||||
<span style="display:flex; gap:8px; align-items:center">
|
||||
<span>⬇ ${(m.downloads||0).toLocaleString()}</span>
|
||||
<span id="cb-s-quant-${i}" class="badge b-idle" style="font-size:11px">GGUF</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
// Lade Metriken im Hintergrund
|
||||
results.forEach((m, i) => fetchAnalysisForCard(i, m.id));
|
||||
<div style="flex:1;margin-top:12px"></div>
|
||||
<div class="flex justify-between items-center text-xs text-mut" style="border-top:1px solid var(--line);padding-top:11px">
|
||||
<span class="mono-sm" id="cb-m-${i}">Hardware-Fit…</span>
|
||||
<span class="mono-sm">⬇ ${(m.downloads || 0).toLocaleString()}</span></div>
|
||||
</div>`).join("");
|
||||
grid.querySelectorAll("[data-res]").forEach(el => el.addEventListener("click", () => openResult(+el.getAttribute("data-res"))));
|
||||
results.forEach((m, i) => fetchFitForCard(i, m.id));
|
||||
}
|
||||
|
||||
async function fetchAnalysisForCard(index, repo_id) {
|
||||
async function fetchFitForCard(i, repo_id) {
|
||||
try {
|
||||
const res = await api("/api/cookbook/analyze", {
|
||||
method: "POST", body: JSON.stringify({ repo_id, ctx: 8192 })
|
||||
});
|
||||
|
||||
const badgeEl = document.getElementById(`cb-s-badge-${index}`);
|
||||
const metricsEl = document.getElementById(`cb-s-metrics-${index}`);
|
||||
const quantEl = document.getElementById(`cb-s-quant-${index}`);
|
||||
|
||||
if (!badgeEl || !metricsEl || !quantEl) return; // card might be gone
|
||||
|
||||
if (!res.files || res.files.length === 0) {
|
||||
badgeEl.className = "badge b-err";
|
||||
badgeEl.textContent = "Keine GGUFs";
|
||||
metricsEl.textContent = "Keine Dateien gefunden";
|
||||
return;
|
||||
}
|
||||
|
||||
let best = res.files.find(f => f.quant && f.quant.includes("Q4_K_M"));
|
||||
if (!best) best = res.files[0];
|
||||
|
||||
const fit = best.fit;
|
||||
const cls = fit.level === "perfect" ? "b-run" : (fit.level === "marginal" ? "b-load" : "b-err");
|
||||
|
||||
badgeEl.className = `badge ${cls}`;
|
||||
badgeEl.textContent = fit.text;
|
||||
metricsEl.innerHTML = `~${fit.req_gb.toFixed(1)} GB RAM/VRAM · ~${Math.round(fit.tps)} t/s`;
|
||||
quantEl.textContent = best.quant || "GGUF";
|
||||
|
||||
} catch(e) {
|
||||
const badgeEl = document.getElementById(`cb-s-badge-${index}`);
|
||||
const metricsEl = document.getElementById(`cb-s-metrics-${index}`);
|
||||
if (badgeEl) {
|
||||
badgeEl.className = "badge b-err";
|
||||
badgeEl.textContent = "Fehler";
|
||||
}
|
||||
if (metricsEl) {
|
||||
metricsEl.textContent = "Metriken konnten nicht geladen werden";
|
||||
}
|
||||
const res = await api("/api/cookbook/analyze", { method: "POST", body: JSON.stringify({ repo_id, ctx: 8192 }) });
|
||||
const b = $("#cb-b-" + i), mt = $("#cb-m-" + i);
|
||||
if (!b || !mt) return;
|
||||
if (!res.files?.length) { b.className = "fit-badge bad"; b.textContent = "keine GGUFs"; mt.textContent = "—"; return; }
|
||||
let best = res.files.find(f => f.quant?.includes("Q4_K_M")) || res.files[0];
|
||||
b.className = "fit-badge " + fitCls(best.fit.level); b.textContent = best.fit.text;
|
||||
mt.textContent = metricLine(best.fit) + " · " + (best.quant || "GGUF");
|
||||
} catch {
|
||||
const b = $("#cb-b-" + i); if (b) { b.className = "fit-badge bad"; b.textContent = "Fehler"; }
|
||||
}
|
||||
}
|
||||
|
||||
// Global hook for inline onclick
|
||||
window.openModelModal = async (index) => {
|
||||
const m = currentResults[index];
|
||||
if (!m) return;
|
||||
$("#cb-modal").style.display = "flex";
|
||||
$("#cb-m-title").textContent = m.id.split('/').pop();
|
||||
$("#cb-m-repo").textContent = m.id;
|
||||
$("#cb-m-files").innerHTML = "";
|
||||
$("#cb-m-alias").value = m.id.split('/').pop().toLowerCase().replace(/[^a-z0-9]/g, "-");
|
||||
|
||||
$("#cb-m-files").style.display = "none";
|
||||
$("#cb-m-loading").style.display = "block";
|
||||
$("#cb-m-download").disabled = true;
|
||||
// ---- Modal ----
|
||||
function showFit() {
|
||||
const file = $("#cb-m-files").value;
|
||||
const f = currentAnalysis?.files.find(x => x.filename === file);
|
||||
if (!f) { $("#cb-m-fit").style.display = "none"; return; }
|
||||
$("#cb-m-fit").style.display = "flex";
|
||||
$("#cb-m-fit-text").innerHTML = `Bedarf: <b>~${f.fit.req_gb.toFixed(1)} GB</b> · ${currentAnalysis.params_b}B · ${esc(f.quant)} · ~${Math.round(f.fit.tps)} Tok/s`;
|
||||
$("#cb-m-fit-badge").innerHTML = `<span class="fit-badge ${fitCls(f.fit.level)}">${esc(f.fit.text)}</span>`;
|
||||
const btn = $("#cb-m-download");
|
||||
if (f.fit.level === "too_tight") { btn.className = "primary warn"; btn.textContent = "Trotzdem holen (zu groß)"; }
|
||||
else { btn.className = "primary"; btn.textContent = "Herunterladen & Einpflegen"; }
|
||||
}
|
||||
function updateLiveFit() { showFit(); }
|
||||
|
||||
async function reanalyzeCtx() {
|
||||
if (!currentAnalysis) return;
|
||||
const ctx = parseInt($("#cb-m-ctx").value) || 8192;
|
||||
const file = $("#cb-m-files").value;
|
||||
try {
|
||||
currentAnalysis = await api("/api/cookbook/analyze", { method: "POST", body: JSON.stringify({ repo_id: currentAnalysis.repo, ctx }) });
|
||||
$("#cb-m-files").value = file; showFit();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function openModalBase(title, repo) {
|
||||
$("#cb-modal").style.display = "flex";
|
||||
$("#cb-m-title").textContent = title;
|
||||
$("#cb-m-repo").textContent = repo;
|
||||
}
|
||||
|
||||
async function openResult(i) {
|
||||
const m = currentResults[i]; if (!m) return;
|
||||
openModalBase(m.id.split("/").pop(), m.id);
|
||||
$("#cb-m-alias").value = m.id.split("/").pop().toLowerCase().replace(/[^a-z0-9]/g, "-");
|
||||
$("#cb-m-files").style.display = "none"; $("#cb-m-loading").style.display = "block"; $("#cb-m-download").disabled = true;
|
||||
try {
|
||||
const ctx = parseInt($("#cb-m-ctx").value) || 8192;
|
||||
const res = await api("/api/cookbook/analyze", {
|
||||
method: "POST", body: JSON.stringify({ repo_id: m.id, ctx })
|
||||
});
|
||||
|
||||
currentAnalysis = res;
|
||||
|
||||
$("#cb-m-loading").style.display = "none";
|
||||
$("#cb-m-files").style.display = "block";
|
||||
|
||||
if (!res.files || res.files.length === 0) {
|
||||
$("#cb-m-files").innerHTML = "<option value=''>Keine GGUF-Dateien gefunden.</option>";
|
||||
$("#cb-m-fit-container").style.display = "none";
|
||||
} else {
|
||||
// Optische Indikatoren im Dropdown
|
||||
$("#cb-m-files").innerHTML = res.files.map(f => {
|
||||
let mark = "";
|
||||
if (f.fit.level === "perfect") mark = "🟢";
|
||||
else if (f.fit.level === "marginal") mark = "🟡";
|
||||
else mark = "🔴";
|
||||
return `<option value="${esc(f.filename)}">${mark} ${esc(f.filename)}</option>`;
|
||||
}).join("");
|
||||
|
||||
$("#cb-m-download").disabled = false;
|
||||
updateLiveFit();
|
||||
}
|
||||
} catch(e) {
|
||||
$("#cb-m-loading").textContent = "Fehler: " + e.message;
|
||||
}
|
||||
};
|
||||
currentAnalysis = await api("/api/cookbook/analyze", { method: "POST", body: JSON.stringify({ repo_id: m.id, ctx }) });
|
||||
$("#cb-m-loading").style.display = "none"; $("#cb-m-files").style.display = "block";
|
||||
if (!currentAnalysis.files?.length) { $("#cb-m-files").innerHTML = "<option>Keine GGUF-Dateien gefunden</option>"; $("#cb-m-fit").style.display = "none"; return; }
|
||||
$("#cb-m-files").innerHTML = currentAnalysis.files.map(f => {
|
||||
const mark = f.fit.level === "perfect" ? "●" : f.fit.level === "marginal" ? "◐" : "○";
|
||||
return `<option value="${esc(f.filename)}">${mark} ${esc(f.filename)}</option>`;
|
||||
}).join("");
|
||||
$("#cb-m-download").disabled = false; showFit();
|
||||
} catch (e) { $("#cb-m-loading").textContent = "Fehler: " + e.message; }
|
||||
}
|
||||
|
||||
async function openCurated(i) {
|
||||
const m = CURATED[i]; if (!m) return;
|
||||
openModalBase(m.name, m.repo);
|
||||
$("#cb-m-files").style.display = "block"; $("#cb-m-loading").style.display = "none";
|
||||
$("#cb-m-files").innerHTML = `<option value="${esc(m.file)}">${esc(m.file)}</option>`;
|
||||
$("#cb-m-alias").value = m.alias; $("#cb-m-ctx").value = m.ctx; $("#cb-m-download").disabled = false;
|
||||
try {
|
||||
const fit = await api("/api/cookbook/evaluate", { method: "POST", body: JSON.stringify({ params_b: m.params_b, quant: m.quant, ctx: m.ctx }) });
|
||||
currentAnalysis = { repo: m.repo, params_b: m.params_b, files: [{ filename: m.file, quant: m.quant, fit }] };
|
||||
showFit();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function doDownload() {
|
||||
const repo = $("#cb-m-repo").textContent;
|
||||
const file = $("#cb-m-files").value;
|
||||
const alias = $("#cb-m-alias").value.trim();
|
||||
const ctx = parseInt($("#cb-m-ctx").value) || 8192;
|
||||
|
||||
const repo = $("#cb-m-repo").textContent, file = $("#cb-m-files").value;
|
||||
const alias = $("#cb-m-alias").value.trim(), ctx = parseInt($("#cb-m-ctx").value) || 8192;
|
||||
if (!repo || !file || !alias) return toast("Bitte alle Felder ausfüllen.", true);
|
||||
|
||||
$("#cb-m-download").disabled = true;
|
||||
$("#cb-m-download").textContent = "Starte...";
|
||||
|
||||
const btn = $("#cb-m-download"); btn.disabled = true; btn.textContent = "Starte…";
|
||||
try {
|
||||
const res = await api("/api/download", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ repo, file })
|
||||
});
|
||||
|
||||
// Register directly, jobengine will download it, MC will pick it up when done
|
||||
await api("/api/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ alias, model_path: res.expected_path, ctx })
|
||||
});
|
||||
|
||||
toast("Download gestartet! Siehe Aktivitäten.");
|
||||
const res = await api("/api/download", { method: "POST", body: JSON.stringify({ repo, file }) });
|
||||
await api("/api/register", { method: "POST", body: JSON.stringify({ alias, model_path: res.expected_path, ctx }) });
|
||||
toast("Download gestartet — siehe Aktivität.");
|
||||
$("#cb-modal").style.display = "none";
|
||||
track(res.job_id); // open in jobs
|
||||
// Switch to activity tab
|
||||
document.querySelector(".nav-item[data-view='activity']").click();
|
||||
|
||||
} catch (e) {
|
||||
toast("Fehler: " + e.message, true);
|
||||
}
|
||||
|
||||
$("#cb-m-download").disabled = false;
|
||||
$("#cb-m-download").textContent = "Herunterladen & Einpflegen";
|
||||
document.querySelector(".nav-item[data-view='activity']")?.click();
|
||||
} catch (e) { toast("Fehler: " + e.message, true); }
|
||||
btn.disabled = false; btn.textContent = "Herunterladen & Einpflegen";
|
||||
}
|
||||
|
||||
async function renderCurated() {
|
||||
$("#cb-section-title").textContent = "Kuratierte Empfehlungen";
|
||||
const grid = $("#cb-grid");
|
||||
if (!grid) return;
|
||||
|
||||
grid.innerHTML = "<div class='meta' style='grid-column:1/-1;text-align:center;padding:40px'>Berechne Hardware-Fit für Empfehlungen...</div>";
|
||||
|
||||
try {
|
||||
let html = "";
|
||||
for (let i = 0; i < CURATED_MODELS.length; i++) {
|
||||
const m = CURATED_MODELS[i];
|
||||
const fit = await api("/api/cookbook/evaluate", {
|
||||
method: "POST", body: JSON.stringify({ params_b: m.params_b, quant: m.quant, ctx: m.ctx })
|
||||
});
|
||||
|
||||
const cls = fit.level === "perfect" ? "b-run" : (fit.level === "marginal" ? "b-load" : "b-err");
|
||||
|
||||
html += `
|
||||
<div class="card" style="display:flex; flex-direction:column; cursor:pointer" onclick="window.openCuratedModal(${i})">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<h3 style="margin:0; font-size:16px">${esc(m.name)}</h3>
|
||||
<span class="badge ${cls}">${fit.text}</span>
|
||||
</div>
|
||||
<div style="font-size:13px; color:var(--mut); margin-top:12px; flex:1; line-height:1.5;">
|
||||
${m.desc}
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between; margin-top:16px; font-size:12px" class="meta">
|
||||
<span>~${fit.req_gb.toFixed(1)} GB RAM/VRAM · ~${Math.round(fit.tps)} t/s</span>
|
||||
<span>${m.quant}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
grid.innerHTML = html;
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<div class="alert err" style="grid-column:1/-1">Fehler beim Laden der Empfehlungen: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
function onSystem(sys) { lastSys = sys; renderHwChip(); }
|
||||
|
||||
window.openCuratedModal = async (index) => {
|
||||
const m = CURATED_MODELS[index];
|
||||
if (!m) return;
|
||||
$("#cb-modal").style.display = "flex";
|
||||
$("#cb-m-title").textContent = m.name;
|
||||
$("#cb-m-repo").textContent = m.repo;
|
||||
$("#cb-m-files").innerHTML = `<option value="${esc(m.file)}">${esc(m.file)}</option>`;
|
||||
$("#cb-m-files").style.display = "block";
|
||||
$("#cb-m-loading").style.display = "none";
|
||||
$("#cb-m-alias").value = m.alias;
|
||||
$("#cb-m-ctx").value = m.ctx;
|
||||
$("#cb-m-download").disabled = false;
|
||||
|
||||
// Wir nutzen die neue API Struktur auch für das simulierte Modal
|
||||
try {
|
||||
const fit = await api("/api/cookbook/evaluate", {
|
||||
method: "POST", body: JSON.stringify({ params_b: m.params_b, quant: m.quant, ctx: m.ctx })
|
||||
});
|
||||
currentAnalysis = {
|
||||
repo: m.repo,
|
||||
params_b: m.params_b,
|
||||
files: [{ filename: m.file, quant: m.quant, fit: fit }]
|
||||
};
|
||||
updateLiveFit();
|
||||
} catch(e) {}
|
||||
};
|
||||
|
||||
function onSystem(sys) {
|
||||
lastSys = sys;
|
||||
}
|
||||
|
||||
function unmount() {}
|
||||
|
||||
export default { id: "cookbook", mount, unmount, onSystem };
|
||||
export default { id: "cookbook", mount, onSystem };
|
||||
|
||||
+51
-64
@@ -1,102 +1,89 @@
|
||||
// guides.js — Integrations-Anleitungen (v3): Copy-Paste-Configs für externe Tools.
|
||||
|
||||
import { $ } from "../core/ui.js";
|
||||
|
||||
let currentUrl = "http://localhost:8000";
|
||||
// Basis-URL aus dem Browser ableiten (zeigt die echte Bosgame-Adresse statt localhost).
|
||||
function apiBase() {
|
||||
const host = location.hostname || "localhost";
|
||||
return `${location.protocol}//${host}:8080/v1`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const c = document.querySelector(".view[data-view='guides']");
|
||||
const c = $(".view[data-view='guides']");
|
||||
if (!c) return;
|
||||
|
||||
const url = apiBase();
|
||||
c.innerHTML = `
|
||||
<div class="card-h"><h3>Guides & Integrationen</h3></div>
|
||||
<div class="card" style="padding:0; overflow:hidden;">
|
||||
|
||||
<div class="pagehead"><div>
|
||||
<h1>Guides & Integrationen</h1>
|
||||
<div class="sub">Binde deine lokalen Modelle in andere Tools ein — fertige Configs zum Kopieren.</div></div></div>
|
||||
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<details class="guide-acc">
|
||||
<summary>Cline / Cursor (Juni 2026)</summary>
|
||||
<summary>Cline / Cursor</summary>
|
||||
<div class="acc-body">
|
||||
<p>Nutze deine lokalen Modelle kostenlos in der Cursor IDE und Cline.</p>
|
||||
<label>Cursor: Settings -> Models -> Add Custom Model</label>
|
||||
<input class="tokin mono-sm" readonly value="coder">
|
||||
<label>Base URL (OpenAI API)</label>
|
||||
<input class="tokin mono-sm" readonly value="${currentUrl}/v1">
|
||||
<label>API Key</label>
|
||||
<input class="tokin mono-sm" readonly value="MissionControl-Token-oder-leer">
|
||||
<p style="margin-top:12px; font-size:12px; color:var(--mut);">
|
||||
In Cline: Wähle "OpenAI Compatible" als Provider.
|
||||
</p>
|
||||
<p>Nutze deine lokalen Modelle kostenlos in Cursor oder Cline (Provider: „OpenAI Compatible").</p>
|
||||
<label>Modell-ID</label>
|
||||
<input class="mono-sm" readonly value="coder">
|
||||
<label>Basis-URL (OpenAI-kompatibel)</label>
|
||||
<input class="mono-sm" readonly value="${url}">
|
||||
<label>API-Key</label>
|
||||
<input class="mono-sm" readonly value="(dein Token oder leer lassen)">
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="guide-acc">
|
||||
<summary>OpenWebUI (Pipes & Connections)</summary>
|
||||
<summary>OpenWebUI</summary>
|
||||
<div class="acc-body">
|
||||
<p>Verbinde llama-swap nativ in OpenWebUI.</p>
|
||||
<label>Settings -> Admin Settings -> Connections</label>
|
||||
<input class="tokin mono-sm" readonly value="${currentUrl}/v1">
|
||||
<label>API Key</label>
|
||||
<input class="tokin mono-sm" readonly value="dummy-key">
|
||||
<p style="margin-top:12px; font-size:12px; color:var(--mut);">
|
||||
OpenWebUI erkennt nun vollautomatisch, wenn ein Modell ausgetauscht wird.
|
||||
</p>
|
||||
<p>Settings → Admin → Connections → neue OpenAI-Verbindung:</p>
|
||||
<label>Basis-URL</label>
|
||||
<input class="mono-sm" readonly value="${url}">
|
||||
<label>API-Key</label>
|
||||
<input class="mono-sm" readonly value="dummy-key">
|
||||
<p class="hint" style="margin-top:10px">OpenWebUI erkennt automatisch, wenn ein Modell getauscht wird.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="guide-acc">
|
||||
<summary>Python / LangChain (OpenAI SDK)</summary>
|
||||
<summary>Python / LangChain (openai-SDK)</summary>
|
||||
<div class="acc-body">
|
||||
<p>Nutze das offizielle <code>openai</code> Package, um mit llama-swap zu sprechen.</p>
|
||||
<pre style="background: var(--bg); padding: 12px; border-radius: 8px; border: 1px solid var(--line); overflow-x: auto; color: var(--tx); font-family: monospace; font-size: 13px;"><code>from openai import OpenAI
|
||||
<p>Mit dem offiziellen <code>openai</code>-Paket:</p>
|
||||
<div class="log" style="max-height:none"><code>from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="${currentUrl}/v1",
|
||||
api_key="Dein_Token" # Optional
|
||||
base_url="${url}",
|
||||
api_key="dein_token" # optional
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="coder", # Alias aus deinem Cookbook
|
||||
resp = client.chat.completions.create(
|
||||
model="coder", # Alias aus dem Cookbook
|
||||
messages=[{"role": "user", "content": "Hallo Modell!"}]
|
||||
)
|
||||
|
||||
print(response.choices[0].message.content)</code></pre>
|
||||
print(resp.choices[0].message.content)</code></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="guide-acc">
|
||||
<summary>n8n (AI Agent Nodes)</summary>
|
||||
<summary>n8n (AI-Agent-Nodes)</summary>
|
||||
<div class="acc-body">
|
||||
<p>Nutze den "OpenAI Chat Model" Node in n8n Advanced AI.</p>
|
||||
<label>Credentials -> OpenAI API -> Custom URL</label>
|
||||
<input class="tokin mono-sm" readonly value="${currentUrl}/v1">
|
||||
<p style="margin-top:12px; font-size:12px; color:var(--mut);">
|
||||
Einfach den Node verbinden, "coder" als Modell-ID (Expression) eintippen und loslegen.
|
||||
</p>
|
||||
<p>„OpenAI Chat Model"-Node → Credentials → Custom URL:</p>
|
||||
<label>Basis-URL</label>
|
||||
<input class="mono-sm" readonly value="${url}">
|
||||
<p class="hint" style="margin-top:10px">Modell-ID „coder" eintragen und loslegen.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
|
||||
<details class="guide-acc">
|
||||
<summary>Begrifflichkeiten (Glossar)</summary>
|
||||
<summary>Begriffe einfach erklärt (Glossar)</summary>
|
||||
<div class="acc-body">
|
||||
<p><b>LLM-Engine (llama-swap):</b> Der Server im Hintergrund, der die Sprachmodelle (LLMs) lädt und OpenAI-kompatible Schnittstellen bereitstellt.</p>
|
||||
<p><b>VRAM:</b> Der Grafikkarten-Speicher. KI-Modelle sind extrem groß und benötigen viel VRAM, um schnell zu laufen.</p>
|
||||
<p><b>Quantisierung (z.B. Q4_K_M):</b> Ein Verfahren, das die Genauigkeit der Modellgewichte leicht reduziert (von 16-Bit auf 4-Bit), damit sie in den VRAM passen, ohne signifikant dümmer zu werden.</p>
|
||||
<p><b>Context-Window:</b> Wie viel Text ("Tokens") sich das Modell gleichzeitig "merken" kann. Ein Buchstabe ist ~0.3 Tokens. Mehr Kontext braucht drastisch mehr VRAM.</p>
|
||||
<p><b>LLM-Engine (llama-swap):</b> Der Dienst im Hintergrund, der die Sprachmodelle lädt und eine OpenAI-kompatible Schnittstelle bereitstellt.</p>
|
||||
<p><b>VRAM / Grafikspeicher:</b> Der schnelle Speicher, in dem ein Modell laufen muss. Große Modelle brauchen viel davon.</p>
|
||||
<p><b>Quantisierung (z.B. Q4_K_M):</b> Verkleinert ein Modell (16-Bit → 4-Bit), damit es in den Speicher passt — bei kaum Qualitätsverlust.</p>
|
||||
<p><b>Kontext-Größe:</b> Wie viel Text sich das Modell gleichzeitig „merken" kann. Mehr Kontext = deutlich mehr Speicherbedarf.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
`;
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function mount() {
|
||||
render();
|
||||
}
|
||||
function mount() { render(); }
|
||||
|
||||
function onStatus(s) {
|
||||
if (s && s.swap_url) {
|
||||
if (currentUrl !== s.swap_url) {
|
||||
currentUrl = s.swap_url;
|
||||
render(); // Neu rendern, wenn sich die URL ändert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default { id: "guides", mount, onStatus };
|
||||
export default { id: "guides", mount };
|
||||
|
||||
+64
-104
@@ -1,142 +1,102 @@
|
||||
// jobs.js (Aktivität) — System-Metriken & Hintergrund-Jobs
|
||||
// jobs.js — Aktivität (v3): Live-System-Metriken + Hintergrund-Jobs mit Log.
|
||||
// Exportiert track(id), damit andere Panels einen Job auto-aufklappen.
|
||||
|
||||
import { $, esc, icon } from "../core/ui.js";
|
||||
import { $, esc, fmtBytes } from "../core/ui.js";
|
||||
|
||||
const tracked = new Set();
|
||||
let JOBS = [];
|
||||
let SYS = null;
|
||||
|
||||
export function track(id) {
|
||||
tracked.add(id);
|
||||
renderJobs();
|
||||
}
|
||||
export function track(id) { tracked.add(id); renderJobs(); }
|
||||
|
||||
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ädt…</span>';
|
||||
}
|
||||
function dotClass(state) {
|
||||
if (state === "done") return "on";
|
||||
if (state === "failed") return "";
|
||||
return "load";
|
||||
}
|
||||
|
||||
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 kvRow(k, v, cls = "") {
|
||||
return `<div class="kv-row"><span class="kv-k">${k}</span><span class="kv-v ${cls}">${v}</span></div>`;
|
||||
}
|
||||
|
||||
// Chart-Daten (letzte 30 Sekunden, je 0.5s Stream = 60 Punkte)
|
||||
const hist = { cpu: [], ram: [], gpu: [] };
|
||||
const MAX_HIST = 60;
|
||||
|
||||
function statusBadge(s) { return s === "done" ? '<span class="badge b-run">fertig</span>' : s === "failed" ? '<span class="badge b-err">fehler</span>' : '<span class="badge b-load">läuft…</span>'; }
|
||||
function dotClass(s) { return s === "done" ? "on" : s === "failed" ? "" : "load"; }
|
||||
|
||||
function tile(label, value, sub) {
|
||||
return `<div class="tile"><div class="t-l">${label}</div><div class="t-v">${value}</div><div class="t-s">${sub}</div></div>`;
|
||||
}
|
||||
function meter(label, pct) {
|
||||
const p = Math.max(0, Math.min(100, pct || 0));
|
||||
const cls = p >= 90 ? "bad" : p >= 75 ? "warn" : "";
|
||||
return `<div class="meter"><div class="meter-h"><span class="mk">${label}</span><span class="mv">${Math.round(p)} %</span></div><div class="bar ${cls}"><i style="width:${Math.max(2, p)}%"></i></div></div>`;
|
||||
}
|
||||
function spark(arr, varName) {
|
||||
return '<div style="display:flex;align-items:flex-end;gap:2px;height:38px;margin-top:10px">' +
|
||||
arr.map(v => `<div style="width:4px;background:var(${varName});opacity:.55;height:${Math.max(2, v)}%;border-radius:2px"></div>`).join("") + "</div>";
|
||||
}
|
||||
|
||||
function gpuPct() {
|
||||
const g = SYS?.gpu;
|
||||
if (g && (g.vram.total + g.gtt.total) > 0) return ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function renderSys() {
|
||||
if (!SYS) return;
|
||||
const sysV = `${SYS.cpu.percent.toFixed(0)}<small>% CPU</small>`;
|
||||
const sysS = `${SYS.ram.percent.toFixed(0)}% RAM, ${SYS.gpu_temp ? SYS.gpu_temp.toFixed(0)+'°C' : SYS.cpu.temp ? SYS.cpu.temp.toFixed(0)+'°C' : '–'}`;
|
||||
hist.cpu.push(SYS.cpu.percent); hist.ram.push(SYS.ram.percent); hist.gpu.push(gpuPct());
|
||||
for (const k of ["cpu", "ram", "gpu"]) if (hist[k].length > MAX_HIST) hist[k].shift();
|
||||
|
||||
// History aktualisieren
|
||||
hist.cpu.push(SYS.cpu.percent);
|
||||
hist.ram.push(SYS.ram.percent);
|
||||
let gpuP = 0;
|
||||
if (SYS.gpu && SYS.gpu.vram.total) {
|
||||
gpuP = ((SYS.gpu.vram.used + SYS.gpu.gtt.used) / (SYS.gpu.vram.total + SYS.gpu.gtt.total)) * 100;
|
||||
}
|
||||
hist.gpu.push(gpuP);
|
||||
if (hist.cpu.length > MAX_HIST) hist.cpu.shift();
|
||||
if (hist.ram.length > MAX_HIST) hist.ram.shift();
|
||||
if (hist.gpu.length > MAX_HIST) hist.gpu.shift();
|
||||
const k = $("#act-kpis");
|
||||
if (k) k.innerHTML =
|
||||
tile("Prozessor (CPU)", `${Math.round(SYS.cpu.percent)}<small> %</small>`, SYS.cpu.temp != null ? `${Math.round(SYS.cpu.temp)}° CPU-Temp` : "Auslastung") +
|
||||
tile("Arbeitsspeicher", `${Math.round(SYS.ram.percent)}<small> %</small>`, `${fmtBytes(SYS.ram.used)} / ${fmtBytes(SYS.ram.total)}`) +
|
||||
tile("Grafikspeicher", `${Math.round(gpuPct())}<small> %</small>`, SYS.gpu_temp != null ? `${Math.round(SYS.gpu_temp)}° GPU-Temp` : "VRAM + GTT");
|
||||
|
||||
// Mini-Sparklines generieren
|
||||
const makeBars = (arr, color) => {
|
||||
return '<div style="display:flex; align-items:flex-end; gap:2px; height:40px; margin-top:12px;">' +
|
||||
arr.map(v => `<div style="width:4px; background:var(--${color}); opacity:0.6; height:${Math.max(2, v)}%; border-radius:2px;"></div>`).join("") +
|
||||
'</div>';
|
||||
};
|
||||
|
||||
$("#act-kpis").innerHTML =
|
||||
kpi("blue", "CPU Last", "gauge", sysV, sysS) +
|
||||
kpi("purple", "RAM", "monitor", `${SYS.ram.percent.toFixed(0)}<small>%</small>`, "Arbeitsspeicher") +
|
||||
kpi("green", "GPU VRAM", "layers", `${gpuP.toFixed(0)}<small>%</small>`, "Grafikspeicher");
|
||||
|
||||
const gb = b => (b / 1024 / 1024 / 1024).toFixed(1);
|
||||
const ramStr = `${gb(SYS.ram.used)} GB / ${gb(SYS.ram.total)} GB`;
|
||||
const gpuStr = (SYS.gpu && SYS.gpu.vram.total) ? `${gb(SYS.gpu.vram.used + SYS.gpu.gtt.used)} GB / ${gb(SYS.gpu.vram.total + SYS.gpu.gtt.total)} GB` : "–";
|
||||
const diskStr = `${SYS.disk.percent.toFixed(0)}% belegt`;
|
||||
|
||||
$("#act-sys").innerHTML = `
|
||||
const g = SYS.gpu;
|
||||
const gpuStr = g && (g.vram.total + g.gtt.total) > 0 ? `${fmtBytes(g.vram.used + g.gtt.used)} / ${fmtBytes(g.vram.total + g.gtt.total)}` : "–";
|
||||
const s = $("#act-sys");
|
||||
if (s) s.innerHTML = `
|
||||
<div class="card-h"><h3>System-Metriken (Bosgame)</h3></div>
|
||||
<div class="kv" style="margin-bottom: 24px;">
|
||||
${kvRow("Arbeitsspeicher (RAM)", ramStr)}
|
||||
${kvRow("Grafikspeicher (VRAM+GTT)", gpuStr)}
|
||||
${kvRow("Speicherplatz (Disk)", diskStr)}
|
||||
${kvRow("Temperatur (GPU / CPU)", `${SYS.gpu_temp?.toFixed(1) || '–'}°C / ${SYS.cpu.temp?.toFixed(1) || '–'}°C`)}
|
||||
<div class="card-sub">Live-Auslastung deines Mini-PCs, alle 0,5 Sekunden.</div>
|
||||
<div class="kv" style="margin-bottom:6px">
|
||||
<div class="kv-row"><span class="kv-k">Arbeitsspeicher (RAM)</span><span class="kv-v">${fmtBytes(SYS.ram.used)} / ${fmtBytes(SYS.ram.total)}</span></div>
|
||||
<div class="kv-row"><span class="kv-k">Grafikspeicher (VRAM + GTT)</span><span class="kv-v">${gpuStr}</span></div>
|
||||
<div class="kv-row"><span class="kv-k">Speicherplatz (Disk)</span><span class="kv-v">${Math.round(SYS.disk.percent)} % belegt</span></div>
|
||||
<div class="kv-row"><span class="kv-k">Temperatur (GPU / CPU)</span><span class="kv-v">${SYS.gpu_temp != null ? Math.round(SYS.gpu_temp) + "°" : "–"} / ${SYS.cpu.temp != null ? Math.round(SYS.cpu.temp) + "°" : "–"}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-3">
|
||||
<div><div class="meta">CPU Historie</div>${makeBars(hist.cpu, "act")}</div>
|
||||
<div><div class="meta">RAM Historie</div>${makeBars(hist.ram, "purple")}</div>
|
||||
<div><div class="meta">VRAM Historie</div>${makeBars(hist.gpu, "on")}</div>
|
||||
</div>
|
||||
`;
|
||||
<div class="grid grid-3" style="margin-top:14px">
|
||||
<div><div class="meta text-xs">CPU-Verlauf</div>${spark(hist.cpu, "--accent")}</div>
|
||||
<div><div class="meta text-xs">RAM-Verlauf</div>${spark(hist.ram, "--purple")}</div>
|
||||
<div><div class="meta text-xs">VRAM-Verlauf</div>${spark(hist.gpu, "--on")}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function mount() {
|
||||
$("#v-activity").innerHTML = `
|
||||
<div class="card-h"><h3>Hintergrund-Aktivitäten</h3><span class="meta" id="job-count"></span></div>
|
||||
<div id="jobs"></div>
|
||||
<div id="jobs-empty" class="empty-c">
|
||||
<div class="e-t">Keine laufenden Jobs.</div>
|
||||
<div class="e-s">Downloads, Updates & Co. erscheinen hier mit Live-Log.</div>
|
||||
</div>`;
|
||||
$("#act-head").innerHTML = `<div class="pagehead"><div>
|
||||
<h1>Aktivität</h1>
|
||||
<div class="sub">Live-Auslastung und laufende Aufgaben (Downloads, Updates) mit Protokoll.</div></div></div>`;
|
||||
|
||||
$("#v-activity").innerHTML = `
|
||||
<div class="card-h"><h3>Hintergrund-Aufgaben</h3><span class="meta" id="job-count"></span></div>
|
||||
<div class="card-sub">Downloads & Updates erscheinen hier mit Live-Protokoll — zum Aufklappen klicken.</div>
|
||||
<div id="jobs"></div>
|
||||
<div id="jobs-empty" class="empty-c"><div class="e-t">Gerade nichts los.</div><div class="e-s">Alles ruhig — keine laufenden Aufgaben.</div></div>`;
|
||||
|
||||
// Klicks auf Job-Kopf -> auf/zuklappen
|
||||
$("#v-activity").addEventListener("click", e => {
|
||||
const h = e.target.closest(".job-h");
|
||||
if (!h) return;
|
||||
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);
|
||||
renderJobs();
|
||||
});
|
||||
|
||||
if (SYS) renderSys();
|
||||
}
|
||||
|
||||
function renderJobs() {
|
||||
const c = $("#jobs");
|
||||
if (!c) return;
|
||||
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>`;
|
||||
const log = tracked.has(j.id) ? `<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 || [];
|
||||
renderJobs();
|
||||
}
|
||||
|
||||
function onSystem(sys) {
|
||||
SYS = sys;
|
||||
const c = $("#act-sys");
|
||||
if (c) renderSys();
|
||||
}
|
||||
function onJobs(jobs) { JOBS = jobs || []; renderJobs(); }
|
||||
function onSystem(sys) { SYS = sys; if ($("#act-sys")) renderSys(); }
|
||||
|
||||
export default { id: "jobs", mount, onJobs, onSystem };
|
||||
|
||||
+74
-107
@@ -1,145 +1,116 @@
|
||||
// models.js — "Modelle"-Ansicht: Schnelltest-Chat, Modell-Tabelle & Konfiguration.
|
||||
// models.js — "Modelle"-Ansicht (v3): Schnelltest-Chat, Modell-Tabelle, Kontext-Konfiguration.
|
||||
|
||||
import { api } from "../core/api.js";
|
||||
import { $, badge, esc, toast, icon } from "../core/ui.js";
|
||||
|
||||
let ALL_MODELS = [];
|
||||
import { $, badge, esc, toast, confirmModal } from "../core/ui.js";
|
||||
|
||||
let ALL = [];
|
||||
function refreshSoon() { document.dispatchEvent(new Event("mc:refresh")); }
|
||||
|
||||
function capTags(caps) {
|
||||
if (!caps?.length) return "–";
|
||||
return caps.map(c =>
|
||||
c === "Code" ? `<span class="tag code">Code</span>`
|
||||
: c === "Bild" ? `<span class="tag img">Bild</span>`
|
||||
: `<span class="tag text">Text</span>`).join(" ");
|
||||
}
|
||||
function details(meta) {
|
||||
if (!meta) return "–";
|
||||
const q = meta.quant || "?";
|
||||
const c = meta.ctx ? Math.round(meta.ctx / 1024) + "K" : "?";
|
||||
const s = meta.size_bytes ? (meta.size_bytes / 1024 ** 3).toFixed(1) + " GB" : "?";
|
||||
return `<span class="mono-sm">${esc(q)} · ${c} · ${s}</span>`;
|
||||
}
|
||||
|
||||
function mount() {
|
||||
$("#m-head").innerHTML = `<div class="pagehead"><div>
|
||||
<h1>Modelle</h1>
|
||||
<div class="sub">Deine konfigurierten Modelle — testen, Kontext anpassen oder aus dem Speicher werfen.</div></div></div>`;
|
||||
|
||||
$("#m-chat").innerHTML = `
|
||||
<div class="card-h"><h3>Schnelltest</h3></div>
|
||||
<div class="card-sub">Schreib eine Nachricht — das gewählte Modell wird automatisch geweckt.</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" title="Sendet eine Chat-Anfrage an das Modell (weckt es auf, falls es schläft)">Senden</button>
|
||||
<textarea id="chat-msg" placeholder="z.B. „Erklär mir kurz, was du kannst."></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>
|
||||
<div class="hint mb-4">
|
||||
💡 <b>Modelle werden automatisch geladen</b>, sobald eine Chat-Anfrage an sie gestellt wird. Du musst sie nicht manuell starten.
|
||||
</div>
|
||||
<div class="card-sub">Modelle laden automatisch, sobald eine Anfrage kommt — du musst nichts manuell starten.</div>
|
||||
<table>
|
||||
<thead><tr><th>Modell</th><th>Fähigkeiten</th><th>Details</th><th>Status</th><th>Port</th><th style="text-align:right">Aktionen</th></tr></thead>
|
||||
<thead><tr><th>Modell</th><th>Kann</th><th>Details</th><th>Status</th><th>Port</th><th style="text-align:right">Aktionen</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>
|
||||
|
||||
<!-- Config Modal -->
|
||||
<div id="cfg-modal" style="display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.8); z-index:100; align-items:center; justify-content:center;">
|
||||
<div class="card" style="width:100%; max-width:400px; position:relative">
|
||||
<button id="cfg-close" class="ghost" style="position:absolute; top:12px; right:12px;">Schließen</button>
|
||||
<h2 style="margin-top:0">Modell konfigurieren</h2>
|
||||
<p class="meta" id="cfg-model-name"></p>
|
||||
|
||||
<div class="mt-6">
|
||||
<label>Context-Size (Tokens)</label>
|
||||
<input id="cfg-ctx" type="number" class="tokin" value="8192">
|
||||
<div class="meta text-xs mt-2">Höhere Werte erlauben längere Dokumente, brauchen aber mehr VRAM.</div>
|
||||
</div>
|
||||
|
||||
<button class="primary mt-6" id="cfg-save" style="width:100%; padding:var(--sp-3)">Speichern</button>
|
||||
</div>
|
||||
<div id="models-empty" class="empty-c" style="display:none">
|
||||
<div class="e-t">Noch keine Modelle konfiguriert</div>
|
||||
<div class="e-s">Hol dir unter „Cookbook" ein passendes Modell.</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
<div id="cfg-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-card">
|
||||
<button id="cfg-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
|
||||
<h3>Modell konfigurieren</h3>
|
||||
<p class="mono-sm" id="cfg-model-name" style="margin:-4px 0 16px"></p>
|
||||
<label>Kontext-Größe (Tokens)</label>
|
||||
<input id="cfg-ctx" type="number" value="8192">
|
||||
<div class="hint">Höhere Werte erlauben längere Texte, brauchen aber mehr Grafikspeicher.</div>
|
||||
<button class="primary" id="cfg-save" style="width:100%;margin-top:6px">Speichern</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
$("#chat-btn").addEventListener("click", sendChat);
|
||||
$("#cfg-close").addEventListener("click", () => $("#cfg-modal").style.display = "none");
|
||||
$("#cfg-modal").addEventListener("click", e => { if (e.target.id === "cfg-modal") $("#cfg-modal").style.display = "none"; });
|
||||
$("#cfg-save").addEventListener("click", saveConfig);
|
||||
}
|
||||
|
||||
function onStatus(s) {
|
||||
ALL_MODELS = s?.models || [];
|
||||
const tb = $("#models");
|
||||
if (!tb) return;
|
||||
tb.innerHTML = "";
|
||||
$("#models-empty").style.display = ALL_MODELS.length ? "none" : "block";
|
||||
$("#m-count").textContent = ALL_MODELS.length ? ALL_MODELS.length + " konfiguriert" : "";
|
||||
ALL = s?.models || [];
|
||||
const tb = $("#models"); if (!tb) return;
|
||||
$("#models-empty").style.display = ALL.length ? "none" : "flex";
|
||||
$("#m-count").textContent = ALL.length ? ALL.length + " konfiguriert" : "";
|
||||
|
||||
const sel = $("#chat-model");
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = "";
|
||||
for (const m of ALL_MODELS) {
|
||||
const tr = document.createElement("tr");
|
||||
|
||||
let capsHtml = "–";
|
||||
if (m.meta && m.meta.caps) {
|
||||
capsHtml = m.meta.caps.map(c => {
|
||||
if (c === "Text") return `<span class="meta" title="Text" style="display:inline-block;width:18px">${icon("compass")}</span>`;
|
||||
if (c === "Code") return `<strong style="color:var(--blue);display:inline-block;width:18px" title="Code">{ }</strong>`;
|
||||
if (c === "Bild") return `<strong style="color:var(--purple);display:inline-block;width:18px" title="Vision">👁</strong>`;
|
||||
return "";
|
||||
}).join(" ");
|
||||
}
|
||||
|
||||
let detailsHtml = "–";
|
||||
if (m.meta) {
|
||||
const q = m.meta.quant || "?";
|
||||
const c = m.meta.ctx ? (m.meta.ctx / 1024).toFixed(0) + "K" : "?";
|
||||
const s = m.meta.size_bytes ? (m.meta.size_bytes / 1024 / 1024 / 1024).toFixed(1) + " GB" : "?";
|
||||
detailsHtml = `<span class="meta" style="font-size:0.9em">${q} · ${c} · ${s}</span>`;
|
||||
}
|
||||
|
||||
const perfHtml = m.state === "running" ? `<br><small class="meta" style="font-size:0.75em">n/a t/s</small>` : "";
|
||||
|
||||
const filenameHtml = m.meta?.filename ? `<br><small class="meta" style="font-size:0.8em">${esc(m.meta.filename)}</small>` : "";
|
||||
|
||||
tr.innerHTML = `<td class="mid" style="font-weight:500">${esc(m.name)}${filenameHtml}</td>
|
||||
<td>${capsHtml}</td>
|
||||
<td>${detailsHtml}</td>
|
||||
<td>${badge(m.state)}${perfHtml}</td>
|
||||
const sel = $("#chat-model"), cur = sel.value; sel.innerHTML = "";
|
||||
tb.innerHTML = ALL.map(m => {
|
||||
const fn = m.meta?.filename ? `<div class="li-sub mono-sm">${esc(m.meta.filename)}</div>` : "";
|
||||
return `<tr>
|
||||
<td class="mid" style="font-weight:500">${esc(m.name)}${fn}</td>
|
||||
<td>${capTags(m.meta?.caps)}</td>
|
||||
<td>${details(m.meta)}</td>
|
||||
<td>${badge(m.state)}</td>
|
||||
<td class="port">${m.port ?? "auto"}</td>
|
||||
<td style="text-align:right">
|
||||
<button class="ghost" data-cfg="${esc(m.name)}" title="Context-Size anpassen">Konfigurieren</button>
|
||||
<button class="ghost" data-unload="${esc(m.name)}" title="Beendet das Modell sofort und gibt den VRAM wieder frei">Aus VRAM entfernen</button>
|
||||
</td>`;
|
||||
tb.appendChild(tr);
|
||||
sel.insertAdjacentHTML("beforeend", `<option>${esc(m.name)}</option>`);
|
||||
}
|
||||
<td style="text-align:right;white-space:nowrap">
|
||||
<button class="ghost" data-cfg="${esc(m.name)}">Konfigurieren</button>
|
||||
<button class="ghost" data-unload="${esc(m.name)}">Entladen</button>
|
||||
</td></tr>`;
|
||||
}).join("");
|
||||
for (const m of ALL) 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")))
|
||||
);
|
||||
tb.querySelectorAll("[data-cfg]").forEach(b =>
|
||||
b.addEventListener("click", () => openConfig(b.getAttribute("data-cfg")))
|
||||
);
|
||||
|
||||
tb.querySelectorAll("[data-unload]").forEach(b => b.addEventListener("click", () => unloadOne(b.getAttribute("data-unload"))));
|
||||
tb.querySelectorAll("[data-cfg]").forEach(b => b.addEventListener("click", () => openConfig(b.getAttribute("data-cfg"))));
|
||||
}
|
||||
|
||||
function openConfig(alias) {
|
||||
const m = ALL_MODELS.find(x => x.name === alias);
|
||||
if (!m) return;
|
||||
const m = ALL.find(x => x.name === alias); if (!m) return;
|
||||
$("#cfg-model-name").textContent = m.name;
|
||||
$("#cfg-ctx").value = m.meta?.ctx || 8192;
|
||||
$("#cfg-modal").style.display = "flex";
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
const alias = $("#cfg-model-name").textContent;
|
||||
const ctx = parseInt($("#cfg-ctx").value) || 8192;
|
||||
|
||||
const alias = $("#cfg-model-name").textContent, ctx = parseInt($("#cfg-ctx").value) || 8192;
|
||||
$("#cfg-save").disabled = true;
|
||||
try {
|
||||
await api("/api/update_model", { method: "POST", body: JSON.stringify({ alias, ctx }) });
|
||||
toast("Gespeichert! Änderungen werden beim nächsten Modell-Start aktiv.");
|
||||
$("#cfg-modal").style.display = "none";
|
||||
refreshSoon();
|
||||
} catch (e) {
|
||||
toast(e.message, true);
|
||||
}
|
||||
try { await api("/api/update_model", { method: "POST", body: JSON.stringify({ alias, ctx }) }); toast("Gespeichert — aktiv beim nächsten Modell-Start."); $("#cfg-modal").style.display = "none"; refreshSoon(); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
$("#cfg-save").disabled = false;
|
||||
}
|
||||
|
||||
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); }
|
||||
if (!await confirmModal({ title: `„${m}" entladen?`, body: "Das Modell wird aus dem Grafikspeicher geworfen und lädt beim nächsten Aufruf automatisch neu.", confirmLabel: "Entladen" })) return;
|
||||
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() {
|
||||
@@ -147,14 +118,10 @@ async function sendChat() {
|
||||
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();
|
||||
const box = $("#chat-reply"); box.style.display = "block"; box.textContent = "(wecke Modell, kann beim Laden 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 };
|
||||
|
||||
+122
-163
@@ -1,184 +1,143 @@
|
||||
// overview.js — Dashboard: Quick Actions, Modelle & Recent Jobs
|
||||
// overview.js — Dashboard (v3): Klartext-Urteil, Metrik-Kacheln, System-Gesundheit,
|
||||
// geführter Schnellstart, "Dein Stack". Speist sich aus /api/status + /api/system/stream.
|
||||
|
||||
import { api } from "../core/api.js";
|
||||
import { $, esc, icon, toast } from "../core/ui.js";
|
||||
import { $, esc, icon, toast, fmtBytes, confirmModal } from "../core/ui.js";
|
||||
|
||||
let S = null; // letzter Status
|
||||
let J = []; // letzte Job-Liste
|
||||
let SYS = null;
|
||||
let S = null; // letzter Status
|
||||
let SYS = null; // letzte System-Metriken
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
function models() { return S?.models || []; }
|
||||
function activeModel() { return models().find(m => RUNNING.has(m.state)); }
|
||||
|
||||
// ---- Aktionen (geführt) ----
|
||||
function go(view) { document.querySelector(`.nav-item[data-view="${view}"]`)?.click(); }
|
||||
async function freeMemory() {
|
||||
const ok = await confirmModal({
|
||||
title: "Speicher freigeben?",
|
||||
body: "Alle aktuell geladenen Modelle werden aus dem Grafikspeicher geworfen. " +
|
||||
"Beim nächsten Aufruf lädt das jeweilige Modell einfach neu — es geht nichts verloren.",
|
||||
confirmLabel: "Speicher freigeben",
|
||||
});
|
||||
if (!ok) return;
|
||||
try { await api("/api/unload", { method: "POST" }); toast("Speicher freigegeben."); setTimeout(() => document.dispatchEvent(new Event("mc:refresh")), 600); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
}
|
||||
window.mcOv = { go, freeMemory };
|
||||
|
||||
// ---- Hero (menschliches Urteil) ----
|
||||
function renderHero() {
|
||||
$("#hero").innerHTML = `<div class="hero">
|
||||
<div>
|
||||
<div class="eyebrow">Dashboard</div>
|
||||
<h1>Mission Control</h1>
|
||||
<p>Steuerzentrale für deinen lokalen llama-swap-Stack. Hier verwaltest du Modelle, Downloads und Server-Wartung.</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function triggerAction(action) {
|
||||
if (action === "restart_llama") {
|
||||
toast("Neustart ausgelöst...");
|
||||
try {
|
||||
await api("/api/service/llama-swap/restart", { method: "POST" });
|
||||
toast("llama-swap wird neugestartet.");
|
||||
} catch(e) {
|
||||
toast("Fehler: " + e.message, true);
|
||||
}
|
||||
} else if (action === "update_mc") {
|
||||
toast("Update gestartet! Siehe Aktivitäten.");
|
||||
try {
|
||||
await api("/api/update", { method: "POST" });
|
||||
document.querySelector('.nav-item[data-view="activity"]').click();
|
||||
} catch(e) {
|
||||
toast("Fehler: " + e.message, true);
|
||||
let title = "Verbinde…", sub = "";
|
||||
if (S) {
|
||||
const total = models().length, act = activeModel();
|
||||
if (!S.swap_ok) { title = "LLM-Engine offline"; sub = "Der llama-swap-Dienst antwortet gerade nicht."; }
|
||||
else if (SYS && SYS.ram.percent >= 90) { title = "Achtung: Speicher wird knapp."; sub = `Arbeitsspeicher bei ${Math.round(SYS.ram.percent)} % — eventuell Speicher freigeben.`; }
|
||||
else {
|
||||
title = "Alles läuft rund.";
|
||||
sub = `${total} ${total === 1 ? "Modell" : "Modelle"} bereit · ${act ? `„${act.name}" geladen` : "keins geladen"} · keine Warnungen.`;
|
||||
}
|
||||
}
|
||||
$("#ov-hero").innerHTML = `<div class="pagehead"><div>
|
||||
<h1>${esc(title)}</h1><div class="sub">${esc(sub)}</div></div></div>`;
|
||||
}
|
||||
|
||||
// Global hook für onclick
|
||||
window.triggerAction = triggerAction;
|
||||
|
||||
function renderQuickActions() {
|
||||
let actionsHtml = "";
|
||||
|
||||
// ---- Metrik-Kacheln ----
|
||||
function tile(label, valueHtml, sub, vCls = "", sCls = "") {
|
||||
return `<div class="tile"><div class="t-l">${label}</div>
|
||||
<div class="t-v ${vCls}">${valueHtml}</div><div class="t-s ${sCls}">${sub}</div></div>`;
|
||||
}
|
||||
function tempWord(t) { return t == null ? "" : t < 55 ? "kühl & gesund" : t < 72 ? "normal" : "läuft heiß"; }
|
||||
function renderTiles() {
|
||||
const act = activeModel();
|
||||
const swap = S?.swap_ok;
|
||||
let engine = tile("LLM-Engine", swap ? "Online" : "Offline", "llama-swap", swap ? "ok" : "bad");
|
||||
let model = tile("Aktives Modell", act ? esc(act.name) : "Keins",
|
||||
act ? "im Grafikspeicher" : "nichts geladen");
|
||||
let temp, mem;
|
||||
if (SYS) {
|
||||
const ram_percent = SYS.ram.percent || 0;
|
||||
// Wenn RAM über 90% ist, zeige Warnung.
|
||||
// Wir nehmen 90 für Produktion, aber für den Test könnte es angepasst werden.
|
||||
if (ram_percent >= 90) {
|
||||
actionsHtml += `
|
||||
<div class="card" style="background:var(--red-dim); border:1px solid var(--red); grid-column:1/-1;">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 style="color:var(--red); margin:0;">⚠️ Arbeitsspeicher kritisch (${ram_percent.toFixed(0)}%)</h3>
|
||||
<p style="color:var(--red); margin-top:4px;">Der RAM/VRAM ist fast voll. Dies kann zu Systeminstabilität führen.</p>
|
||||
</div>
|
||||
<button class="primary warn" onclick="window.triggerAction('restart_llama')">VRAM leeren (Neustart)</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Simulierter Update-Check (Idealerweise vom Backend, hier als permanenter Button wenn man manuell checken will,
|
||||
// oder wir blenden ihn ein wenn ein lokales flag gesetzt ist. Wir zeigen ihn hier als Feature-Highlight)
|
||||
// Da wir aktuell keinen echten Git-Check im Backend haben, zeigen wir einen "Update Prüfen" Button in den QuickActions.
|
||||
const t = SYS.gpu_temp ?? SYS.cpu.temp;
|
||||
temp = tile("GPU-Temperatur", t != null ? `${Math.round(t)}°` : "–", tempWord(t),
|
||||
"", t != null && t < 72 ? "ok" : "");
|
||||
const free = SYS.ram.total - SYS.ram.used;
|
||||
mem = tile("Freier Speicher", `${(free / 1024 ** 3).toFixed(0)}<small> GB</small>`,
|
||||
`von ${(SYS.ram.total / 1024 ** 3).toFixed(0)} GB`);
|
||||
} else {
|
||||
temp = tile("GPU-Temperatur", "–", "messe…");
|
||||
mem = tile("Freier Speicher", "–", "messe…");
|
||||
}
|
||||
|
||||
// 3 Standard Kacheln (Cookbook, Server-Status, Aktivität/Guides)
|
||||
$("#ov-quick").innerHTML = actionsHtml + `
|
||||
<button class="card-btn" onclick="document.querySelector('.nav-item[data-view=\\'cookbook\\']').click()">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3>Modell finden</h3>
|
||||
<span class="text-act">${icon("book")}</span>
|
||||
</div>
|
||||
<p>Durchsuche HuggingFace nach neuen Modellen im Cookbook.</p>
|
||||
</button>
|
||||
|
||||
<button class="card-btn" onclick="window.triggerAction('update_mc')">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3>Container Updates</h3>
|
||||
<span class="text-act">${icon("download")}</span>
|
||||
</div>
|
||||
<p>Prüfe auf Updates für Mission-Control und Llama.cpp.</p>
|
||||
</button>
|
||||
|
||||
<button class="card-btn" onclick="document.querySelector('.nav-item[data-view=\\'server\\']').click()">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3>Wartung</h3>
|
||||
<span class="text-act">${icon("server")}</span>
|
||||
</div>
|
||||
<p>Server neustarten, VRAM leeren oder OS aktualisieren.</p>
|
||||
</button>
|
||||
`;
|
||||
$("#ov-tiles").innerHTML = engine + model + temp + mem;
|
||||
}
|
||||
|
||||
function modelRow(m) {
|
||||
// ---- System-Gesundheit ----
|
||||
function meter(label, pct) {
|
||||
const p = Math.max(0, Math.min(100, pct || 0));
|
||||
const cls = p >= 90 ? "bad" : p >= 75 ? "warn" : "";
|
||||
return `<div class="meter"><div class="meter-h"><span class="mk">${label}</span>
|
||||
<span class="mv">${Math.round(p)} %</span></div>
|
||||
<div class="bar ${cls}"><i style="width:${Math.max(2, p)}%"></i></div></div>`;
|
||||
}
|
||||
function renderHealth() {
|
||||
const head = `<div class="card-h"><h3>System-Gesundheit</h3></div>
|
||||
<div class="card-sub">So ausgelastet ist dein Mini-PC gerade.</div>`;
|
||||
if (!SYS) { $("#ov-health").innerHTML = head + `<div class="empty">Warte auf Messwerte…</div>`; return; }
|
||||
let gpuPct = 0;
|
||||
const g = SYS.gpu;
|
||||
if (g && (g.vram.total + g.gtt.total) > 0)
|
||||
gpuPct = ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100;
|
||||
$("#ov-health").innerHTML = head +
|
||||
meter("Prozessor (CPU)", SYS.cpu.percent) +
|
||||
meter("Arbeitsspeicher (RAM)", SYS.ram.percent) +
|
||||
meter("Grafikspeicher (VRAM)", gpuPct);
|
||||
}
|
||||
|
||||
// ---- Schnellstart ----
|
||||
function qa(ic, tone, title, sub, onclick) {
|
||||
return `<button class="qa" onclick="${onclick}">
|
||||
<span class="qa-ic ${tone}">${icon(ic)}</span>
|
||||
<span class="qa-main"><span class="qa-t">${title}</span><span class="qa-s">${sub}</span></span>
|
||||
<span class="qa-arrow">${icon("chevron")}</span></button>`;
|
||||
}
|
||||
function renderQuickstart() {
|
||||
$("#ov-quickstart").innerHTML =
|
||||
`<div class="card-h"><h3>Schnellstart</h3></div>
|
||||
<div class="card-sub">Die häufigsten Aufgaben — ein Klick.</div>` +
|
||||
qa("search", "teal", "Modell finden", "Passend zu deiner Hardware", "window.mcOv.go('cookbook')") +
|
||||
qa("refresh", "amber", "Speicher freigeben", "Modelle entladen · fragt vorher nach", "window.mcOv.freeMemory()") +
|
||||
qa("file", "blue", "Logs ansehen", "Live mitlesen, was läuft", "window.mcOv.go('server')");
|
||||
}
|
||||
|
||||
// ---- Dein Stack ----
|
||||
function capTag(caps) {
|
||||
if (!caps) return "";
|
||||
if (caps.includes("Bild")) return `<span class="tag img">Bild</span>`;
|
||||
if (caps.includes("Code")) return `<span class="tag code">Code</span>`;
|
||||
return `<span class="tag text">Text</span>`;
|
||||
}
|
||||
function stackRow(m) {
|
||||
const on = RUNNING.has(m.state);
|
||||
const dot = m.state === "loading" || m.state === "starting" ? "load" : (on || m.state === "bereit") ? "on" : "";
|
||||
const state = on ? (m.state === "loading" ? "lädt…" : "geladen") : "bereit";
|
||||
|
||||
let caps = "";
|
||||
if (m.meta && m.meta.caps) {
|
||||
caps = m.meta.caps.map(c => {
|
||||
if (c === "Code") return `<span title="Code" style="color:var(--blue);font-size:0.9em;margin-left:6px">{ }</span>`;
|
||||
if (c === "Bild") return `<span title="Vision" style="color:var(--purple);font-size:0.9em;margin-left:6px">👁</span>`;
|
||||
return "";
|
||||
}).join("");
|
||||
}
|
||||
|
||||
const filename = m.meta?.filename ? `<div class="li-sub" style="font-family:var(--mono); color:var(--mut);">${esc(m.meta.filename)}</div>` : '';
|
||||
|
||||
return `<div class="li">
|
||||
<span class="li-dot ${dot}"></span>
|
||||
<div class="li-main">
|
||||
<div class="li-id" style="font-weight:500">${esc(m.name)}${caps}</div>
|
||||
${filename}
|
||||
</div>
|
||||
<div class="li-right">
|
||||
<div class="li-time">${state}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
const dot = m.state === "loading" || m.state === "starting" ? "load" : on ? "on" : "";
|
||||
const status = on ? (m.state === "loading" ? "lädt…" : `geladen${m.port ? " · Port " + m.port : ""}`) : "bereit";
|
||||
return `<div class="li"><span class="li-dot ${dot}"></span>
|
||||
<span class="li-id" style="flex:1">${esc(m.name)}</span>
|
||||
${capTag(m.meta?.caps)}
|
||||
<span class="li-meta" style="width:120px;text-align:right">${status}</span></div>`;
|
||||
}
|
||||
function renderStack() {
|
||||
const ms = models();
|
||||
$("#ov-stack").innerHTML =
|
||||
`<div class="card-h"><h3>Dein Stack</h3><span class="meta">${ms.length ? ms.length + " konfiguriert" : ""}</span></div>` +
|
||||
(ms.length
|
||||
? `<div class="list">${ms.map(stackRow).join("")}</div>`
|
||||
: `<div class="empty-c"><div class="e-t">Noch keine Modelle</div>
|
||||
<div class="e-s">Hol dir unter „Cookbook" ein Modell, das auf deine Hardware passt.</div></div>`);
|
||||
}
|
||||
|
||||
function renderModels() {
|
||||
const models = S?.models || [];
|
||||
$("#ov-models").innerHTML = `
|
||||
<div class="card-h"><h3>Aktuelle Modelle im Stack</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 „Cookbook“ eins von HuggingFace.</div></div>`}`;
|
||||
}
|
||||
function renderAll() { renderHero(); renderTiles(); renderHealth(); renderQuickstart(); renderStack(); }
|
||||
|
||||
function renderRecentJobs() {
|
||||
const latest = J.slice(0, 4);
|
||||
|
||||
const statusBadge = (s) => {
|
||||
if (s === "done") return '<span class="badge b-run" style="font-size:10px">fertig</span>';
|
||||
if (s === "failed") return '<span class="badge b-err" style="font-size:10px">fehler</span>';
|
||||
return '<span class="badge b-load" style="font-size:10px">lädt…</span>';
|
||||
};
|
||||
function mount() { renderQuickstart(); renderAll(); }
|
||||
function onStatus(s) { S = s; renderHero(); renderTiles(); renderStack(); }
|
||||
function onSystem(sys) { SYS = sys; renderHero(); renderTiles(); renderHealth(); }
|
||||
|
||||
$("#ov-recent-jobs").innerHTML = `
|
||||
<div class="card-h"><h3>Letzte Aktivitäten</h3><span class="meta" style="cursor:pointer" onclick="document.querySelector('.nav-item[data-view=\\'activity\\']').click()">Alle ansehen →</span></div>
|
||||
${latest.length
|
||||
? `<div class="list">
|
||||
${latest.map(j => `
|
||||
<div class="li">
|
||||
<div class="li-main">
|
||||
<div class="li-id text-sm">${esc(j.label)}</div>
|
||||
</div>
|
||||
<div class="li-right">${statusBadge(j.state)}</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>`
|
||||
: `<div class="empty-c"><div class="e-t">Keine Aktivitäten</div><div class="e-s">Alles läuft ruhig.</div></div>`
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
renderHero();
|
||||
renderQuickActions();
|
||||
renderModels();
|
||||
renderRecentJobs();
|
||||
}
|
||||
|
||||
function mount() { renderAll(); }
|
||||
function onStatus(s) { S = s; renderModels(); }
|
||||
function onJobs(jobs) { J = jobs || []; renderRecentJobs(); }
|
||||
function onSystem(sys) { SYS = sys; }
|
||||
|
||||
export default { id: "overview", mount, onStatus, onJobs, onSystem };
|
||||
export default { id: "overview", mount, onStatus, onSystem };
|
||||
|
||||
+77
-92
@@ -1,120 +1,105 @@
|
||||
// server.js — Server & Wartung (v3): Dienste, OS-Updates, Reboot, Live-Konsole.
|
||||
// Heikle Aktionen mit Klartext-Bestätigung (confirmModal/promptModal).
|
||||
|
||||
import { api, getToken } from "../core/api.js";
|
||||
import { $, toast } from "../core/ui.js";
|
||||
import { $, toast, icon, confirmModal, promptModal } from "../core/ui.js";
|
||||
import { track } from "./jobs.js";
|
||||
|
||||
function refreshSoon() { document.dispatchEvent(new Event("mc:refresh")); }
|
||||
|
||||
function mount() {
|
||||
$("#wartung").innerHTML = `
|
||||
<div class="card-h"><h3>Dienste & Applikation</h3></div>
|
||||
<div class="btn-row">
|
||||
<button id="w-restart-swap" title="Startet die LLM-Engine neu. Dauer ca. 5 Sekunden.">llama-swap neustarten</button>
|
||||
<button id="w-restart-mc" title="Startet Mission Control (das Dashboard) neu.">Mission Control neustarten</button>
|
||||
<button id="w-update" title="Zieht den neuesten Code via Git und aktualisiert das Dashboard.">Mission Control System-Update</button>
|
||||
<button id="w-unload" class="ghost" title="Wirft alle derzeit in VRAM geladenen Modelle sofort raus.">Aus dem VRAM entfernen</button>
|
||||
</div>
|
||||
<div class="hint" style="margin-top:12px; margin-bottom:32px">
|
||||
Dienste starten via passwortlosem Sudo neu.
|
||||
$("#wartung").outerHTML = `<div id="wartung" style="display:flex;flex-direction:column;gap:var(--sp-4)">
|
||||
<div class="pagehead"><div>
|
||||
<h1>Server & Wartung</h1>
|
||||
<div class="sub">Dienste steuern, Updates einspielen und live mitlesen — ohne SSH/Terminal.</div>
|
||||
</div></div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>Dienste & Applikation</h3></div>
|
||||
<div class="card-sub">Neustarts sind harmlos und passwortlos — nichts geht dabei verloren.</div>
|
||||
<div class="btn-row">
|
||||
<button id="w-restart-swap">LLM-Engine neustarten</button>
|
||||
<button id="w-restart-mc">Dashboard neustarten</button>
|
||||
<button id="w-update">Nach Updates suchen</button>
|
||||
<button id="w-unload" class="ghost">Grafikspeicher leeren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-h"><h3>Betriebssystem (Bosgame)</h3></div>
|
||||
<div class="btn-row">
|
||||
<button id="w-os-update" title="Führt apt update & upgrade aus, um das Betriebssystem (Bosgame) zu aktualisieren.">OS-Updates installieren (apt update)</button>
|
||||
<button id="w-reboot" class="danger" title="Startet den gesamten Server physikalisch neu. Alles ist kurz offline.">Server Reboot</button>
|
||||
<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>
|
||||
<div class="btn-row">
|
||||
<button id="w-os-update">OS-Updates installieren</button>
|
||||
<button id="w-reboot" class="danger">Server neustarten (Reboot)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint" style="margin-top:12px; margin-bottom:32px">
|
||||
Für tiefe Eingriffe fragt das Dashboard einmalig das sudo-Passwort ab.
|
||||
</div>
|
||||
|
||||
<div class="card-h">
|
||||
<h3>Live-Konsole</h3>
|
||||
<select id="w-console-sel" style="margin-left:auto; width:200px">
|
||||
<option value="llama-swap">llama-swap</option>
|
||||
<option value="mission-control">mission-control</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="w-console" style="background:#111; color:#0f0; font-family:monospace; font-size:12px; padding:12px; border-radius:8px; height:400px; overflow-y:auto; white-space:pre-wrap;">
|
||||
Verbinde...
|
||||
</div>`;
|
||||
|
||||
$("#w-restart-swap").addEventListener("click", () => restartService("llama-swap"));
|
||||
$("#w-restart-mc").addEventListener("click", () => restartService("mission-control"));
|
||||
<div class="card">
|
||||
<div class="card-h"><h3>Live-Konsole</h3>
|
||||
<select id="w-console-sel" style="margin:0 0 0 auto;width:220px">
|
||||
<option value="llama-swap">LLM-Engine (llama-swap)</option>
|
||||
<option value="mission-control">Dashboard (mission-control)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="card-sub">Was der Dienst gerade tut — live mitlesen.</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-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-console-sel").addEventListener("change", connectConsole);
|
||||
connectConsole();
|
||||
}
|
||||
|
||||
let ws = null;
|
||||
|
||||
function connectConsole() {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
const svc = $("#w-console-sel").value;
|
||||
const out = $("#w-console");
|
||||
out.innerHTML = "Verbinde mit " + svc + "...\n";
|
||||
|
||||
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:";
|
||||
const url = `${proto}//${location.host}/api/logs/${svc}?token=${getToken()}`;
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
out.innerHTML += e.data;
|
||||
out.scrollTop = out.scrollHeight;
|
||||
};
|
||||
ws.onclose = () => {
|
||||
out.innerHTML += "\n--- Verbindung getrennt ---";
|
||||
};
|
||||
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) {
|
||||
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 osUpdate() {
|
||||
const pwd = window.prompt("Bitte sudo Passwort eingeben (für apt-get update & upgrade):");
|
||||
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);
|
||||
} catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
async function rebootServer() {
|
||||
if (!window.confirm("ACHTUNG: Server wird komplett neu gestartet. Fortfahren?")) return;
|
||||
const pwd = window.prompt("Bitte sudo Passwort eingeben (für reboot):");
|
||||
if (!pwd) return;
|
||||
try {
|
||||
await api("/api/reboot", { method: "POST", body: JSON.stringify({ password: pwd }) });
|
||||
toast("Reboot ausgelöst. UI ist gleich offline.");
|
||||
} catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
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 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() {
|
||||
try {
|
||||
await api("/api/unload", { method: "POST" });
|
||||
toast("Alle Modelle entladen.");
|
||||
setTimeout(refreshSoon, 600);
|
||||
} catch (e) { toast(e.message, true); }
|
||||
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 update() {
|
||||
try { const r = await api("/api/update", { method: "POST" }); toast("Update läuft — siehe Aktivität."); track(r.job_id); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
async function osUpdate() {
|
||||
if (!await confirmModal({ title: "OS-Updates installieren?", body: "Führt <code>apt update & 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); }
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user