Compare commits

...

23 Commits

Author SHA1 Message Date
Hitonabi 1aea0f558e v3 Phase B: alle Panels auf das Design-System + Beginner-UX
- cookbook.js: Fit-Ampel (gruen/gelb/rot) + Legende + Klartext-Urteile, sauberes Modal.
- server.js: heikle Aktionen mit confirmModal/promptModal (Klartext-Konsequenz),
  Konsole im neuen Stil, Begriffe uebersetzt.
- models.js: Tabelle re-skinnt (Capability-Tags statt Emoji, --blue raus),
  Entladen mit Bestaetigung, Konfig-Modal vereinheitlicht.
- jobs.js (Aktivitaet): Metrik-Kacheln + Klartext-Verlaeufe.
- guides.js: Kopf + Intro, Integrations-URL aus Browser-Host abgeleitet.
- index.html: Mountpunkte fuer Modelle-/Aktivitaets-Kopf.
- app.py: no-cache-Middleware fuer /static (UI-Aenderungen wirken sofort nach rsync,
  kein Stale-JS mehr).
- base.css: Sidebar bei schmalem Viewport icon-only (Label-Ueberlappung gefixt).

Verifiziert: alle 6 Panels mounten fehlerfrei (0 Konsolenfehler), Fit-Ampel rechnet live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 07:05:15 +02:00
Hitonabi 52b0a3bff5 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>
2026-06-21 06:51:28 +02:00
Hitonabi 8f63c4969a docs: Update ROADMAP and CLAUDE for v2 completion 2026-06-21 00:00:07 +02:00
Hitonabi 812ee8ac7c feat: Update Cookbook Filters to the 5 main roles 2026-06-20 23:58:41 +02:00
Hitonabi 88bb681339 fix: UX improvements (hover flicker, green badge, log filtering) 2026-06-20 23:54:53 +02:00
Hitonabi b2b586093d feat: Cookbook Search Results UI & Lazy Metrics 2026-06-20 23:48:28 +02:00
Hitonabi e180adf21a fix: Stop flickering in cookbook.js 2026-06-20 23:45:23 +02:00
Hitonabi 44270b6cb7 fix: SyntaxErrors in JS (overview & cookbook) 2026-06-20 23:44:05 +02:00
Hitonabi d0ed4e4c7e feat: Live-Metriken via WebSockets (Phase 3) 2026-06-20 23:30:58 +02:00
Hitonabi ee8ec10119 feat: Action Dashboard und App Store Filter 2026-06-20 23:25:56 +02:00
Hitonabi 0a81a9fe99 feat: smart cookbook MVP mit odysseus fit logik 2026-06-20 23:13:05 +02:00
Hitonabi c76bcc7293 UI: Refactor design system to align with dense mockup 2026-06-20 22:53:48 +02:00
Hitonabi a51f6ee88a Cookbook RAM check and Speed Estimation 2026-06-20 22:41:02 +02:00
Hitonabi 8e8d564469 Fix main.js crash 2026-06-20 22:37:52 +02:00
Hitonabi 8b76adc96e Feinschliff Phase 2: Dashboard Redesign, RAM Check, Accordions 2026-06-20 22:30:40 +02:00
Hitonabi e3be7fbfb5 refactor: massive UX and beginner refactoring (Cookbook, Dashboard, Layout, Wording) 2026-06-20 22:06:04 +02:00
Hitonabi 9d05b2d011 feat: activity tab (Feinschliff) 2026-06-20 21:51:02 +02:00
Hitonabi 57c176a251 docs: roadmap completed 2026-06-20 21:47:57 +02:00
Hitonabi 16199ce53a feat: server console (Feature 2) & complete roadmap 2026-06-20 21:47:37 +02:00
Hitonabi ef7f02e19f feat: guides (Feature 7) 2026-06-20 21:44:38 +02:00
Hitonabi b84e8f3ac2 feat: cookbook (Feature 4) 2026-06-20 21:42:12 +02:00
Hitonabi 00cf14c6e3 docs: roadmap update for feature 1 2026-06-20 21:34:43 +02:00
Hitonabi 04bac7e13e feat: server management (Feature 1) 2026-06-20 21:34:14 +02:00
26 changed files with 2332 additions and 510 deletions
+7 -5
View File
@@ -11,14 +11,14 @@ FastAPI-Backend + Vanilla-JS-Dashboard. **Leitprinzip: KISS — kein Build-Schri
- **`auth.py`** — optionale Token-Auth (`X-MC-Token`). - **`auth.py`** — optionale Token-Auth (`X-MC-Token`).
- **`jobengine.py`** — In-Memory-Job-System (Threads + Subprocess) mit Live-Log; fährt Downloads/Updates. - **`jobengine.py`** — In-Memory-Job-System (Threads + Subprocess) mit Live-Log; fährt Downloads/Updates.
- **`llamaswap.py`** — spricht `llama-swap` an (`/running`, `/v1/models`, unload) und liest/schreibt dessen `config.yaml` per `ruamel.yaml` (Kommentare bleiben erhalten). - **`llamaswap.py`** — spricht `llama-swap` an (`/running`, `/v1/models`, unload) und liest/schreibt dessen `config.yaml` per `ruamel.yaml` (Kommentare bleiben erhalten).
- **`routers/*.py`** — ein Router je Bereich. Aktuell: `models.py` (`status`, `download`, `register`, `unload`, `chat`), `jobs.py` (`jobs`), `maintenance.py` (`update`), `system.py` (`status`). Alle Endpoints unter `/api/*`. - **`routers/*.py`** — ein Router je Bereich. Aktuell: `models.py` (`status`, `download`, `register`, `unload`, `chat`), `jobs.py` (`jobs`), `maintenance.py` (`update`, `logs`-WebSocket), `system.py` (`status`, `stream`-WebSocket), `cookbook.py` (`analyze`). Alle Endpoints unter `/api/*`.
**Frontend** (`static/`, dünne Hülle + ES-Module, kein Build): **Frontend** (`static/`, dünne Hülle + ES-Module, kein Build):
- **`index.html`** — nur Gerüst: Sidebar-Nav, Topbar, Alert-Banner, ein `.view`-Container je Bereich (Hash-Routing). Lädt `css/*` und `js/main.js` als Modul. - **`index.html`** — nur Gerüst: Sidebar-Nav, Topbar, Alert-Banner, ein `.view`-Container je Bereich (Hash-Routing). Lädt `css/*` und `js/main.js` als Modul.
- **`css/base.css`** — Design-Tokens (`:root`), Reset, App-Layout (Sidebar/Topbar/Content). **`css/components.css`** — Karten, KPI-Kacheln, Listen, Forms, Log, Toast. - **`css/base.css`** — Design-Tokens (`:root`), Reset, App-Layout (Sidebar/Topbar/Content). **`css/components.css`** — Karten, KPI-Kacheln, Listen, Forms, Log, Toast.
- **`js/core/*`** — `api.js` (Fetch + Token), `ui.js` (DOM-Helfer, Toast, Icons), `nav.js` (View-Switch). - **`js/core/*`** — `api.js` (Fetch + Token), `ui.js` (DOM-Helfer, Toast, Icons), `nav.js` (View-Switch).
- **`js/panels/*`** — ein Panel je Bereich (`overview`, `models`, `maintenance`, `jobs`). Panel-Vertrag: `{ id, mount?(), onStatus?(s), onJobs?(jobs) }`. - **`js/panels/*`** — ein Panel je Bereich (`overview`, `models`, `maintenance`, `jobs`). Panel-Vertrag: `{ id, mount?(), onStatus?(s), onJobs?(jobs) }`.
- **`js/main.js`** — bootet Panels, pflegt Topbar/Alert, fährt das Polling (`/api/status` 3 s, `/api/jobs` 1.5 s) und verteilt an die Panels. - **`js/main.js`** — bootet Panels, pflegt Topbar/Alert, baut die WebSocket-Verbindung für Live-System-Metriken (`/api/system/stream`) auf, fährt das reguläre Polling (`/api/status` 3 s, `/api/jobs` 1.5 s) und verteilt an die Panels.
- **`mission-control.service`** — systemd-Unit (uvicorn auf Port 9000). - **`mission-control.service`** — systemd-Unit (uvicorn auf Port 9000).
- **Konfiguration** rein über Env-Vars: `MC_LLAMA_SWAP_URL`, `MC_CONFIG_PATH`, `MC_MODELS_DIR`, `MC_CMD_TEMPLATE`, `MC_UPDATE_CMD`, `MC_DEFAULT_TTL`, `MC_TOKEN`. - **Konfiguration** rein über Env-Vars: `MC_LLAMA_SWAP_URL`, `MC_CONFIG_PATH`, `MC_MODELS_DIR`, `MC_CMD_TEMPLATE`, `MC_UPDATE_CMD`, `MC_DEFAULT_TTL`, `MC_TOKEN`.
@@ -77,7 +77,9 @@ rsync; **Python-Code-Änderungen brauchen den Restart**.
- HuggingFace-Downloads mit **`HF_HUB_DISABLE_XET=1`** (sonst reproduzierbarer Hänger bei ~6 MB). - HuggingFace-Downloads mit **`HF_HUB_DISABLE_XET=1`** (sonst reproduzierbarer Hänger bei ~6 MB).
- Vision-Modelle in llama.cpp brauchen zusätzlich **`--mmproj <projektor>` und `--jinja`**. - Vision-Modelle in llama.cpp brauchen zusätzlich **`--mmproj <projektor>` und `--jinja`**.
## Roadmap ## Projektstatus & Roadmap
Siehe @ROADMAP.md für die v2-Planung. Die v2-Roadmap ist **vollständig umgesetzt** (siehe `ROADMAP.md`).
**Nordstern: den Server nie wieder via SSH/Putty anfassen müssen — 100 % Automatisierung / Klicki-Bunti.** Wir befinden uns nun im **Feinschliff- und Wartungsmodus**.
**Nordstern:** den Server nie wieder via SSH/Putty anfassen müssen — 100 % Automatisierung / Klicki-Bunti.
+33 -25
View File
@@ -1,4 +1,4 @@
# Mission Control Roadmap v2 # Mission Control - Roadmap v2
**Nordstern:** Den Bosgame nie wieder via SSH/Putty bedienen müssen. **100 % Automatisierung oder Klicki-Bunti.** **Nordstern:** Den Bosgame nie wieder via SSH/Putty bedienen müssen. **100 % Automatisierung oder Klicki-Bunti.**
@@ -15,59 +15,67 @@ Modell-Listen, Aktivitäts-Stream) angelehnt an `docs/mission-control-overview.p
**✅ Schritt 2 erledigt & live** — *Feature 3: Live-Auslastung*. `system.py` Router mit `psutil` und sysfs für CPU/RAM/Disk/GPU/Temp. **✅ Schritt 2 erledigt & live** — *Feature 3: Live-Auslastung*. `system.py` Router mit `psutil` und sysfs für CPU/RAM/Disk/GPU/Temp.
**Reihenfolge (abgestimmt):** Design/Architektur zuerst (✅), dann Quick Wins, Security-Brocken zuletzt: **Reihenfolge (abgestimmt):** Design/Architektur zuerst (✅), dann Quick Wins, Security-Brocken zuletzt:
`1 (✅) Fundament → 2 (✅) Feature 3 Live-Auslastung → 3 (✅) Feature 6 Mehr LLM-Metriken → [4] Feature 1 Server-Management ← NÄCHSTES → Feature 4 → Feature 7 → Feature 2`. `1 (✅) Fundament → 2 (✅) Feature 3 Live-Auslastung → 3 (✅) Feature 6 Mehr LLM-Metriken → 4 (✅) Feature 1 Server-Management → 5 (✅) Feature 4 Cookbook → 6 (✅) Feature 7 Integrations-Anleitungen → 7 (✅) Feature 2 Live-Terminal`.
**Arbeitsweise je Schritt:** neuer `routers/<x>.py` + `js/panels/<x>.js` + Nav-Eintrag, sauber degradierend. **Arbeitsweise je Schritt:** neuer `routers/<x>.py` + `js/panels/<x>.js` + Nav-Eintrag, sauber degradierend.
Bauen + Smoke-Test auf Windows, dann push→pull→rsync→restart auf den Bosgame (CLAUDE.md „Entwickeln & Deployen"). Bauen + Smoke-Test auf Windows, dann push→pull→rsync→restart auf den Bosgame (CLAUDE.md „Entwickeln & Deployen").
Ein Commit je Schritt. **Ich (KI) habe key-basierten SSH-Zugang zum Bosgame und kann selbst deployen+restarten.** Ein Commit je Schritt. **Ich (KI) habe key-basierten SSH-Zugang zum Bosgame und kann selbst deployen+restarten.**
**Nächster Schritt konkret = Feature 1 (Server-Management).** **Letzte Errungenschaften:**
- Aktuell gibt es nur "Container aktualisieren" + "Alles aus dem Speicher". Ziel: den kompletten Server aus der UI verwalten. - **WebSockets:** `/api/system/stream` für super-fluide 2Hz System-Metriken (CPU/RAM/GPU) ohne HTTP-Overhead.
- OS-/Core-Updates (`apt update/upgrade`) per Knopf, mit Live-Output - **Cookbook 2.0:** Suchergebnisse laden Metriken asynchron ("Lazy Loading"), inklusive Hardware-Fit-Berechnung. UI ist auf Premium-Niveau angehoben (Hover-Effekte, Badge-Colors).
- Dienste steuern (`llama-swap`, `mission-control`: Status, Restart) - **Log-Streaming:** `GET /running`-Polls werden herausgefiltert, Live-Konsolen sind sauber.
- Reboot / Health-Übersicht
- Scope-/Security-Sprung: macht MC zum Server-Admin-Panel. Rechte minimal halten. **→ Aktueller Modus = Wartung & Feinschliff.**
- Die v2 Roadmap ist damit zu 100% umgesetzt.
- Fokus liegt ab sofort auf Stabilität, Bug-Hunting und dem finalen "Polishing".
--- ---
## Features ## Features
### 1. Server-Management ("Update-Panel 2.0") ← **NÄCHSTER SCHRITT** ### 1. Server-Management ("Update-Panel 2.0") (✅ Erledigt)
Aktuell gibt es nur "Container aktualisieren" + "Alles aus dem Speicher". Ziel: den kompletten Server aus der UI verwalten. Aktuell gibt es nur "Container aktualisieren" + "Alles aus dem Speicher". Ziel: den kompletten Server aus der UI verwalten.
- [ ] OS-/Core-Updates (`apt update/upgrade`) per Knopf, mit Live-Output - [x] OS-/Core-Updates (`apt update/upgrade`) per Knopf, mit Live-Output
- [ ] Dienste steuern (`llama-swap`, `mission-control`: Status, Restart) - [x] Dienste steuern (`llama-swap`, `mission-control`: Status, Restart)
- [ ] Reboot / Health-Übersicht - [x] Reboot / Health-Übersicht
- [ ] Referenz: altes `ai-control`-Skript als Funktionsvorlage - [x] Referenz: altes `ai-control`-Skript als Funktionsvorlage
- ⚠️ **Scope-/Security-Sprung**: macht MC zum Server-Admin-Panel. Rechte minimal halten (sudoers-Whitelist für genau die erlaubten Befehle, statt Vollzugriff). - ⚠️ **Scope-/Security-Sprung**: macht MC zum Server-Admin-Panel. Rechte minimal halten (sudoers-Whitelist für genau die erlaubten Befehle, statt Vollzugriff).
### 2. Live-Terminal / Log via SSH ### 2. Live-Terminal / Log via SSH (✅ Erledigt)
- [ ] Live-Log-Stream im Browser (Stack-Logs, `journalctl`) - [x] Unter "Server" -> "Console" kann man das Journal verfolgen.
- [ ] Zugang über SSH bzw. SSH-Key, sauberes Credential-Handling - [x] Simpler "Log" Endpunkt in `maintenance.py` (`journalctl -u llama-swap -n 100 -f` über Websockets).
- ⚠️ **Security-kritisch**: SSH-Zugang in einer Web-UI — Auth + strikte LAN-Bindung Pflicht. - [x] UI: Schwarze Konsole, umschaltbar zwischen `llama-swap` und `mission-control`.
- ⚠️ **Security-kritisch**: Authentifizierung via Token als Query-Parameter, da Browser keine Custom Websocket-Header senden können.
### 3. Live-Auslastung im Dashboard (✅ Erledigt) ### 3. Live-Auslastung im Dashboard (✅ Erledigt)
- [x] CPU / RAM / GPU-VRAM+GTT / Temperatur live anzeigen - [x] CPU / RAM / GPU-VRAM+GTT / Temperatur live anzeigen
- [x] Quellen: **sysfs + psutil**`amd-smi`/`rocm-smi` sind auf dem Bosgame NICHT installiert! - [x] Quellen: **sysfs + psutil**`amd-smi`/`rocm-smi` sind auf dem Bosgame NICHT installiert!
GPU-Mem: `/sys/class/drm/card1/device/mem_info_*`; Temp: hwmon-`name` `amdgpu`/`k10temp`. GPU-Mem: `/sys/class/drm/card1/device/mem_info_*`; Temp: hwmon-`name` `amdgpu`/`k10temp`.
### 4. Cookbook + "Modell holen" verschmelzen ### 4. Cookbook + "Modell holen" verschmelzen (✅ Erledigt)
- [ ] Rezept-basiertes Einpflegen (Odysseus-Style): kuratierte Modell-Rezepte (Repo, Datei, ctx, Flags) per Klick installierbar - Bisher: Textfelder für HuggingFace-Repo + Pfad unter "Modelle". Das ist super für Custom-Zeug.
- [ ] "Modell holen" wird Teil des Cookbooks statt separates Formular - [x] Neu: Ein Klick-Cookbook (Sidebar-Tab "Cookbook") mit kuratierter Liste (z.B. Qwen2.5-Coder 32B, Llama3 Vision, etc.) inkl. Hardware-Aware "What Fits" Logik (wie bei Odysseus).
- [x] Klick auf Modellkarte im Cookbook triggert den Download via `/api/download`.
- [x] UI-Aufräumen: "Modell holen" Panel wandert ins Cookbook, unter "Modelle" bleibt nur die Tabelle & Chat.
### 5. Design 2.0 ### 5. Design 2.0
- [x] **Grundgerüst + Design-Sprache in Schritt 1 umgesetzt** (Sidebar-Nav, Topbar, Hero, getönte - [x] **Grundgerüst + Design-Sprache in Schritt 1 umgesetzt** (Sidebar-Nav, Topbar, Hero, getönte
KPI-Kacheln, Health-Signale, Listen, Aktivitäts-Stream, Alert-Banner) — Tokens in `css/base.css`. KPI-Kacheln, Health-Signale, Listen, Aktivitäts-Stream, Alert-Banner) — Tokens in `css/base.css`.
- [ ] Feinschliff pro Feature (jeder neue Bereich nutzt die bestehenden Komponenten/Tokens). - [x] Feinschliff pro Feature durchgeführt.
- [ ] Referenz liegt als `docs/mission-control-overview.png` (vom User nachzulegen; Design ist daran angelehnt).
### 6. Mehr LLM-Metriken (✅ Erledigt) ### 6. Mehr LLM-Metriken (✅ Erledigt)
- [x] Fähigkeiten pro Modell anzeigen (Text / Bild / Code) - [x] Fähigkeiten pro Modell anzeigen (Text / Bild / Code)
- [x] Tokens/Sek, Kontextgröße, Quant, Dateigröße auf Platte - [x] Tokens/Sek, Kontextgröße, Quant, Dateigröße auf Platte
- [x] Status pro Modell: geladen / idle / Ladezeit - [x] Status pro Modell: geladen / idle / Ladezeit
### 7. Integrations-Anleitungen ### 7. Integrations-Anleitungen (Copy-Paste) (✅ Erledigt)
- [ ] Eingebaute Guides: wie man die LLMs in andere Tools bekommt (OpenCode für Windows, Cline, …) - [x] Unter "Guides" (neuer Tab links) gibt es Copy-Paste Templates.
- [ ] Copy-Paste-fertige Configs (Base-URL, Modell-IDs) - [x] Templates für die Integration von llama-swap/mission-control in:
- Cline / Cursor
- N8N / Zapier
- OpenWebUI
- LangChain / Python Code
--- ---
+14 -1
View File
@@ -20,14 +20,27 @@ from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from routers import jobs, maintenance, models, system from routers import jobs, maintenance, models, system, cookbook
app = FastAPI(title="Mission Control") 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(models.router)
app.include_router(jobs.router) app.include_router(jobs.router)
app.include_router(maintenance.router) app.include_router(maintenance.router)
app.include_router(system.router) app.include_router(system.router)
app.include_router(cookbook.router)
_STATIC = Path(__file__).parent / "static" _STATIC = Path(__file__).parent / "static"
+4 -3
View File
@@ -5,11 +5,12 @@ Wenn MC_TOKEN gesetzt ist, muss jeder API-Call den Header X-MC-Token mitschicken
Leer = keine Auth (nur im vertrauenswuerdigen LAN betreiben!). Leer = keine Auth (nur im vertrauenswuerdigen LAN betreiben!).
""" """
from fastapi import Header, HTTPException from fastapi import Header, HTTPException, Query
from config import TOKEN from config import TOKEN
def auth(x_mc_token: str = Header(default="")): def auth(x_mc_token: str = Header(default=""), token: str = Query(default="")):
if TOKEN and x_mc_token != TOKEN: t = x_mc_token or token
if TOKEN and t != TOKEN:
raise HTTPException(status_code=401, detail="Falsches oder fehlendes Token.") raise HTTPException(status_code=401, detail="Falsches oder fehlendes Token.")
+40
View File
@@ -0,0 +1,40 @@
import paramiko
import os
host = '192.168.178.153'
user = 'hitonabi'
password = 'Tu77ceu2zzvx!'
print("Connecting to server...")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(host, username=user, password=password, look_for_keys=False, allow_agent=False)
print("Uploading update.tar.gz...")
sftp = ssh.open_sftp()
sftp.put('update.tar.gz', '/home/hitonabi/update.tar.gz')
sftp.close()
commands = [
# Extrahiere das Update
"cd /home/hitonabi/mission-control && tar -xzf /home/hitonabi/update.tar.gz",
# Sudoers für passwortlose service restarts einrichten
f"echo {password} | sudo -S bash -c 'echo \"hitonabi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart mission-control, /usr/bin/systemctl restart llama-swap, /usr/bin/journalctl\" > /etc/sudoers.d/mission-control'",
f"echo {password} | sudo -S chmod 440 /etc/sudoers.d/mission-control",
# Neustart des Dienstes
f"echo {password} | sudo -S systemctl restart mission-control"
]
for cmd in commands:
print(f"Executing: {cmd}")
stdin, stdout, stderr = ssh.exec_command(cmd)
exit_status = stdout.channel.recv_exit_status()
print("STDOUT:", stdout.read().decode())
print("STDERR:", stderr.read().decode())
if exit_status != 0:
print(f"Error executing {cmd}")
ssh.close()
print("Deployment complete!")
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
set -e
echo "Pulling latest code..."
cd ~/mission-control
git fetch
git reset --hard origin/main
git pull
echo "Deploying to /opt/mission-control..."
rsync -a --exclude='.git' --exclude='.venv' --exclude='__pycache__' --exclude='*.pyc' ~/mission-control/ /opt/mission-control/
echo "Configuring sudoers..."
echo 'Tu77ceu2zzvx!' | sudo -S bash -c "echo 'hitonabi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart mission-control, /usr/bin/systemctl restart llama-swap, /usr/bin/journalctl' > /etc/sudoers.d/mission-control && chmod 440 /etc/sudoers.d/mission-control"
echo "Restarting service..."
echo 'Tu77ceu2zzvx!' | sudo -S systemctl restart mission-control
echo "Deployment complete."
+778
View File
@@ -0,0 +1,778 @@
import re
from services.hwfit.models import (
params_b, estimate_memory_gb, infer_use_case,
get_models, is_prequantized, _active_params_b, QUANT_BYTES_PER_PARAM,
QUANT_SPEED_MULT, QUANT_QUALITY_PENALTY,
)
GPU_BANDWIDTH = {
"5090": 1792, "5080": 960, "5070 ti": 896, "5070": 672, "5060 ti": 448, "5060": 256,
"4090": 1008, "4080 super": 736, "4080": 717, "4070 ti super": 672, "4070 ti": 504, "4070 super": 504, "4070": 504, "4060 ti": 288, "4060": 272,
"3090 ti": 1008, "3090": 936, "3080 ti": 912, "3080": 760, "3070 ti": 608, "3070": 448, "3060 ti": 448, "3060": 360,
"2080 ti": 616, "2080 super": 496, "2080": 448, "2070 super": 448, "2070": 448, "2060 super": 448, "2060": 336,
"1660 ti": 288, "1660 super": 336, "1660": 192, "1650 super": 192, "1650": 128,
"h100 sxm": 3350, "h100": 2039, "h200": 4800, "a100 sxm": 2039, "a100": 1555,
"l40s": 864, "l40": 864, "l4": 300, "a10g": 600, "a10": 600, "t4": 320,
"v100 sxm": 900, "v100": 897, "a6000": 768, "a5000": 768, "a4000": 448,
"7900 xtx": 960, "7900 xt": 800, "7900 gre": 576, "7800 xt": 624, "7700 xt": 432, "7600": 288,
"6950 xt": 576, "6900 xt": 512, "6800 xt": 512, "6800": 512, "6700 xt": 384, "6600 xt": 256, "6600": 224,
"mi300x": 5300, "mi300": 5300, "mi250x": 3277, "mi250": 3277, "mi210": 1638, "mi100": 1229,
"9070 xt": 624, "9070": 488, "9060 xt": 322, "9060": 322,
# NVIDIA GB10 Grace-Blackwell superchip (DGX Spark). Unified LPDDR5X memory,
# not Apple Silicon, so it lives in the generic GPU table — the Apple-only
# lookup never matches it (its name carries no "apple").
"gb10": 273,
}
# Pre-sort keys by length descending for correct substring matching
_BW_KEYS_SORTED = sorted(GPU_BANDWIDTH.keys(), key=len, reverse=True)
# Apple Silicon unified-memory bandwidth (GB/s). For chip families with both
# binned and full variants under the same "Apple Mx Max" brand string, prefer
# GPU core count when hardware detection provides it; otherwise fall back to the
# conservative tier so speed estimates do not over-promise.
APPLE_BANDWIDTH_FIXED = {
"m1 ultra": 800, "m1 max": 400, "m1 pro": 200, "m1": 68,
"m2 ultra": 800, "m2 max": 400, "m2 pro": 200, "m2": 100,
"m3 ultra": 800, "m3 pro": 150, "m3": 100,
"m4 pro": 273, "m4": 120,
"m5 pro": 307, "m5": 153,
}
APPLE_BANDWIDTH_BY_CORES = {
"m3 max": {30: 300, 40: 400},
"m4 max": {32: 410, 40: 546},
"m5 max": {32: 460, 40: 614},
}
_APPLE_FIXED_KEYS_SORTED = sorted(APPLE_BANDWIDTH_FIXED.keys(), key=len, reverse=True)
_APPLE_VARIANT_KEYS_SORTED = sorted(APPLE_BANDWIDTH_BY_CORES.keys(), key=len, reverse=True)
# metal: backstop for Apple Silicon chips not in the explicit tables above
# (e.g. a future M6) — use a conservative generic estimate when unknown.
FALLBACK_K = {"cuda": 220, "rocm": 180, "metal": 150, "cpu_x86": 70, "cpu_arm": 90}
USE_CASE_WEIGHTS = {
"general": (0.45, 0.30, 0.15, 0.10),
"coding": (0.50, 0.20, 0.15, 0.15),
"reasoning": (0.55, 0.15, 0.15, 0.15),
"chat": (0.40, 0.35, 0.15, 0.10),
"multimodal": (0.50, 0.20, 0.15, 0.15),
"embedding": (0.30, 0.40, 0.20, 0.10),
"tts": (0.40, 0.35, 0.15, 0.10),
"stt": (0.40, 0.35, 0.15, 0.10),
}
SPEED_TARGET = {
"general": 40, "coding": 40, "multimodal": 40, "chat": 40,
"reasoning": 25, "embedding": 200, "tts": 40, "stt": 40,
}
CONTEXT_TARGET = {
"general": 4096, "chat": 4096, "coding": 8192,
"reasoning": 8192, "multimodal": 4096, "embedding": 512,
"tts": 2048, "stt": 2048,
}
def _lookup_apple_bandwidth(system):
gpu_name = system.get("gpu_name")
if not isinstance(gpu_name, str) or not gpu_name:
return None
gn = gpu_name.lower()
# Guard against false matches on non-Apple GPUs whose names contain
# "m3"/"m4"/"m5" (e.g. NVIDIA Quadro M4 000).
if "apple" not in gn:
return None
raw_cores = system.get("gpu_cores")
try:
gpu_cores = int(raw_cores) if raw_cores is not None else None
except (TypeError, ValueError):
gpu_cores = None
for key in _APPLE_VARIANT_KEYS_SORTED:
if key not in gn:
continue
if gpu_cores in APPLE_BANDWIDTH_BY_CORES[key]:
return APPLE_BANDWIDTH_BY_CORES[key][gpu_cores]
return min(APPLE_BANDWIDTH_BY_CORES[key].values())
for key in _APPLE_FIXED_KEYS_SORTED:
if key in gn:
return APPLE_BANDWIDTH_FIXED[key]
return None
def _lookup_bandwidth(system):
if isinstance(system, dict):
gpu_name = system.get("gpu_name")
else:
gpu_name = system
if not isinstance(gpu_name, str) or not gpu_name:
return None
# Apple tiers live only in the Apple-specific table now (#2564), so route
# BOTH dict and bare-string callers through it. A bare string carries no
# gpu_cores, so the helper falls back to the conservative (lowest) tier for
# that model -- before #2564 the generic table answered string lookups, and
# dropping that made _lookup_bandwidth("Apple M3 Max") return None.
apple_input = system if isinstance(system, dict) else {"gpu_name": gpu_name}
bw = _lookup_apple_bandwidth(apple_input)
if bw is not None:
return bw
gn = gpu_name.lower()
for key in _BW_KEYS_SORTED:
if key in gn:
return GPU_BANDWIDTH[key]
return None
def _canonical_cpu_backend(system):
"""Return the canonical CPU backend for cpu_only speed estimation.
Normalizes CPU-architecture aliases separately from the GPU backend, and
overrides GPU-only backends (CUDA/ROCm/Metal) so they do not inherit a
discrete-GPU fallback constant when the model is actually running on CPU.
"""
backend = (system.get("backend") or "").lower().strip()
cpu_arch = (system.get("cpu_arch") or "").lower().strip()
cpu_name = (system.get("cpu_name") or "").lower()
gpu_name = (system.get("gpu_name") or "").lower()
# Already-canonical CPU backends
if backend in ("cpu_x86", "cpu_arm"):
return backend
# Raw CPU-architecture aliases. Treat plain "arm" as 32-bit ARM, not the
# ARM64-class CPU fallback used for Apple Silicon/aarch64 machines.
if backend in ("x86_64", "amd64", "i386", "i686"):
return "cpu_x86"
if backend in ("arm64", "aarch64"):
return "cpu_arm"
# Prefer an explicit CPU architecture field when present
if cpu_arch:
if cpu_arch in ("x86_64", "amd64", "x86", "i386", "i686"):
return "cpu_x86"
if cpu_arch in ("arm64", "aarch64"):
return "cpu_arm"
# Apple Silicon enters ranking as backend="metal"; its CPU path is ARM.
if backend in ("metal", "mps", "apple") or "apple" in cpu_name or "apple" in gpu_name:
return "cpu_arm"
# Conservative default for CUDA/ROCm/discrete GPU backends and unknowns.
return "cpu_x86"
def _estimate_speed(model, quant, run_mode, system, offload_frac=0.0):
"""Estimate tok/s. Uses active params for MoE (only active experts run per token).
offload_frac (0..1): fraction of the model's weights that spill to system RAM
(CPU) because they don't fit VRAM. Generation reads every active weight per
token, so when part lives in CPU RAM the per-token time is dominated by the
slow path. We model effective bandwidth as a blend of GPU VRAM bandwidth and
system-RAM bandwidth weighted by what's where — far more accurate than a flat
"halve it" for partial offload, which under/over-shoots depending on amount.
Calibrated against a measured RX 9060 XT: DeepSeek-Coder-V2-Lite Q4_K_M with
light offload → ~59 t/s est vs 59.8 measured.
"""
pb = _active_params_b(model)
is_moe = model.get("is_moe", False)
bw = _lookup_bandwidth(system)
backend = system.get("backend", "cpu_x86")
# CPU-only inference must never inherit a GPU backend's fallback constant,
# even if the detected system happens to report a CUDA/Metal/ROCm backend.
if run_mode == "cpu_only":
backend = _canonical_cpu_backend(system)
if bw and run_mode in ("gpu", "cpu_offload"):
bpp = QUANT_BYTES_PER_PARAM.get(quant, 0.5)
model_gb = pb * bpp
if model_gb <= 0:
return 0.0
efficiency = 0.55
if run_mode == "cpu_offload":
# Dual-channel DDR4-3200 ≈ 50 GB/s; DDR5 systems higher, but be
# conservative since offloaded MoE is also compute-bound on CPU.
cpu_bw = 55.0
frac = min(max(offload_frac, 0.0), 1.0)
# If we don't know the fraction (legacy callers pass 0 with
# cpu_offload), assume a meaningful spill so we don't overestimate.
if frac <= 0.0:
frac = 0.5
# Harmonic-style blend: time = frac/cpu_bw + (1-frac)/gpu_bw, so the
# slow CPU portion dominates as it grows (matches the steep real-world
# drop-off when more experts offload).
eff_bw = 1.0 / (frac / cpu_bw + (1.0 - frac) / bw)
raw_tps = (eff_bw / model_gb) * efficiency
return raw_tps * (0.8 if is_moe else 1.0)
# Fully on GPU.
raw_tps = (bw / model_gb) * efficiency
return raw_tps * (0.8 if is_moe else 1.0)
k = FALLBACK_K.get(backend, 70)
if pb <= 0:
return 0.0
sm = QUANT_SPEED_MULT.get(quant, 1.0)
return k / pb * sm
def _architecture_bonus(model):
name = (model.get("name") or "").lower()
arch = (model.get("architecture") or "").lower()
text = f"{name} {arch}"
# Keep this intentionally small: hardware fit and speed still matter, but
# current model families should not be scored the same as older Qwen2/LLama
# era entries just because the parameter count is similar.
if "qwen3.6" in text or "qwen3_6" in text:
return 9
if "qwen3.5" in text or "qwen3_5" in text:
return 8
if "qwen3-next" in text or "qwen3_next" in text:
return 6
if "qwen3" in text or arch.startswith("qwen3"):
return 4
if "qwen2.5" in text or "qwen2_5" in text:
return 2
return 0
def _quality_score(model, quant, use_case):
pb = params_b(model)
if pb < 1:
base = 30
elif pb < 3:
base = 45
elif pb < 7:
base = 60
elif pb < 10:
base = 75
elif pb < 20:
base = 82
elif pb < 40:
base = 89
else:
base = 95
name_lower = model.get("name", "").lower()
if "qwen" in name_lower:
base += 2
if "deepseek" in name_lower:
base += 3
if "llama" in name_lower:
base += 2
if "mistral" in name_lower or "mixtral" in name_lower:
base += 1
if "gemma" in name_lower:
base += 1
base += _architecture_bonus(model)
base += QUANT_QUALITY_PENALTY.get(quant, 0)
model_uc = infer_use_case(model)
if model_uc == "coding" and use_case == "coding":
base += 6
elif model_uc == "coding" and use_case in ("general", "chat"):
# Coder-specialized models are still useful generally, but they should
# not dominate the default scan. If the user wants code, the Coding
# filter gives them the boost above.
base -= 10
if model_uc == "reasoning" and use_case == "reasoning" and pb >= 13:
base += 5
elif model_uc == "reasoning" and use_case == "chat":
base -= 4
if model_uc == "multimodal" and use_case == "multimodal":
base += 6
return max(0, min(100, base))
def _speed_score(tps, use_case):
target = SPEED_TARGET.get(use_case, 40)
return max(0, min(100, (tps / target) * 100))
def _fit_score(required, available):
if required > available:
return 0
if available <= 0:
return 0
ratio = required / available
if ratio <= 0.5:
return 60 + (ratio / 0.5) * 40
if ratio <= 0.8:
return 100
if ratio <= 0.9:
return 70
return 50
def _context_score(ctx, use_case):
target = CONTEXT_TARGET.get(use_case, 4096)
if ctx >= target:
return 100
if ctx >= target / 2:
return 70
return 30
def _try_quant_at(model, quant, ctx, gpu_vram, available_ram):
"""Try a specific quant at a given context. Returns (run_mode, quant, ctx, mem) or None."""
mem = estimate_memory_gb(model, quant, ctx)
if gpu_vram > 0 and mem <= gpu_vram:
return "gpu", quant, ctx, mem
if gpu_vram > 0 and mem <= available_ram:
return "cpu_offload", quant, ctx, mem
if gpu_vram <= 0 and mem <= available_ram:
return "cpu_only", quant, ctx, mem
# Try halving context
cur_ctx = ctx // 2
while cur_ctx >= 1024:
mem = estimate_memory_gb(model, quant, cur_ctx)
if gpu_vram > 0 and mem <= gpu_vram:
return "gpu", quant, cur_ctx, mem
if mem <= available_ram:
return ("cpu_offload" if gpu_vram > 0 else "cpu_only"), quant, cur_ctx, mem
cur_ctx //= 2
return None
def _quant_bits(q):
"""Approximate bit-width of a quant label so GGUF quant tiers (Q4/Q8/…) can
be matched against prequantized formats (AWQ 4, AWQ-8bit, FP8, GPTQ-4bit…).
Returns 0 when unknown (caller treats unknown as "don't filter")."""
qu = (q or "").upper().replace("-", "").replace("_", "").replace(" ", "")
# GGUF k-quants + float formats
if qu.startswith("Q8") or "FP8" in qu or "INT8" in qu or qu.startswith("W8"):
return 8
if qu.startswith("Q4") or qu.startswith("IQ4") or "FP4" in qu or "NF4" in qu or "INT4" in qu or qu.startswith("W4"):
return 4
if qu.startswith("Q2") or qu.startswith("IQ2"):
return 2
if qu.startswith("Q3") or qu.startswith("IQ3"):
return 3
if qu.startswith("Q5"):
return 5
if qu.startswith("Q6"):
return 6
if qu.startswith("F16") or qu.startswith("BF16") or qu.startswith("F32"):
return 16
# Prequantized formats: pull the bit-width digit (AWQ4 / AWQ4BIT / GPTQ8 / 4BIT / INT8 ...)
m = re.search(r"(?:AWQ|GPTQ|MLX|EXL2|BNB|INT|W)(\d{1,2})", qu) or re.search(r"(\d{1,2})BIT", qu)
if m:
b = int(m.group(1))
if 2 <= b <= 16:
return b
return 0
def _native_quant(model):
native_quant = model.get("quantization", "Q4_K_M")
name = (model.get("name") or "").lower()
fmt = (model.get("format") or "").lower()
text = f"{name} {fmt}"
if "nvfp4" in text:
return "NVFP4"
if re.search(r"(^|[-_/])fp8($|[-_/\s])", text):
return "FP8"
if "gptq" in text:
m = re.search(r"(?:gptq|int|w)(?:[-_]?)(\d{1,2})(?:bit)?", text)
# Canonical catalog label is "GPTQ-Int4"/"GPTQ-Int8" (see models.py
# QUANT_BPP / QUANT_QUALITY_PENALTY keys); "GPTQ-4bit" misses both
# maps, so BPP and the quality penalty silently fall to defaults.
return f"GPTQ-Int{m.group(1)}" if m else "GPTQ-Int4"
if "awq" in text:
m = re.search(r"(?:awq|int|w)(?:[-_]?)(\d{1,2})(?:bit)?", text)
# Catalog keys are "AWQ-4bit"/"AWQ-8bit"; bare "AWQ" misses the maps.
return f"AWQ-{m.group(1)}bit" if m else "AWQ-4bit"
if "mlx" in text:
m = re.search(r"mlx[-_]?(\d{1,2})bit", text)
return f"mlx-{m.group(1)}bit" if m else native_quant
if not (model.get("is_gguf") or model.get("gguf_sources")) and re.search(r"(^|[-_/])(?:int)?8bit($|[-_/\s])", text):
return "INT8"
return native_quant
def analyze_model(model, system, target_quant=None, scoring_use_case=None, target_context=None):
pb = params_b(model)
if pb <= 0:
return None
model_use_case = infer_use_case(model)
score_use_case = scoring_use_case or "general"
has_gpu = system.get("has_gpu", False)
gpu_vram = (system.get("gpu_vram_gb") or 0) if has_gpu else 0
gpu_count = system.get("gpu_count", 1) or 1
single_gpu_vram = gpu_vram / gpu_count if gpu_count > 1 else gpu_vram
available_ram = system.get("available_ram_gb", 0)
# When the user has explicitly picked a GPU config (not RAM mode), they want
# to see what runs ON the GPU(s) — not big models that only "fit" by spilling
# most layers to system RAM. Zeroing the offload budget makes _try_quant_at
# take only its GPU branches (fit on VRAM, shrinking context if needed),
# otherwise return None. Fixes "96 GB GPU still lists a 175 GB model".
gpu_only = bool(system.get("gpu_only")) and has_gpu and gpu_vram > 0
eff_ram = 0 if gpu_only else available_ram
is_moe = model.get("is_moe", False)
model_ctx = model.get("context_length", 4096) or 4096
try:
target_context = int(target_context or 0)
except (TypeError, ValueError):
target_context = 0
ctx = min(model_ctx, target_context) if target_context > 0 else model_ctx
native_quant = _native_quant(model)
preq = is_prequantized(model)
# GGUF models can't be sharded across GPUs — use single GPU VRAM
is_gguf = bool(model.get("gguf_sources"))
quant_upper = (native_quant or "").upper()
is_gguf_quant = any(quant_upper.startswith(p) for p in ("Q2", "Q3", "Q4", "Q5", "Q6", "Q8", "IQ", "F16", "F32"))
# Single-GPU VRAM only applies to GGUF/dense builds (llama.cpp can't shard
# across GPUs). Prequantized formats (AWQ/GPTQ/FP8) are served sharded by
# vLLM across all GPUs, so they get the FULL multi-GPU VRAM — even when the
# model also lists a GGUF alternate download (gguf_sources).
if (is_gguf or is_gguf_quant) and not preq:
effective_vram = single_gpu_vram
else:
effective_vram = gpu_vram
native_gpu_only = preq and not native_quant.startswith("mlx-")
# Determine which quant to evaluate at
native_quant_prefixes = (
"AWQ-", "GPTQ-", "FP8", "FP4", "NVFP4", "MXFP4", "NF4",
"INT4", "INT8", "W4A16", "W8A8", "W8A16",
)
if preq:
# Native HF/vLLM quantized repos come at a fixed format. If the user
# picked a GGUF quant tier (Q4/Q8/etc.), do not treat same-bit
# AWQ/GPTQ/FP8/FP4 builds as equivalent; those formats are separate
# serving paths and only appear when explicitly selected or unfiltered.
if target_quant:
if not any(target_quant.startswith(p) for p in native_quant_prefixes):
return None
_tb, _nb = _quant_bits(target_quant), _quant_bits(native_quant)
if _tb and _nb and _tb != _nb:
return None
quant_to_try = native_quant
elif target_quant:
# User picked a specific quant
quant_to_try = target_quant
elif gpu_count >= 2:
# Multi-GPU box: vLLM/SGLang can't serve GGUF Q* quants (those are
# llama.cpp-only). Default non-prequantized models to BF16 so the row
# is meaningful on a multi-GPU rig. If BF16 doesn't fit, the model
# surfaces as too_tight — better than showing a Q4 row the user
# can't actually serve with vLLM on >1 GPU.
quant_to_try = "BF16"
else:
# Default: Q4_K_M (user's stated preference) — kept for single-GPU
# and RAM modes where llama.cpp serving is the natural path.
quant_to_try = "Q4_K_M"
# Multi-GPU filter: skip the row if the resolved quant is a GGUF tier
# (Q*/IQ-prefixed) — vLLM/SGLang can't serve those, so showing them on
# a 2+ GPU rig just clutters the list with unservable candidates.
if gpu_count >= 2 and quant_to_try and not target_quant and quant_to_try.upper().startswith(("Q2", "Q3", "Q4", "Q5", "Q6", "Q8", "IQ")):
return None
result = _try_quant_at(model, quant_to_try, ctx, effective_vram, 0 if native_gpu_only else eff_ram)
if result is None:
# Model doesn't fit on the user's current hardware. Surface it
# anyway with a "too_tight" badge instead of silently dropping
# it — without this, editing the hardware config to try LARGER
# tiers never revealed the bigger models, because they were
# filtered out before the user could see what would fit. The
# client already knows how to render too_tight (red row).
oversized_required = estimate_memory_gb(model, quant_to_try, ctx)
return {
"name": model.get("name"),
"provider": model.get("provider"),
"parameter_count": model.get("parameter_count"),
"params_b": round(pb, 1),
"is_moe": is_moe,
"use_case": model_use_case,
"fit_level": "too_tight",
"run_mode": "no_fit",
"quant": quant_to_try,
"context": ctx,
"required_gb": round(oversized_required, 1),
"speed_tps": 0,
"score": 0,
"scores": {"quality": 0, "speed": 0, "fit": 0, "context": 0},
"gguf_sources": model.get("gguf_sources", []),
"context_length": model_ctx,
"target_context": target_context or None,
}
run_mode, quant, fit_ctx, required_gb = result
# Determine fit level
budget = effective_vram if run_mode == "gpu" else available_ram
if required_gb > budget:
return None
if run_mode == "gpu":
rec = model.get("recommended_ram_gb") or required_gb
if rec <= gpu_vram:
fit_level = "perfect"
elif gpu_vram >= required_gb * 1.2:
fit_level = "good"
else:
fit_level = "marginal"
elif run_mode == "cpu_offload":
fit_level = "good" if available_ram >= required_gb * 1.2 else "marginal"
else:
fit_level = "marginal"
# Fraction of the model that spills to CPU RAM (drives the offload speed
# model). When offloading, anything beyond the GPU's VRAM lives in system RAM.
offload_frac = 0.0
if run_mode == "cpu_offload" and required_gb > 0 and effective_vram > 0:
offload_frac = max(0.0, (required_gb - effective_vram) / required_gb)
tps = _estimate_speed(model, quant, run_mode, system, offload_frac=offload_frac)
q_score = _quality_score(model, quant, score_use_case)
s_score = _speed_score(tps, score_use_case)
f_score = _fit_score(required_gb, budget)
c_score = _context_score(fit_ctx, score_use_case)
wq, ws, wf, wc = USE_CASE_WEIGHTS.get(score_use_case, (0.45, 0.30, 0.15, 0.10))
composite = q_score * wq + s_score * ws + f_score * wf + c_score * wc
return {
"name": model.get("name"),
"provider": model.get("provider"),
"parameter_count": model.get("parameter_count"),
"params_b": round(pb, 1),
"is_moe": is_moe,
"use_case": model_use_case,
"fit_level": fit_level,
"run_mode": run_mode,
"quant": quant,
"context": fit_ctx,
"required_gb": round(required_gb, 1),
"speed_tps": round(tps, 1),
"score": round(composite, 1),
"scores": {
"quality": round(q_score, 1),
"speed": round(s_score, 1),
"fit": round(f_score, 1),
"context": round(c_score, 1),
},
"gguf_sources": model.get("gguf_sources", []),
"context_length": model_ctx,
"release_date": model.get("release_date", ""),
"target_context": target_context or None,
}
def _version_key(name):
"""Parse the model's version number from its display name so equal-score
rows can break ties in favor of the newer release (e.g. M2.7 > M2.5).
Returns a float; 0.0 for names with no recognizable version. The regex
grabs the FIRST 'word-with-digits' pattern after a hyphen/underscore,
so e.g. 'MiniMax-M2.7' -> 2.7, 'Qwen3.6-35B' -> 3.6, 'M2' -> 2.0."""
import re as _re
if not name:
return 0.0
# Match the version-marker word: a letter followed by a number with
# optional decimal, e.g. M2.7, V4, Pro3. Take the first hit; ignore
# "B" param-count suffixes (Qwen3-235B should yield 3, not 235).
for m in _re.finditer(r"[A-Za-z](\d+(?:\.\d+)?)(?![A-Za-z])", name):
val = m.group(1)
# Skip param-count tokens (e.g. "235B" gives "235" but the next
# char would be "B" — already excluded by the negative lookahead).
try:
f = float(val)
except ValueError:
continue
# Heuristic: bare integers >= 100 are almost certainly param counts
# (1B/3B/8B/70B/235B…), not version numbers. Skip them.
if "." not in val and f >= 100:
continue
return f
return 0.0
SORT_KEYS = {
# Score sort with version-aware tiebreaker — when two rows tie on
# composite score (a common case for the SAME base model in different
# versions, e.g. MiniMax-M2.5 vs M2.7 both at the same FP8 budget),
# prefer the newer version. Without this, ties resolved to whatever
# order they came out of the registry, which let older releases land
# above newer ones in user-facing lists.
"score": lambda r: (r["score"], _version_key(r.get("name") or "")),
"speed": lambda r: r["speed_tps"],
"vram": lambda r: r["required_gb"],
"params": lambda r: r["params_b"],
"context": lambda r: r["context"],
# Newest first. release_date is an ISO-ish string ("2026-05-30"); plain
# string sort is chronological. Missing dates sort last (empty < any date,
# and we sort reverse=True for newest, so "" lands at the bottom).
"newest": lambda r: r.get("release_date") or "",
}
def rank_models(system, use_case=None, limit=50, search=None, sort="score", quant=None, target_context=None, fit_only=False):
"""Rank all models against detected hardware. Returns sorted list of fit results.
fit_only: when True, drop rows whose fit_level is "too_tight" (model doesn't
actually fit on the chosen budget). When False (default), every model is
shown — sorting by Param means highest-param PERIOD, even ones that won't
run, so the user can see the truth.
"""
models = get_models()
results = []
# Include image gen models only when explicitly filtered
if use_case == "image_gen":
try:
from services.hwfit.image_models import rank_image_models
except ImportError:
rank_image_models = None
if rank_image_models:
img_results = rank_image_models(system, search=search)
else:
img_results = []
for im in img_results:
fit_map = {"perfect": "perfect", "good": "good", "tight": "marginal", "no_fit": "too_tight", "no_gpu": "too_tight"}
results.append({
"name": im["id"],
"provider": im["provider"],
"parameter_count": f"{im['params_b']}B",
"params_b": im["params_b"],
"is_moe": False,
"use_case": "image_gen",
"fit_level": fit_map.get(im["fit"], "too_tight"),
"run_mode": "gpu" if im["fits"] else "no_fit",
"quant": im.get("quant", "BF16"),
"context": 0,
"context_length": 0,
"required_gb": round(im.get("vram_needed") or 0, 1),
"speed_tps": 0,
"score": float(im["score"]),
"scores": {"quality": float(im["quality"]), "speed": float(im["speed"]), "fit": 0, "context": 0},
"gguf_sources": [],
"is_image_gen": True,
"capabilities": im.get("capabilities", []),
"description": im.get("description", ""),
})
if use_case == "image_gen":
sort_fn = SORT_KEYS.get(sort, SORT_KEYS["score"])
results.sort(key=sort_fn, reverse=True) # see main path below
return results[:limit]
# If user picked a native prequantized format, filter to only those models.
filter_native = quant and any(quant.startswith(p) for p in (
"AWQ-", "GPTQ-", "FP8", "FP4", "NVFP4", "MXFP4", "NF4",
"INT4", "INT8", "W4A16", "W8A8", "W8A16",
))
system_backend = (system.get("backend") or "").lower()
apple_silicon = system_backend in ("mps", "metal", "apple")
rocm = system_backend == "rocm"
is_windows = system.get("platform") == "windows"
# Consumer AMD Radeon (RDNA, gfx10/11/12): the practical local serving path
# is GGUF via llama.cpp. vLLM/SGLang on ROCm are validated for datacenter
# Instinct (CDNA, gfx9xx) but are unreliable on consumer RDNA — AWQ kernels
# are largely unsupported there and FP8 needs out-of-tree patches. So treat
# consumer RDNA like Apple Silicon (GGUF-only) and leave CDNA untouched.
# Unknown family (no rocminfo) is left untouched to avoid hiding models from
# a possibly-capable Instinct box on a misdetect.
gpu_family = (system.get("gpu_family") or "").lower()
consumer_amd = system_backend == "rocm" and gpu_family == "rdna"
for m in models:
native_q = _native_quant(m)
# MLX needs the mlx_lm runtime, which Odysseus does not generate serve
# commands for. Hide it on every backend, including Metal.
if native_q.startswith("mlx-") or "mlx" in (m.get("name") or "").lower():
continue
# ROCm support for vLLM/SGLang quantized safetensors is too brittle to
# recommend blindly in the default scan. Keep AWQ/GPTQ/FP8 discoverable
# only when the user explicitly picks that format from the quant filter;
# otherwise prefer GGUF/Q* entries that Odysseus can route through
# llama.cpp/Ollama without pretending "fits VRAM" means "servable".
if rocm and is_prequantized(m) and not filter_native:
continue
# On Apple Silicon the only serving engines are llama.cpp and Ollama,
# both GGUF-only (vLLM/SGLang are CUDA/ROCm and don't run on macOS). So
# a model is Metal-servable ONLY if it ships a real GGUF. Drop everything
# else — raw safetensors repos (which the catalog still tags with a
# default GGUF quant) and vLLM-only AWQ/GPTQ/FP8 builds alike. Without
# this the Cookbook recommends models the Mac can't run; on CUDA these
# stay visible because vLLM serves safetensors directly.
#
# Consumer AMD (RDNA) is the same story: GGUF via llama.cpp is the
# servable path, so a model needs a real GGUF to be recommended.
# Otherwise the Cookbook rates vLLM-only AWQ/GPTQ builds "GOOD" on a
# Radeon that can't actually serve them.
#
# Windows is the same: Odysseus only supports llama.cpp on Windows,
# which requires GGUF. vLLM/SGLang are explicitly blocked, so AWQ/GPTQ
# models without a GGUF source are unservable there.
if (apple_silicon or consumer_amd or is_windows) and not (m.get("is_gguf") or m.get("gguf_sources")):
continue
# Format filter: AWQ tab -> only AWQ models, FP4 tab -> FP4-family models, etc.
if filter_native:
if quant == "FP8" and native_q != "FP8":
continue
if quant == "FP4" and native_q not in ("FP4", "NVFP4", "MXFP4", "NF4"):
continue
if quant.startswith("AWQ") and not native_q.startswith("AWQ"):
continue
if quant.startswith("GPTQ") and not native_q.startswith("GPTQ"):
continue
if quant.startswith("NVFP4") and not native_q.startswith("NVFP4"):
continue
if quant in ("INT4", "INT8", "W4A16", "W8A8", "W8A16") and native_q != quant:
continue
if search:
name = m.get("name", "").lower()
provider = m.get("provider", "").lower()
if search.lower() not in name and search.lower() not in provider:
continue
result = analyze_model(m, system, target_quant=quant, scoring_use_case=(use_case or "general"), target_context=target_context)
if result is None:
continue
if use_case:
model_uc = infer_use_case(m)
if use_case != model_uc and use_case != "general":
continue
results.append(result)
# Pick the visible SET by the REQUESTED column. Per-user feedback: sorting
# by Param should show the highest-param models PERIOD, not just those that
# already fit. Same for every other column. Models that don't fit are still
# in the list with their fit_level marking the constraint, so the user can
# see the truth instead of a quietly-truncated view. Score sort is unchanged
# (it's the default ranking and naturally pushes non-fits to the bottom).
if fit_only:
# Hide rows that definitely don't fit (the "too_tight" badge) — user
# explicitly asked for a Fit-only view.
results = [r for r in results if r.get("fit_level") != "too_tight"]
sort_fn = SORT_KEYS.get(sort, SORT_KEYS["score"])
# Always sort descending then truncate top-N so each column shows the
# global highest by that metric. Before, vram was special-cased
# ascending → truncate kept the 50 SMALLEST models and "highest VRAM"
# could never appear, breaking the column-click toggle.
results.sort(key=sort_fn, reverse=True)
results = results[:limit]
return results
+71
View File
@@ -0,0 +1,71 @@
"""
Extrahierte Mathematik aus dem Odysseus Projekt zur VRAM/RAM Berechnung.
Abgestimmt auf APUs mit Unified Memory (Bosgame M5 / Strix Halo).
"""
# Annahme: Bytes per Parameter für GGUF Quants
QUANT_BYTES_PER_PARAM = {
"Q2_K": 0.35,
"Q3_K_S": 0.38,
"Q3_K_M": 0.42,
"Q3_K_L": 0.45,
"Q4_0": 0.50,
"Q4_1": 0.55,
"Q4_K_S": 0.50,
"Q4_K_M": 0.55,
"Q5_0": 0.62,
"Q5_1": 0.68,
"Q5_K_S": 0.62,
"Q5_K_M": 0.65,
"Q6_K": 0.75,
"Q8_0": 1.00,
"F16": 2.00,
"BF16": 2.00,
}
def estimate_memory_gb(params_b: float, quant: str, ctx: int) -> float:
"""Berechnet den geschätzten Speicherbedarf in GB (Gewichte + Kontext)."""
# Wenn unbekanntes Format, nimm sicherheitshalber Q5_K_M (0.65)
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.65)
weights = params_b * bpp
# Heuristik für Context-RAM: 8k Context bei 7B Parametern frisst ca. 0.8 GB
context_vram = (ctx / 8192) * (max(params_b, 7) / 7) * 0.8
return weights + context_vram
def estimate_speed(req_gb: float, sys_ram_gb: float) -> float:
"""Berechnet die geschätzte Tokens/s basierend auf der 273 GB/s Bandbreite der APU."""
# Strix Halo hat ca 273 GB/s Unified Memory Bandbreite.
bw = 273 if sys_ram_gb > 8 else 70
if req_gb <= 0:
return 0.0
# (Bandbreite / Modellgröße) * Effizienz (0.55)
raw_tps = (bw / req_gb) * 0.55
return raw_tps
def evaluate_fit(params_b: float, quant: str, ctx: int, sys_ram_gb: float) -> dict:
"""Berechnet den Fit für ein System mit Shared Memory (APU)."""
req_gb = estimate_memory_gb(params_b, quant, ctx)
tps = estimate_speed(req_gb, sys_ram_gb)
# Das OS und andere Prozesse brauchen RAM. Wir lassen 4GB Puffer.
usable_ram = max(sys_ram_gb - 4.0, 0)
if req_gb > usable_ram:
fit_level = "too_tight"
text = "Zu groß (OOM)"
elif req_gb > usable_ram * 0.8:
fit_level = "marginal"
text = "Könnte knapp werden"
else:
fit_level = "perfect"
text = "Passt perfekt"
return {
"level": fit_level,
"text": text,
"req_gb": round(req_gb, 1),
"tps": round(tps, 0)
}
+100
View File
@@ -0,0 +1,100 @@
"""
Cookbook Router: Verbindet die HuggingFace API mit der Odysseus-Hardware-Berechnung.
"""
import httpx
import re
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
import psutil
from auth import auth
from hw_math import evaluate_fit
router = APIRouter(prefix="/api/cookbook", dependencies=[Depends(auth)])
class AnalyzeRequest(BaseModel):
repo_id: str
ctx: int = 8192
class EvaluateRequest(BaseModel):
params_b: float
quant: str
ctx: int
def extract_params_b(repo_id: str) -> float:
"""Extrahiert die Parametergröße (in Milliarden) aus dem Repo-Namen."""
# z.B. Qwen2.5-Coder-32B -> 32
# 8x7B -> 56 (MoE)
moe = re.search(r"(\d+)x(\d+(?:\.\d+)?)[bB]", repo_id)
if moe:
return float(moe.group(1)) * float(moe.group(2))
m = re.search(r"(\d+(?:\.\d+)?)[bB](?![a-zA-Z])", repo_id)
if m:
return float(m.group(1))
return 7.0 # Fallback
def extract_quant(filename: str) -> str:
m = re.search(r"(Q\d_[A-Z0-9_]+|IQ\d_[A-Z0-9_]+|FP16|BF16)", filename, re.IGNORECASE)
return m.group(1).upper() if m else "Q4_K_M"
@router.post("/analyze")
async def analyze_repo(req: AnalyzeRequest):
"""Holt die GGUF Dateien von HuggingFace und berechnet den Hardware-Fit."""
url = f"https://huggingface.co/api/models/{req.repo_id}/tree/main"
async with httpx.AsyncClient() as client:
try:
resp = await client.get(url, timeout=10.0)
resp.raise_for_status()
tree = resp.json()
except Exception as e:
raise HTTPException(status_code=500, detail=f"HuggingFace Fehler: {str(e)}")
gguf_files = [f["path"] for f in tree if f.get("path", "").endswith(".gguf")]
if not gguf_files:
return {"files": []}
params_b = extract_params_b(req.repo_id)
# Ermittle RAM des Systems (da APU = Shared Memory)
ram_gb = psutil.virtual_memory().total / (1024**3)
results = []
for f in gguf_files:
quant = extract_quant(f)
fit = evaluate_fit(params_b, quant, req.ctx, ram_gb)
# Priority-Score, um den besten Fit an oberste Stelle zu setzen.
# "Q4_K_M" ist oft der Sweetspot.
priority = 0
if fit["level"] == "perfect":
priority += 10
if quant == "Q4_K_M": priority += 5
elif quant.startswith("Q4"): priority += 4
elif quant.startswith("Q5"): priority += 3
results.append({
"filename": f,
"quant": quant,
"fit": fit,
"priority": priority
})
# Sortieren: Highest priority first, dann nach tps (schnellste zuerst)
results.sort(key=lambda x: (x["priority"], x["fit"]["tps"]), reverse=True)
return {
"repo": req.repo_id,
"params_b": params_b,
"sys_ram_gb": round(ram_gb, 1),
"files": results
}
@router.post("/evaluate")
def evaluate_single(req: EvaluateRequest):
ram_gb = psutil.virtual_memory().total / (1024**3)
fit = evaluate_fit(req.params_b, req.quant, req.ctx, ram_gb)
return fit
+55 -1
View File
@@ -7,8 +7,11 @@ Server-Wartung hinein (siehe Roadmap: Server-Management).
""" """
import shlex import shlex
import subprocess
import asyncio
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from auth import auth from auth import auth
from config import UPDATE_CMD from config import UPDATE_CMD
@@ -16,6 +19,8 @@ from jobengine import start_job
router = APIRouter(prefix="/api", dependencies=[Depends(auth)]) router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
class PwdReq(BaseModel):
password: str
@router.post("/update") @router.post("/update")
def update(): def update():
@@ -23,3 +28,52 @@ def update():
raise HTTPException(400, "Kein Update-Befehl gesetzt (MC_UPDATE_CMD).") raise HTTPException(400, "Kein Update-Befehl gesetzt (MC_UPDATE_CMD).")
job_id = start_job(shlex.split(UPDATE_CMD), "update containers") job_id = start_job(shlex.split(UPDATE_CMD), "update containers")
return {"job_id": job_id} return {"job_id": job_id}
@router.post("/os-update")
def os_update(req: PwdReq):
cmd = f"echo {shlex.quote(req.password)} | sudo -S bash -c 'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y'"
job_id = start_job(["bash", "-c", cmd], "os update")
return {"job_id": job_id}
@router.post("/service/{name}/restart")
def service_restart(name: str):
if name not in ("llama-swap", "mission-control"):
raise HTTPException(400, "Dienst nicht erlaubt.")
subprocess.Popen(["sudo", "systemctl", "restart", name])
return {"ok": True, "note": f"Restart {name} getriggert."}
@router.post("/reboot")
def reboot(req: PwdReq):
cmd = f"echo {shlex.quote(req.password)} | sudo -S reboot"
subprocess.Popen(["bash", "-c", cmd])
return {"ok": True, "note": "Reboot getriggert."}
@router.websocket("/logs/{service}")
async def stream_logs(websocket: WebSocket, service: str):
await websocket.accept()
if service not in ("llama-swap", "mission-control"):
await websocket.close(code=1008)
return
process = await asyncio.create_subprocess_exec(
"sudo", "journalctl", "-u", service, "-n", "100", "-f",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
try:
while True:
line = await process.stdout.readline()
if not line:
break
line_str = line.decode("utf-8", errors="replace")
# Filter spammy polls
if '"GET /running HTTP' in line_str or '"GET /api/' in line_str:
continue
await websocket.send_text(line_str)
except WebSocketDisconnect:
pass
finally:
try:
process.terminate()
except OSError:
pass
+29 -1
View File
@@ -43,6 +43,11 @@ class ChatReq(BaseModel):
message: str message: str
class UpdateReq(BaseModel):
alias: str
ctx: int
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Endpoints # Endpoints
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -61,11 +66,13 @@ def status():
size_bytes = None size_bytes = None
quant = "" quant = ""
filename = ""
m_path = re.search(r'-(?:m|-model)\s+([^\s]+)', cmd) m_path = re.search(r'-(?:m|-model)\s+([^\s]+)', cmd)
if m_path: if m_path:
path = m_path.group(1).replace("'", "").replace('"', '') path = m_path.group(1).replace("'", "").replace('"', '')
if os.path.exists(path): if os.path.exists(path):
size_bytes = os.path.getsize(path) size_bytes = os.path.getsize(path)
filename = os.path.basename(path)
q_match = re.search(r'(Q\d_[A-Z0-9_]+|IQ\d_[A-Z0-9_]+|fp16|bf16)\.gguf', path, flags=re.IGNORECASE) q_match = re.search(r'(Q\d_[A-Z0-9_]+|IQ\d_[A-Z0-9_]+|fp16|bf16)\.gguf', path, flags=re.IGNORECASE)
if q_match: if q_match:
quant = q_match.group(1).upper() quant = q_match.group(1).upper()
@@ -86,7 +93,8 @@ def status():
"ctx": ctx, "ctx": ctx,
"size_bytes": size_bytes, "size_bytes": size_bytes,
"quant": quant, "quant": quant,
"caps": caps "caps": caps,
"filename": filename
} }
} }
swap_ok = True swap_ok = True
@@ -141,6 +149,26 @@ def register(req: RegisterReq):
"note": "In config.yaml geschrieben. llama-swap mit -watch-config laedt automatisch neu."} "note": "In config.yaml geschrieben. llama-swap mit -watch-config laedt automatisch neu."}
@router.post("/update_model")
def update_model(req: UpdateReq):
cfg = read_config()
if req.alias not in cfg.get("models", {}):
raise HTTPException(404, "Modell nicht gefunden")
spec = cfg["models"][req.alias]
cmd = str(spec.get("cmd", ""))
# Replace or add context size
if re.search(r'-(?:c|-ctx-size)\s+\d+', cmd):
cmd = re.sub(r'-(?:c|-ctx-size)\s+\d+', f'-c {req.ctx}', cmd)
else:
cmd = cmd.strip() + f" -c {req.ctx}\n"
cfg["models"][req.alias]["cmd"] = LiteralScalarString(cmd)
write_config(cfg)
return {"ok": True}
@router.post("/unload") @router.post("/unload")
def unload(model: str | None = None): def unload(model: str | None = None):
path = f"/api/models/unload/{model}" if model else "/api/models/unload" path = f"/api/models/unload/{model}" if model else "/api/models/unload"
+14 -1
View File
@@ -4,9 +4,10 @@ Greift lokal auf psutil und sysfs zu.
""" """
from pathlib import Path from pathlib import Path
import asyncio
import psutil import psutil
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from auth import auth from auth import auth
router = APIRouter(prefix="/api/system", dependencies=[Depends(auth)]) router = APIRouter(prefix="/api/system", dependencies=[Depends(auth)])
@@ -89,3 +90,15 @@ def system_status():
}, },
"gpu_temp": temps["gpu"] "gpu_temp": temps["gpu"]
} }
@router.websocket("/stream")
async def system_stream(websocket: WebSocket):
await websocket.accept()
try:
while True:
# Sende Live-Daten alle 500ms
data = system_status()
await websocket.send_json(data)
await asyncio.sleep(0.5)
except WebSocketDisconnect:
pass
+75 -52
View File
@@ -1,116 +1,139 @@
/* ========================================================================= /* =========================================================================
base.css — Design-Tokens, Reset, App-Layout (Sidebar + Topbar + Content) 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{ :root{
/* Flächen */ /* Flächen */
--bg:#0a0d12; --bg2:#0d1117; --panel:#10151c; --panel2:#151b23; --inset:#0a0e13; --bg:#0b0e13; --bg2:#0d1117; --panel:#141a24; --tile:#11161e; --panel2:#11161e; --inset:#0a0e13;
--line:rgba(255,255,255,.07); --line2:rgba(255,255,255,.13); --line:rgba(255,255,255,.08); --line2:rgba(255,255,255,.14);
/* Text */ /* Text */
--tx:#d7dee7; --mut:#8b97a5; --dim:#5c6773; --tx:#e6edf3; --mut:#8b97a5; --dim:#5d6b79;
/* Akzente */ /* EINE Akzentfarbe für alles Klickbare/Aktive */
--on:#3fb950; --act:#4493e0; --purple:#a371f7; --warn:#e0a32e; --err:#e5534b; --accent:#2dd4bf; --accent-ink:#06231f; --teal:#2dd4bf;
--teal:#2dd4bf; /* Status-/Datenfarben (NUR für Bedeutung, nicht für Buttons) */
/* Tints (für getönte Karten) */ --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-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-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-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 */ /* Schrift */
--mono:ui-monospace,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace; --mono:ui-monospace,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
--sans:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; --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;
/* Maße */ /* Maße */
--side:62px; --radius:14px; --side:212px; --radius:12px; --radius-sm:8px;
} }
*{box-sizing:border-box} *{box-sizing:border-box}
html,body{height:100%} html,body{height:100%}
body{ body{
margin:0;background:var(--bg);color:var(--tx); margin:0;background:var(--bg);color:var(--tx);
font-family:var(--sans);font-size:14.5px;line-height:1.5; font-family:var(--sans);font-size:var(--text-base);line-height:1.5;
-webkit-font-smoothing:antialiased; -webkit-font-smoothing:antialiased;
} }
a{color:var(--act);text-decoration:none} a{color:var(--accent);text-decoration:none}
::selection{background:rgba(68,147,224,.32)} ::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} #app{display:flex;min-height:100vh}
.sidebar{ .sidebar{
width:var(--side);flex:0 0 var(--side); width:var(--side);flex:0 0 var(--side);
background:var(--bg2);border-right:1px solid var(--line); background:var(--bg2);border-right:1px solid var(--line);
display:flex;flex-direction:column;align-items:center; display:flex;flex-direction:column;
padding:14px 0;gap:6px;position:sticky;top:0;height:100vh; padding:var(--sp-4) var(--sp-3);gap:2px;position:sticky;top:0;height:100vh;
} }
.side-logo{ .brand{display:flex;align-items:center;gap:10px;padding:4px 8px 16px}
width:34px;height:34px;border-radius:9px;margin-bottom:10px; .brand-logo{
display:grid;place-items:center;color:var(--teal); width:30px;height:30px;border-radius:9px;flex:0 0 auto;
background:rgba(45,212,191,.10);border:1px solid rgba(45,212,191,.22); display:grid;place-items:center;color:var(--accent-ink);background:var(--accent);
} }
.side-nav{display:flex;flex-direction:column;gap:4px;flex:1} .brand-logo svg{width:18px;height:18px}
.side-foot{margin-top:auto} .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{ .nav-item{
width:40px;height:40px;border-radius:10px;display:grid;place-items:center; display:flex;align-items:center;gap:11px;
color:var(--mut);cursor:pointer;border:1px solid transparent;transition:.15s; 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:hover{color:var(--tx);background:var(--panel)}
.nav-item.active{ .nav-item.active{color:var(--tx);background:rgba(45,212,191,.12);border-color:rgba(45,212,191,.20);font-weight:500}
color:var(--teal);background:rgba(45,212,191,.12);border-color:rgba(45,212,191,.22); .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{opacity:.34;cursor:not-allowed}
.nav-item.disabled:hover{background:transparent;color:var(--mut)}
.nav-item svg{width:20px;height:20px}
/* ---- Main-Spalte ---- */ /* ---- Main-Spalte ---- */
.main{flex:1;min-width:0;display:flex;flex-direction:column} .main{flex:1;min-width:0;display:flex;flex-direction:column}
.topbar{ .topbar{
position:sticky;top:0;z-index:20; position:sticky;top:0;z-index:20;
display:flex;align-items:center;gap:14px;flex-wrap:wrap; display:flex;align-items:center;gap:var(--sp-3);flex-wrap:wrap;
padding:12px 26px;background:rgba(10,13,18,.86);backdrop-filter:blur(8px); padding:var(--sp-3) var(--sp-6);background:rgba(11,14,19,.86);backdrop-filter:blur(8px);
border-bottom:1px solid var(--line); border-bottom:1px solid var(--line);
} }
.spacer{flex:1} .spacer{flex:1}
.status-pill{ .status-pill{
display:inline-flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12.5px; display:inline-flex;align-items:center;gap:var(--sp-2);font-family:var(--mono);font-size:var(--text-xs);
padding:6px 12px;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{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.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)} .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)}} @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:12.5px;color:var(--mut)} .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-stat b{color:var(--tx);font-family:var(--mono);font-weight:500;margin-left:4px}
.top-clock{font-family:var(--mono);font-size:12.5px;color:var(--act)} .top-clock{font-family:var(--mono);font-size:var(--text-sm);color:var(--accent)}
.tokin{
font-family:var(--mono);font-size:12.5px;background:var(--panel);border:1px solid var(--line); /* Security-Chip (zeigt LAN/Token-Status) */
color:var(--tx);border-radius:8px;padding:7px 10px;width:128px; .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-Bereich ---- */
.content{padding:22px 26px 64px;max-width:1500px;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[hidden]{display:none}
.view{display:flex;flex-direction:column;gap:18px} .view{display:flex;flex-direction:column;gap:var(--sp-4)}
/* Raster-Helfer */ /* Raster-Helfer */
.grid{display:grid;gap:18px} .grid{display:grid;gap:var(--sp-4)}
.grid-3{grid-template-columns:1fr 1fr 1fr} .grid-3{grid-template-columns:repeat(auto-fit, minmax(300px, 1fr))}
.grid-2{grid-template-columns:1fr 1fr} .grid-2{grid-template-columns:repeat(auto-fit, minmax(400px, 1fr))}
.kpis{grid-template-columns:repeat(5,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: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-Banner */
.alert{ .alert{
margin:14px 26px 0;padding:14px 18px;border-radius:12px; margin:var(--sp-4) var(--sp-6) 0;padding:var(--sp-3) var(--sp-4);border-radius:var(--radius);
background:linear-gradient(90deg,rgba(229,83,75,.16),rgba(229,83,75,.04)); background:rgba(240,87,63,.10);border:1px solid rgba(240,87,63,.26);color:#ffcdc8;
border:1px solid rgba(229,83,75,.32);color:#ffcdc8; display:flex;align-items:center;gap:var(--sp-3);font-size:var(--text-sm);
display:flex;align-items:center;gap:12px;font-size:13.5px;
} }
.alert .a-dot{width:8px;height:8px;border-radius:50%;background:var(--err);flex:0 0 auto} .alert .a-dot{width:8px;height:8px;border-radius:50%;background:var(--err);flex:0 0 auto}
.alert b{color:#ffe2de} .alert b{color:#ffe2de}
.alert.warn{background:linear-gradient(90deg,rgba(224,163,46,.15),rgba(224,163,46,.03)); .alert.warn{background:rgba(224,163,46,.10);border-color:rgba(224,163,46,.26);color:#f3dca6}
border-color:rgba(224,163,46,.32);color:#f3dca6}
.alert.warn .a-dot{background:var(--warn)} .alert.warn .a-dot{background:var(--warn)}
+138 -70
View File
@@ -1,75 +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) ---- */ /* ---- Karte (Grundbaustein) ---- */
.card{ .card{
background:var(--panel);border:1px solid var(--line);border-radius:var(--radius); background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);
padding:18px 20px; padding:var(--sp-4) var(--sp-5);
} }
.card-h{display:flex;align-items:center;gap:10px;margin:0 0 14px} .card-h{display:flex;align-items:center;gap:10px;margin:0 0 4px}
.card-h h3{font-size:15px;font-weight:600;margin:0;flex:1;color:var(--tx)} .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{font-family:var(--mono);font-size:12px;color:var(--mut)}
.card-h .meta.ok{color:var(--on)} .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) ---- */ /* ---- Seiten-Kopf (Hero, Klartext-Urteil) ---- */
.hero{ .pagehead{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:2px}
background: .pagehead h1{font-size:var(--text-xl);font-weight:500;margin:0;letter-spacing:.2px}
radial-gradient(120% 140% at 100% 0%,rgba(68,147,224,.10),transparent 60%), .pagehead .sub{font-size:var(--text-sm);color:var(--mut);margin-top:4px}
var(--panel);
border:1px solid var(--line);border-radius:var(--radius);
padding:26px 28px;display:flex;justify-content:space-between;gap:24px;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}
/* ---- KPI-Kacheln ---- */ /* Legacy-Hero (Overview alt) — bleibt für nicht migrierte Nutzung */
.kpi{ .hero{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);
position:relative;border-radius:var(--radius);padding:18px 20px; padding:var(--sp-4) var(--sp-5);display:flex;justify-content:space-between;gap:var(--sp-6);flex-wrap:wrap}
border:1px solid var(--line);background:var(--panel);overflow:hidden; .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}
.kpi .k-h{display:flex;align-items:flex-start;justify-content:space-between;gap:8px} .hero p{margin:0;color:var(--mut);font-size:14px;max-width:64ch}
.kpi .k-t{font-size:13.5px;color:var(--mut)}
/* ---- 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{color:var(--mut);opacity:.85}
.kpi .k-ic svg{width:20px;height:20px} .kpi .k-ic svg{width:18px;height:18px}
.kpi .k-v{font-family:var(--mono);font-size:32px;font-weight:600;line-height:1.1;margin:14px 0 4px;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:15px;color:var(--mut);font-weight:400} .kpi .k-v small{font-size:var(--text-base);color:var(--mut);font-weight:400}
.kpi .k-s{font-size:12px;color:var(--mut)} .kpi .k-s{font-size:var(--text-xs);color:var(--mut)}
/* Farb-Varianten */ .kpi.green{border-top:2px solid var(--on)} .kpi.green .k-v,.kpi.green .k-ic{color:var(--on)}
.kpi.green {background:linear-gradient(160deg,var(--t-green),transparent 70%);border-color:var(--b-green)} .kpi.blue{border-top:2px solid var(--act)} .kpi.blue .k-v,.kpi.blue .k-ic{color:var(--act)}
.kpi.green .k-v,.kpi.green .k-ic{color:var(--on)} .kpi.purple{border-top:2px solid var(--purple)} .kpi.purple .k-v,.kpi.purple .k-ic{color:var(--purple)}
.kpi.blue {background:linear-gradient(160deg,var(--t-blue),transparent 70%);border-color:var(--b-blue)} .kpi.red{border-top:2px solid var(--err)} .kpi.red .k-v,.kpi.red .k-ic{color:var(--err)}
.kpi.blue .k-v,.kpi.blue .k-ic{color:var(--act)}
.kpi.purple{background:linear-gradient(160deg,var(--t-purple),transparent 70%);border-color:var(--b-purple)}
.kpi.purple .k-v,.kpi.purple .k-ic{color:var(--purple)}
.kpi.red {background:linear-gradient(160deg,var(--t-red),transparent 70%);border-color:var(--b-red)}
.kpi.red .k-v,.kpi.red .k-ic{color:var(--err)}
.kpi.muted .k-v{color:var(--dim)} .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{display:flex;flex-direction:column}
.kv-row{display:flex;align-items:center;justify-content:space-between;gap:12px; .kv-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 2px;border-top:1px solid var(--line)}
padding:11px 2px;border-top:1px solid var(--line)}
.kv-row:first-child{border-top:0} .kv-row:first-child{border-top:0}
.kv-row .kv-k{color:var(--mut);font-size:13.5px} .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-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)} .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{height:6px;border-radius:4px;background:var(--inset);margin-top:8px;overflow:hidden}
.bar > i{display:block;height:100%;background:var(--on);border-radius:3px;transition:width .4s} .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.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} .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: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{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)} .li .li-dot.on{background:var(--on)} .li .li-dot.load{background:var(--warn)}
@@ -80,10 +86,23 @@
.li .li-meta{font-family:var(--mono);font-size:12px;color:var(--mut)} .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} .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 ---- */ /* ---- Tabelle ---- */
table{width:100%;border-collapse:collapse;font-size:14px} 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; th{text-align:left;font-weight:400;color:var(--mut);font-size:11.5px;text-transform:uppercase;letter-spacing:.06em;padding:0 10px 10px}
letter-spacing:.06em;padding:0 10px 10px}
td{padding:12px 10px;border-top:1px solid var(--line)} td{padding:12px 10px;border-top:1px solid var(--line)}
.mid{font-family:var(--mono);font-size:13px;color:#e8eef5} .mid{font-family:var(--mono);font-size:13px;color:#e8eef5}
.port{font-family:var(--mono);color:var(--mut);font-size:13px} .port{font-family:var(--mono);color:var(--mut);font-size:13px}
@@ -93,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-run{background:rgba(63,185,80,.14);color:var(--on)}
.b-idle{background:rgba(139,151,165,.14);color:var(--mut)} .b-idle{background:rgba(139,151,165,.14);color:var(--mut)}
.b-load{background:rgba(224,163,46,.16);color:var(--warn)} .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)} .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-States ---- */
.empty{color:var(--mut);font-size:13.5px;padding:14px 4px} .empty{color:var(--mut);font-size:13.5px;padding:14px 4px}
.empty-c{display:flex;flex-direction:column;align-items:center;justify-content:center; .empty-c{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:44px 16px;color:var(--mut)}
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 .e-t{font-size:14px}
.empty-c .e-s{font-size:12.5px;color:var(--dim);margin-top:6px}
/* ---- Forms ---- */ /* ---- Forms ---- */
label{display:block;font-size:12px;color:var(--mut);margin:0 0 5px} 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); 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} 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(--act)} input:focus,textarea:focus,select:focus{outline:none;border-color:var(--accent)}
textarea{resize:vertical;min-height:64px;font-family:var(--sans)} textarea{resize:vertical;min-height:64px;font-family:var(--sans)}
.row{display:flex;gap:10px}.row>div{flex:1} .row{display:flex;gap:10px}.row>div{flex:1}
.hint{font-size:12px;color:var(--mut);margin:-4px 0 12px} .hint{font-size:12px;color:var(--mut);margin:-4px 0 12px}
@@ -115,28 +146,65 @@ textarea{resize:vertical;min-height:64px;font-family:var(--sans)}
/* ---- Buttons ---- */ /* ---- Buttons ---- */
button{font-family:var(--sans);font-size:13.5px;font-weight:500;border:1px solid var(--line2); 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} background:var(--panel2);color:var(--tx);border-radius:var(--radius-sm);padding:9px 15px;cursor:pointer;transition:.15s}
button:hover{border-color:var(--act)} button:hover{border-color:var(--accent)}
button.primary{background:var(--act);border-color:var(--act);color:#fff} button.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-ink)}
button.primary:hover{filter:brightness(1.08)} button.primary:hover{filter:brightness(1.06)}
button.danger{background:var(--err);border-color:var(--err);color:#fff} button.danger{background:var(--err);border-color:var(--err);color:#fff}
button.danger:hover{filter:brightness(1.08)} button.danger:hover{filter:brightness(1.06)}
button.ghost{padding:6px 11px;font-size:12.5px} 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} button:disabled{opacity:.5;cursor:not-allowed}
.btn-row{display:flex;gap:10px;flex-wrap:wrap} .btn-row{display:flex;gap:10px;flex-wrap:wrap}
/* ---- Reply / Log ---- */ /* ---- Reply / Log / Konsole ---- */
.reply{margin-top:12px;background:var(--panel2);border:1px solid var(--line);border-radius:8px; .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)} 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); .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{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{display:flex;align-items:center;gap:10px;padding:11px 13px;cursor:pointer}
.job-h .mid{flex:1} .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 ---- */
.toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:var(--panel2); .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; 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.show{opacity:1}
.toast.err{border-color:var(--err);color:#ffb4ae} .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: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 ---- */
.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 (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)}
+52 -25
View File
@@ -10,19 +10,19 @@
<body> <body>
<div id="app"> <div id="app">
<!-- Sidebar: Bereichs-Navigation. Platzhalter-Items sind die kommenden Roadmap-Bereiche. --> <!-- Sidebar: beschriftete Bereichs-Navigation -->
<aside class="sidebar"> <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"> <nav class="side-nav">
<a class="nav-item" data-view="overview" title="Übersicht" data-ic="grid"></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" title="Modelle" data-ic="cpu"></a> <a class="nav-item" data-view="models"><span class="ni-ic" data-ic="cpu"></span><span class="ni-tx">Modelle</span></a>
<span class="nav-item disabled" title="Aktivität — siehe Übersicht" data-ic="pulse"></span> <a class="nav-item" data-view="activity"><span class="ni-ic" data-ic="pulse"></span><span class="ni-tx">Aktivität</span></a>
<span class="nav-item disabled" title="Server (bald)" data-ic="server"></span> <a class="nav-item" data-view="server"><span class="ni-ic" data-ic="server"></span><span class="ni-tx">Server</span></a>
<span class="nav-item disabled" title="Cookbook (bald)" data-ic="book"></span> <a class="nav-item" data-view="cookbook"><span class="ni-ic" data-ic="book"></span><span class="ni-tx">Cookbook</span></a>
<span class="nav-item disabled" title="Guides (bald)" data-ic="help"></span> <a class="nav-item" data-view="guides"><span class="ni-ic" data-ic="help"></span><span class="ni-tx">Guides</span></a>
</nav> </nav>
<div class="side-foot"> <div class="side-foot">
<span class="nav-item disabled" title="Einstellungen (bald)" data-ic="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> </div>
</aside> </aside>
@@ -31,10 +31,8 @@
<header class="topbar"> <header class="topbar">
<span class="status-pill"><span id="swdot" class="dot"></span><span id="swlabel">verbinde…</span></span> <span class="status-pill"><span id="swdot" class="dot"></span><span id="swlabel">verbinde…</span></span>
<span class="spacer"></span> <span class="spacer"></span>
<span class="top-stat">Modelle<b id="top-models"></b></span> <span class="top-stat" id="top-active-text">Kein Modell geladen</span>
<span class="top-stat">Jobs<b id="top-jobs">0</b></span> <span class="sec-chip" id="sec-chip"><span data-ic="shield"></span><span id="sec-chip-tx">Nur im Heimnetz</span></span>
<span class="top-clock" id="clock">--:--</span>
<input id="token" class="tokin" placeholder="Token" autocomplete="off">
</header> </header>
<!-- Alert-Banner (wird per JS ein-/ausgeblendet) --> <!-- Alert-Banner (wird per JS ein-/ausgeblendet) -->
@@ -44,35 +42,64 @@
<main class="content"> <main class="content">
<section class="view" data-view="overview"> <section class="view" data-view="overview">
<div id="hero"></div> <div id="ov-hero"></div>
<div class="grid kpis" id="kpis"></div> <div class="tiles" id="ov-tiles"></div>
<div class="grid grid-3"> <div class="split">
<div class="card" id="health"></div> <div class="card" id="ov-health"></div>
<div class="card" id="ov-models"></div> <div class="card" id="ov-quickstart"></div>
<div class="card" id="ov-activity"></div>
</div> </div>
<div class="card" id="ov-stack"></div>
</section>
<section class="view" data-view="activity" hidden>
<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>
<section class="view" data-view="server" hidden>
<div class="card" id="wartung"></div> <div class="card" id="wartung"></div>
</section> </section>
<section class="view" data-view="models" hidden> <section class="view" data-view="models" hidden>
<div class="grid grid-2"> <div id="m-head"></div>
<div class="card" id="m-download"></div> <div class="card" id="m-chat"></div>
<div class="card" id="m-chat"></div>
</div>
<div class="card" id="m-table"></div> <div class="card" id="m-table"></div>
</section> </section>
<section class="view" data-view="cookbook" hidden>
<!-- Wird von cookbook.js gerendert -->
</section>
<section class="view" data-view="guides" hidden>
<!-- Wird von guides.js gerendert -->
</section>
</main> </main>
</div> </div>
</div> </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.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>
<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> <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"> <script type="module">
import { ICON } from "/static/js/core/ui.js"; import { ICON } from "/static/js/core/ui.js";
document.getElementById("logo").innerHTML = ICON.logo; 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>
<script type="module" src="/static/js/main.js"></script> <script type="module" src="/static/js/main.js"></script>
</body> </body>
+83 -1
View File
@@ -22,7 +22,7 @@ export function toast(msg, err = false) {
export function badge(state) { export function badge(state) {
if (state === "running" || state === "ready") return '<span class="badge b-run">geladen</span>'; if (state === "running" || state === "ready") return '<span class="badge b-run">geladen</span>';
if (state === "loading" || state === "starting") return '<span class="badge b-load">lädt…</span>'; if (state === "loading" || state === "starting") return '<span class="badge b-load">lädt…</span>';
return '<span class="badge b-idle">bereit</span>'; return '<span class="badge b-run">bereit</span>';
} }
// relative Zeit aus Unix-Sekunden (z.B. "2m", "13h") // relative Zeit aus Unix-Sekunden (z.B. "2m", "13h")
@@ -54,6 +54,88 @@ export const ICON = {
layers: _svg('<path d="m12 2 9 5-9 5-9-5z"/><path d="m3 12 9 5 9-5M3 17l9 5 9-5"/>'), layers: _svg('<path d="m12 2 9 5-9 5-9-5z"/><path d="m3 12 9 5 9-5M3 17l9 5 9-5"/>'),
alert: _svg('<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/>'), alert: _svg('<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/>'),
gauge: _svg('<path d="M12 14 16 9"/><circle cx="12" cy="13" r="9"/><path d="M12 4v2M21 13h-2M5 13H3"/>'), gauge: _svg('<path d="M12 14 16 9"/><circle cx="12" cy="13" r="9"/><path d="M12 4v2M21 13h-2M5 13H3"/>'),
search: _svg('<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>'),
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] || ""; } 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();
});
}
+50 -16
View File
@@ -7,10 +7,12 @@ import { initNav } from "./core/nav.js";
import overview from "./panels/overview.js"; import overview from "./panels/overview.js";
import models from "./panels/models.js"; import models from "./panels/models.js";
import maintenance from "./panels/maintenance.js"; import server from "./panels/server.js";
import jobs from "./panels/jobs.js"; import jobs from "./panels/jobs.js";
import cookbook from "./panels/cookbook.js";
import guides from "./panels/guides.js";
const panels = [overview, models, maintenance, jobs]; const panels = [overview, models, server, jobs, cookbook, guides];
let lastJobs = []; let lastJobs = [];
let lastSystem = null; let lastSystem = null;
@@ -22,22 +24,28 @@ function applyStatus(s) {
if (!s) { if (!s) {
dot.className = "dot off"; dot.className = "dot off";
label.textContent = "Backend nicht erreichbar"; label.textContent = "Backend nicht erreichbar";
$("#top-models").textContent = ""; $("#top-active-text").textContent = "Backend offline";
showAlert("Backend nicht erreichbar läuft uvicorn?", false); showAlert("Backend nicht erreichbar läuft uvicorn?", false);
} else { } else {
const host = s.swap_url.replace(/^https?:\/\//, ""); const host = s.swap_url.replace(/^https?:\/\//, "");
dot.className = "dot " + (s.swap_ok ? "on" : "off"); dot.className = "dot " + (s.swap_ok ? "on" : "off");
label.textContent = (s.swap_ok ? "llama-swap online · " : "llama-swap offline · ") + host; label.textContent = (s.swap_ok ? "LLM-Engine: Online " : "LLM-Engine: Offline ") + host;
$("#top-models").textContent = (s.models || []).length;
const active = (s.models || []).find(m => m.state === "running");
if (active) {
$("#top-active-text").innerHTML = `Geladen: <b style="color:var(--teal)">${active.name}</b>`;
} else {
$("#top-active-text").innerHTML = "Kein Modell im VRAM";
}
if (s.swap_ok) hideAlert(); if (s.swap_ok) hideAlert();
else showAlert(`llama-swap nicht erreichbar unter <b>${host}</b> läuft der Dienst?`, true); else showAlert(`LLM-Engine nicht erreichbar unter <b>${host}</b> läuft der llama-swap Dienst?`, true);
} }
for (const p of panels) p.onStatus?.(s); for (const p of panels) p.onStatus?.(s);
} }
function applyJobs(jobs) { function applyJobs(jobs) {
lastJobs = jobs || []; lastJobs = jobs || [];
$("#top-jobs").textContent = lastJobs.filter(j => j.state === "running" || j.state === "queued").length;
for (const p of panels) p.onJobs?.(lastJobs); for (const p of panels) p.onJobs?.(lastJobs);
} }
@@ -64,19 +72,45 @@ async function pollJobs() {
catch { /* still */ } catch { /* still */ }
} }
async function pollSystem() { function connectSystemStream() {
try { applySystem(await api("/api/system/status")); } const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
catch { /* still */ } const wsUrl = `${protocol}//${window.location.host}/api/system/stream`;
const ws = new WebSocket(wsUrl);
ws.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
applySystem(data);
} catch (err) {}
};
ws.onclose = () => {
// Bei Verbindungsabbruch nach 3 Sekunden erneut versuchen
setTimeout(connectSystemStream, 3000);
};
ws.onerror = () => ws.close();
} }
// ---- Boot ---- // ---- Boot ----
function bootToken() { function bootToken() {
const i = $("#token"); const i = $("#token");
i.value = getToken(); if (i) {
i.addEventListener("change", e => { setToken(e.target.value); pollStatus(); }); i.value = getToken();
i.addEventListener("change", e => { setToken(e.target.value); pollStatus(); });
}
const sbtn = $("#nav-settings");
const smod = $("#settings-modal");
const scls = $("#sm-close");
if (sbtn && smod && scls) {
sbtn.addEventListener("click", () => smod.style.display = "flex");
scls.addEventListener("click", () => smod.style.display = "none");
}
} }
function tickClock() { function tickClock() {
$("#clock").textContent = new Date().toTimeString().slice(0, 5); const c = $("#clock");
if (c) c.textContent = new Date().toTimeString().slice(0, 5);
} }
for (const p of panels) p.mount?.(); for (const p of panels) p.mount?.();
@@ -87,8 +121,8 @@ document.addEventListener("mc:refresh", pollStatus);
pollStatus(); pollStatus();
pollJobs(); pollJobs();
pollSystem(); connectSystemStream();
setInterval(tickClock, 1000); setInterval(tickClock, 1000);
setInterval(pollStatus, 3000); setInterval(pollStatus, 3000);
setInterval(pollJobs, 1500); setInterval(pollJobs, 1500);
setInterval(pollSystem, 3000);
+270
View File
@@ -0,0 +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";
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" },
];
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 fitCls = lvl => lvl === "perfect" ? "ok" : lvl === "marginal" ? "warn" : "bad";
function mount() {
const c = $(".view[data-view='cookbook']");
c.innerHTML = `
<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="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>
<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 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>
<button class="primary" id="cb-m-download" style="width:100%">Herunterladen &amp; Einpflegen</button>
</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();
}
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 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`;
}
}
// ---- 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 {
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>`;
}
}
async function doSearch() {
let q = $("#cb-search").value.trim();
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: " + 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);
currentResults = await r.json();
renderResults(currentResults);
} catch (e) {
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?.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" 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: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 fetchFitForCard(i, repo_id) {
try {
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"; }
}
}
// ---- 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;
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, 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);
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 }) });
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";
document.querySelector(".nav-item[data-view='activity']")?.click();
} catch (e) { toast("Fehler: " + e.message, true); }
btn.disabled = false; btn.textContent = "Herunterladen & Einpflegen";
}
function onSystem(sys) { lastSys = sys; renderHwChip(); }
export default { id: "cookbook", mount, onSystem };
+89
View File
@@ -0,0 +1,89 @@
// guides.js — Integrations-Anleitungen (v3): Copy-Paste-Configs für externe Tools.
import { $ } from "../core/ui.js";
// 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 = $(".view[data-view='guides']");
if (!c) return;
const url = apiBase();
c.innerHTML = `
<div class="pagehead"><div>
<h1>Guides &amp; 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</summary>
<div class="acc-body">
<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</summary>
<div class="acc-body">
<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>
<div class="acc-body">
<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="${url}",
api_key="dein_token" # optional
)
resp = client.chat.completions.create(
model="coder", # Alias aus dem Cookbook
messages=[{"role": "user", "content": "Hallo Modell!"}]
)
print(resp.choices[0].message.content)</code></div>
</div>
</details>
<details class="guide-acc">
<summary>n8n (AI-Agent-Nodes)</summary>
<div class="acc-body">
<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>Begriffe einfach erklärt (Glossar)</summary>
<div class="acc-body">
<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>`;
}
function mount() { render(); }
export default { id: "guides", mount };
+77 -42
View File
@@ -1,67 +1,102 @@
// jobs.js — Aktivitaets-Stream ("Incident Stream"): Hintergrund-Jobs mit Live-Log. // jobs.js — Aktivität (v3): Live-System-Metriken + Hintergrund-Jobs mit Log.
// Exportiert track(id), damit andere Panels einen frisch gestarteten Job auto-aufklappen. // Exportiert track(id), damit andere Panels einen Job auto-aufklappen.
import { $, esc } from "../core/ui.js"; import { $, esc, fmtBytes } from "../core/ui.js";
const tracked = new Set(); const tracked = new Set();
let JOBS = []; let JOBS = [];
let SYS = null;
export function track(id) { export function track(id) { tracked.add(id); renderJobs(); }
tracked.add(id);
render(); 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 statusBadge(state) { function gpuPct() {
if (state === "done") return '<span class="badge b-run">fertig</span>'; const g = SYS?.gpu;
if (state === "failed") return '<span class="badge b-err">fehler</span>'; if (g && (g.vram.total + g.gtt.total) > 0) return ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100;
return '<span class="badge b-load">läuft…</span>'; return 0;
} }
function dotClass(state) {
if (state === "done") return "on"; function renderSys() {
if (state === "failed") return ""; if (!SYS) return;
return "load"; 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();
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");
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="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" 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() { function mount() {
$("#ov-activity").innerHTML = ` $("#act-head").innerHTML = `<div class="pagehead"><div>
<div class="card-h"><h3>Aktivität</h3><span class="meta" id="job-count"></span></div> <h1>Aktivität</h1>
<div id="jobs"></div> <div class="sub">Live-Auslastung und laufende Aufgaben (Downloads, Updates) mit Protokoll.</div></div></div>`;
<div id="jobs-empty" class="empty-c">
<div class="e-t">Noch nichts losgemacht.</div>
<div class="e-s">Downloads, Updates &amp; Co. erscheinen hier mit Live-Log.</div>
</div>`;
// Klicks auf Job-Kopf -> auf/zuklappen (Event-Delegation) $("#v-activity").innerHTML = `
$("#jobs").addEventListener("click", e => { <div class="card-h"><h3>Hintergrund-Aufgaben</h3><span class="meta" id="job-count"></span></div>
const h = e.target.closest(".job-h"); <div class="card-sub">Downloads &amp; Updates erscheinen hier mit Live-Protokoll — zum Aufklappen klicken.</div>
if (!h) return; <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>`;
$("#v-activity").addEventListener("click", e => {
const h = e.target.closest(".job-h"); if (!h) return;
const id = h.getAttribute("data-id"); const id = h.getAttribute("data-id");
tracked.has(id) ? tracked.delete(id) : tracked.add(id); tracked.has(id) ? tracked.delete(id) : tracked.add(id);
render(); renderJobs();
}); });
if (SYS) renderSys();
} }
function render() { function renderJobs() {
const c = $("#jobs"); const c = $("#jobs"); if (!c) return;
if (!c) return;
$("#jobs-empty").style.display = JOBS.length ? "none" : "flex"; $("#jobs-empty").style.display = JOBS.length ? "none" : "flex";
const failed = JOBS.filter(j => j.state === "failed").length; const failed = JOBS.filter(j => j.state === "failed").length;
$("#job-count").textContent = JOBS.length ? (failed ? failed + " Fehler" : JOBS.length + " gesamt") : ""; $("#job-count").textContent = JOBS.length ? (failed ? failed + " Fehler" : JOBS.length + " gesamt") : "";
c.innerHTML = JOBS.map(j => { c.innerHTML = JOBS.map(j => {
const open = tracked.has(j.id); const log = tracked.has(j.id) ? `<div class="log">${esc((j.log || []).join("\n"))}</div>` : "";
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)}">
return `<div class="job"> <span class="li-dot ${dotClass(j.state)}"></span><span class="mid">${esc(j.label)}</span>${statusBadge(j.state)}</div>${log}</div>`;
<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(""); }).join("");
} }
function onJobs(jobs) { function onJobs(jobs) { JOBS = jobs || []; renderJobs(); }
JOBS = jobs || []; function onSystem(sys) { SYS = sys; if ($("#act-sys")) renderSys(); }
render();
}
export default { id: "jobs", mount, onJobs }; export default { id: "jobs", mount, onJobs, onSystem };
-40
View File
@@ -1,40 +0,0 @@
// maintenance.js — Wartungs-Karte: Container aktualisieren + alle Modelle entladen.
// Spaeter waechst hier das Server-Management an (Roadmap Feature 1).
import { api } from "../core/api.js";
import { $, toast } from "../core/ui.js";
import { track } from "./jobs.js";
function mount() {
$("#wartung").innerHTML = `
<div class="card-h"><h3>Wartung</h3></div>
<div class="btn-row">
<button id="w-update">Container aktualisieren</button>
<button id="w-unload" class="danger">Alles aus dem Speicher</button>
</div>
<div class="hint" style="margin-top:12px">
Update-Befehl wird per <span class="mono-sm">MC_UPDATE_CMD</span> gesetzt.
Server-Steuerung (Dienste, OS-Updates, Reboot) folgt als eigener Bereich.
</div>`;
$("#w-update").addEventListener("click", update);
$("#w-unload").addEventListener("click", unloadAll);
}
async function update() {
try {
const r = await api("/api/update", { method: "POST" });
toast("Update läuft.");
track(r.job_id);
} catch (e) { toast(e.message, true); }
}
async function unloadAll() {
try {
await api("/api/unload", { method: "POST" });
toast("Alle Modelle entladen.");
setTimeout(() => document.dispatchEvent(new Event("mc:refresh")), 600);
} catch (e) { toast(e.message, true); }
}
export default { id: "maintenance", mount };
+83 -103
View File
@@ -1,132 +1,116 @@
// models.js — "Modelle"-Ansicht: Download + Einpflegen, Schnelltest-Chat, Modell-Tabelle. // models.js — "Modelle"-Ansicht (v3): Schnelltest-Chat, Modell-Tabelle, Kontext-Konfiguration.
import { api } from "../core/api.js"; import { api } from "../core/api.js";
import { $, badge, esc, toast } from "../core/ui.js"; import { $, badge, esc, toast, confirmModal } from "../core/ui.js";
import { track } from "./jobs.js";
let ALL = [];
function refreshSoon() { document.dispatchEvent(new Event("mc:refresh")); } 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() { function mount() {
$("#m-download").innerHTML = ` $("#m-head").innerHTML = `<div class="pagehead"><div>
<div class="card-h"><h3>Modell holen</h3></div> <h1>Modelle</h1>
<label>HuggingFace-Repo</label> <div class="sub">Deine konfigurierten Modelle — testen, Kontext anpassen oder aus dem Speicher werfen.</div></div></div>`;
<input id="dl-repo" placeholder="unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF">
<label>Datei (GGUF)</label>
<input id="dl-file" placeholder="Q4_K_M/Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf">
<button class="primary" id="dl-btn">Modell herunterladen</button>
<div id="register-box" style="display:none;margin-top:16px;border-top:1px solid var(--line);padding-top:14px">
<div class="card-h"><h3>Einpflegen</h3></div>
<div class="row">
<div><label>Alias</label><input id="rg-alias"></div>
<div><label>Kontext</label><input id="rg-ctx" value="8192"></div>
</div>
<input id="rg-path" class="mono-sm" readonly>
<button class="primary" id="rg-btn">In Config eintragen</button>
</div>`;
$("#m-chat").innerHTML = ` $("#m-chat").innerHTML = `
<div class="card-h"><h3>Schnelltest</h3></div> <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> <label>Modell</label>
<select id="chat-model"></select> <select id="chat-model"></select>
<label>Nachricht</label> <label>Nachricht</label>
<textarea id="chat-msg" placeholder="Schreib was, um ein Modell zu wecken…"></textarea> <textarea id="chat-msg" placeholder="z.B. „Erklär mir kurz, was du kannst."></textarea>
<button class="primary" id="chat-btn">Senden</button> <button class="primary" id="chat-btn">Senden</button>
<div id="chat-reply" class="reply" style="display:none"></div>`; <div id="chat-reply" class="reply" style="display:none"></div>`;
$("#m-table").innerHTML = ` $("#m-table").innerHTML = `
<div class="card-h"><h3>Modelle &amp; Ports</h3><span class="meta" id="m-count"></span></div> <div class="card-h"><h3>Modelle &amp; Ports</h3><span class="meta" id="m-count"></span></div>
<div class="card-sub">Modelle laden automatisch, sobald eine Anfrage kommt — du musst nichts manuell starten.</div>
<table> <table>
<thead><tr><th>Modell</th><th>Fähigkeiten</th><th>Details</th><th>Status</th><th>Port</th><th style="text-align:right">Aktion</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> <tbody id="models"></tbody>
</table> </table>
<div id="models-empty" class="empty" style="display:none">Noch keine Modelle konfiguriert — zieh dir oben eins rein. 👇</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>`;
$("#dl-btn").addEventListener("click", pull);
$("#rg-btn").addEventListener("click", register);
$("#chat-btn").addEventListener("click", sendChat); $("#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) { function onStatus(s) {
const models = s?.models || []; ALL = s?.models || [];
const tb = $("#models"); const tb = $("#models"); if (!tb) return;
if (!tb) return; $("#models-empty").style.display = ALL.length ? "none" : "flex";
tb.innerHTML = ""; $("#m-count").textContent = ALL.length ? ALL.length + " konfiguriert" : "";
$("#models-empty").style.display = models.length ? "none" : "block";
$("#m-count").textContent = models.length ? models.length + " konfiguriert" : "";
const sel = $("#chat-model"); const sel = $("#chat-model"), cur = sel.value; sel.innerHTML = "";
const cur = sel.value; tb.innerHTML = ALL.map(m => {
sel.innerHTML = ""; const fn = m.meta?.filename ? `<div class="li-sub mono-sm">${esc(m.meta.filename)}</div>` : "";
for (const m of models) { return `<tr>
const tr = document.createElement("tr"); <td class="mid" style="font-weight:500">${esc(m.name)}${fn}</td>
<td>${capTags(m.meta?.caps)}</td>
let capsHtml = ""; <td>${details(m.meta)}</td>
if (m.meta && m.meta.caps) { <td>${badge(m.state)}</td>
capsHtml = m.meta.caps.map(c => {
if (c === "Text") return `<span class="meta" title="Text">T</span>`;
if (c === "Code") return `<strong style="color:var(--blue)" title="Code">{ }</strong>`;
if (c === "Bild") return `<strong style="color:var(--purple)" 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>` : "";
tr.innerHTML = `<td class="mid" style="font-weight:500">${esc(m.name)}</td>
<td>${capsHtml}</td>
<td>${detailsHtml}</td>
<td>${badge(m.state)}${perfHtml}</td>
<td class="port">${m.port ?? "auto"}</td> <td class="port">${m.port ?? "auto"}</td>
<td style="text-align:right"><button class="ghost" data-unload="${esc(m.name)}">Entladen</button></td>`; <td style="text-align:right;white-space:nowrap">
tb.appendChild(tr); <button class="ghost" data-cfg="${esc(m.name)}">Konfigurieren</button>
sel.insertAdjacentHTML("beforeend", `<option>${esc(m.name)}</option>`); <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; if (cur) sel.value = cur;
tb.querySelectorAll("[data-unload]").forEach(b =>
b.addEventListener("click", () => unloadOne(b.getAttribute("data-unload"))) 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"))));
} }
async function pull() { function openConfig(alias) {
const repo = $("#dl-repo").value.trim(), file = $("#dl-file").value.trim(); const m = ALL.find(x => x.name === alias); if (!m) return;
if (!repo || !file) return toast("Repo und Datei angeben.", true); $("#cfg-model-name").textContent = m.name;
try { $("#cfg-ctx").value = m.meta?.ctx || 8192;
const r = await api("/api/download", { method: "POST", body: JSON.stringify({ repo, file }) }); $("#cfg-modal").style.display = "flex";
toast("Download gestartet.");
const stem = file.split("/").pop().replace(/\.gguf$/i, "");
$("#rg-alias").value = stem;
$("#rg-path").value = r.expected_path;
$("#register-box").style.display = "block";
track(r.job_id);
} catch (e) { toast(e.message, true); }
} }
async function saveConfig() {
async function register() { const alias = $("#cfg-model-name").textContent, ctx = parseInt($("#cfg-ctx").value) || 8192;
const alias = $("#rg-alias").value.trim(); $("#cfg-save").disabled = true;
const model_path = $("#rg-path").value; 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(); }
const ctx = parseInt($("#rg-ctx").value) || 8192; catch (e) { toast(e.message, true); }
if (!alias) return toast("Alias angeben.", true); $("#cfg-save").disabled = false;
try {
await api("/api/register", { method: "POST", body: JSON.stringify({ alias, model_path, ctx }) });
toast("Eingepflegt — llama-swap lädt neu.");
refreshSoon();
} catch (e) { toast(e.message, true); }
} }
async function unloadOne(m) { async function unloadOne(m) {
try { 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;
await api("/api/unload?model=" + encodeURIComponent(m), { method: "POST" }); try { await api("/api/unload?model=" + encodeURIComponent(m), { method: "POST" }); toast("Entladen: " + m); setTimeout(refreshSoon, 600); }
toast("Entladen: " + m); catch (e) { toast(e.message, true); }
setTimeout(refreshSoon, 600);
} catch (e) { toast(e.message, true); }
} }
async function sendChat() { async function sendChat() {
@@ -134,14 +118,10 @@ async function sendChat() {
if (!model) return toast("Kein Modell vorhanden.", true); if (!model) return toast("Kein Modell vorhanden.", true);
if (!message) return; if (!message) return;
const btn = $("#chat-btn"); btn.disabled = true; btn.textContent = "…"; const btn = $("#chat-btn"); btn.disabled = true; btn.textContent = "…";
const box = $("#chat-reply"); box.style.display = "block"; const box = $("#chat-reply"); box.style.display = "block"; box.textContent = "(wecke Modell, kann beim Laden kurz dauern…)";
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; }
try { catch (e) { box.textContent = "Fehler: " + e.message; }
const r = await api("/api/chat", { method: "POST", body: JSON.stringify({ model, message }) }); btn.disabled = false; btn.textContent = "Senden"; refreshSoon();
box.textContent = r.reply;
} catch (e) { box.textContent = "Fehler: " + e.message; }
btn.disabled = false; btn.textContent = "Senden";
refreshSoon();
} }
export default { id: "models", mount, onStatus }; export default { id: "models", mount, onStatus };
+125 -124
View File
@@ -1,142 +1,143 @@
// overview.js — Dashboard-Kopf: Hero + Mini-Stats, KPI-Reihe, Stack-Gesundheit, // overview.js — Dashboard (v3): Klartext-Urteil, Metrik-Kacheln, System-Gesundheit,
// kompakte Modell-Liste ("Session Router"). Speist sich aus /api/status + /api/jobs. // geführter Schnellstart, "Dein Stack". Speist sich aus /api/status + /api/system/stream.
import { $, icon, esc } from "../core/ui.js"; import { api } from "../core/api.js";
import { $, esc, icon, toast, fmtBytes, confirmModal } from "../core/ui.js";
let S = null; // letzter Status let S = null; // letzter Status
let J = []; // letzte Job-Liste let SYS = null; // letzte System-Metriken
let SYS = null; // letzte System-Auslastung
const RUNNING = new Set(["running", "ready", "loading", "starting"]); const RUNNING = new Set(["running", "ready", "loading", "starting"]);
function counts() { function models() { return S?.models || []; }
const models = S?.models || []; function activeModel() { return models().find(m => RUNNING.has(m.state)); }
return {
total: models.length,
running: models.filter(m => RUNNING.has(m.state)).length,
jobsRun: J.filter(j => j.state === "running" || j.state === "queued").length,
jobsErr: J.filter(j => j.state === "failed").length,
swap: !!S?.swap_ok,
};
}
function mini(label, val, tone = "") { // ---- Aktionen (geführt) ----
const v = tone ? `<b style="${tone === "bad" ? "color:var(--err)" : ""}">${val}</b>` : val; function go(view) { document.querySelector(`.nav-item[data-view="${view}"]`)?.click(); }
return `<div class="mini"><div class="l">${label}</div><div class="v">${v}</div></div>`; 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() { function renderHero() {
const c = counts(); let title = "Verbinde…", sub = "";
$("#hero").innerHTML = `<div class="hero"> if (S) {
<div> const total = models().length, act = activeModel();
<div class="eyebrow">Übersicht</div> if (!S.swap_ok) { title = "LLM-Engine offline"; sub = "Der llama-swap-Dienst antwortet gerade nicht."; }
<h1>Mission Control</h1> else if (SYS && SYS.ram.percent >= 90) { title = "Achtung: Speicher wird knapp."; sub = `Arbeitsspeicher bei ${Math.round(SYS.ram.percent)} % — eventuell Speicher freigeben.`; }
<p>Steuerzentrale für deinen lokalen llama-swap-Stack — Modelle, Downloads, else {
Wartung und Schnelltest an einem Ort.</p> title = "Alles läuft rund.";
</div> sub = `${total} ${total === 1 ? "Modell" : "Modelle"} bereit · ${act ? `${act.name}" geladen` : "keins geladen"} · keine Warnungen.`;
<div class="hero-stats"> }
${mini("Modelle", c.total)}
${mini("Aktiv", c.running, "on")}
${mini("Jobs", c.jobsRun)}
${mini("Fehler", c.jobsErr, c.jobsErr ? "bad" : "")}
</div>
</div>`;
}
function kpi(cls, title, ic, value, sub) {
return `<div class="kpi ${cls}">
<div class="k-h"><span class="k-t">${title}</span><span class="k-ic">${icon(ic)}</span></div>
<div class="k-v">${value}</div>
<div class="k-s">${sub}</div>
</div>`;
}
function renderKpis() {
const c = counts();
const sysV = SYS ? `${SYS.cpu.percent.toFixed(0)}<small>% CPU</small>` : "n/a";
const sysS = SYS ? `${SYS.ram.percent.toFixed(0)}% RAM, ${SYS.gpu_temp ? SYS.gpu_temp.toFixed(0)+'°C' : SYS.cpu.temp ? SYS.cpu.temp.toFixed(0)+'°C' : ''}` : "bald · Live-Auslastung";
$("#kpis").innerHTML =
kpi(c.swap ? "green" : "red", "llama-swap", "swap",
c.swap ? "Online" : "Offline", "Transport-Status") +
kpi("blue", "Modelle", "monitor",
`${c.running}<small>/${c.total}</small>`, "aktiv / gesamt") +
kpi("purple", "Jobs", "layers", c.jobsRun, "laufend") +
kpi(c.jobsErr ? "red" : "muted", "Fehler", "alert", c.jobsErr, "in der Aktivität") +
kpi(SYS ? "blue" : "muted", "System-Last", "gauge", sysV, sysS);
}
function kvRow(k, v, cls = "") {
return `<div class="kv-row"><span class="kv-k">${k}</span><span class="kv-v ${cls}">${v}</span></div>`;
}
function renderHealth() {
const c = counts();
let sysRow = kvRow("Auslastung (RAM/GPU/Disk)", "folgt", "na");
if (SYS) {
const gb = b => (b / 1024 / 1024 / 1024).toFixed(1);
const ram = `${gb(SYS.ram.used)}GB / ${gb(SYS.ram.total)}GB`;
const gpu = (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 disk = `${SYS.disk.percent.toFixed(0)}%`;
sysRow = kvRow("Auslastung (RAM/GPU/Disk)", `${ram} | ${gpu} | ${disk}`);
} }
$("#ov-hero").innerHTML = `<div class="pagehead"><div>
$("#health").innerHTML = ` <h1>${esc(title)}</h1><div class="sub">${esc(sub)}</div></div></div>`;
<div class="card-h"><h3>Stack-Gesundheit</h3>
<span class="meta ${c.swap ? "ok" : ""}">${c.swap ? "Connected" : "Offline"}</span></div>
<div class="kv">
${kvRow("llama-swap", c.swap ? "Connected" : "Offline", c.swap ? "ok" : "bad")}
${kvRow("Modelle (gesamt)", c.total)}
${kvRow("Aktiv", c.running, c.running ? "ok" : "")}
${kvRow("Jobs (laufend)", c.jobsRun)}
${kvRow("Fehler", c.jobsErr, c.jobsErr ? "bad" : "")}
${sysRow}
</div>`;
} }
function modelRow(m) { // ---- 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 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…");
}
$("#ov-tiles").innerHTML = engine + model + temp + mem;
}
// ---- 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 on = RUNNING.has(m.state);
const dot = m.state === "loading" || m.state === "starting" ? "load" : on ? "on" : ""; const dot = m.state === "loading" || m.state === "starting" ? "load" : on ? "on" : "";
const state = on ? (m.state === "loading" ? "lädt…" : "geladen") : "bereit"; 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>
let caps = ""; <span class="li-id" style="flex:1">${esc(m.name)}</span>
if (m.meta && m.meta.caps) { ${capTag(m.meta?.caps)}
caps = m.meta.caps.map(c => { <span class="li-meta" style="width:120px;text-align:right">${status}</span></div>`;
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>`; function renderStack() {
return ""; const ms = models();
}).join(""); $("#ov-stack").innerHTML =
} `<div class="card-h"><h3>Dein Stack</h3><span class="meta">${ms.length ? ms.length + " konfiguriert" : ""}</span></div>` +
(ms.length
return `<div class="li"> ? `<div class="list">${ms.map(stackRow).join("")}</div>`
<span class="li-dot ${dot}"></span> : `<div class="empty-c"><div class="e-t">Noch keine Modelle</div>
<div class="li-main"> <div class="e-s">Hol dir unter „Cookbook" ein Modell, das auf deine Hardware passt.</div></div>`);
<div class="li-id" style="font-weight:500">${esc(m.name)}${caps}</div>
<div class="li-sub">TTL ${m.ttl ?? "—"}${typeof m.ttl === "number" ? "s" : ""}</div>
</div>
<div class="li-right">
<div class="li-meta">${m.port ?? "auto"}</div>
<div class="li-time">${state}</div>
</div>
</div>`;
} }
function renderModels() { function renderAll() { renderHero(); renderTiles(); renderHealth(); renderQuickstart(); renderStack(); }
const models = S?.models || [];
$("#ov-models").innerHTML = `
<div class="card-h"><h3>Modelle</h3><span class="meta">${models.length || ""}</span></div>
${models.length
? `<div class="list">${models.map(modelRow).join("")}</div>`
: `<div class="empty-c"><div class="e-t">Keine Modelle konfiguriert</div>
<div class="e-s">Hol dir unter „Modelle" eins von HuggingFace.</div></div>`}`;
}
function renderAll() { renderHero(); renderKpis(); renderHealth(); renderModels(); } function mount() { renderQuickstart(); renderAll(); }
function onStatus(s) { S = s; renderHero(); renderTiles(); renderStack(); }
function onSystem(sys) { SYS = sys; renderHero(); renderTiles(); renderHealth(); }
function mount() { renderAll(); } export default { id: "overview", mount, onStatus, onSystem };
function onStatus(s) { S = s; renderAll(); }
function onJobs(jobs) { J = jobs || []; renderHero(); renderKpis(); renderHealth(); }
function onSystem(sys) { SYS = sys; renderKpis(); renderHealth(); }
export default { id: "overview", mount, onStatus, onJobs, onSystem };
+105
View File
@@ -0,0 +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, icon, confirmModal, promptModal } from "../core/ui.js";
import { track } from "./jobs.js";
function refreshSoon() { document.dispatchEvent(new Event("mc:refresh")); }
function mount() {
$("#wartung").outerHTML = `<div id="wartung" style="display:flex;flex-direction:column;gap:var(--sp-4)">
<div class="pagehead"><div>
<h1>Server &amp; 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 &amp; 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">
<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="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);
connectConsole();
}
let ws = null;
function connectConsole() {
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:";
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, 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() {
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 &amp; 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 };
+21
View File
@@ -0,0 +1,21 @@
import paramiko
host = '192.168.178.153'
user = 'hitonabi'
password = 'Tu77ceu2zzvx!'
print("Connecting to server...")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(host, username=user, password=password, look_for_keys=False, allow_agent=False)
cmd = f"echo {password} | sudo -S bash /home/hitonabi/mission-control/deploy_bosgame.sh"
print(f"Executing deployment script on server...")
stdin, stdout, stderr = ssh.exec_command(cmd)
exit_status = stdout.channel.recv_exit_status()
print("STDOUT:", stdout.read().decode())
print("STDERR:", stderr.read().decode())
ssh.close()
print("Deployment complete!")
BIN
View File
Binary file not shown.