v3 Phase A: Design-System-Fundament + Übersicht neu

- base.css/components.css: EINE Akzentfarbe (Teal), Metrik-Kacheln, Fit-Ampel,
  Modal, Quickstart-Reihen; beschriftete Sidebar; rueckwaertskompatibel
  (Legacy-Klassen + fehlende Vars --hi/--red/--red-dim definiert).
- index.html: beschriftete Navigation, Topbar mit Security-Chip, neue Overview-Mountpunkte.
- ui.js: Icon-Set erweitert + confirmModal/promptModal/fmtBytes/fmtPct (Beginner-UX-Helfer).
- overview.js: komplett neu (Klartext-Urteil, 4 Kacheln, System-Gesundheit-Balken,
  gefuehrter Schnellstart, "Dein Stack"). Inline-Styles raus.

Verifiziert: lokal 0 Konsolenfehler, Live-Metriken via WS, alle Views unbeschaedigt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-21 06:51:28 +02:00
parent 8f63c4969a
commit 52b0a3bff5
5 changed files with 418 additions and 346 deletions
+63 -47
View File
@@ -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,19 @@ 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{--side:64px}}
/* 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
View File
@@ -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)}
+26 -27
View File
@@ -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,14 +42,13 @@
<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>
@@ -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>
+78
View File
@@ -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();
});
}
+122 -163
View File
@@ -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 };