Compare commits
23 Commits
8800dd4558
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1aea0f558e | |||
| 52b0a3bff5 | |||
| 8f63c4969a | |||
| 812ee8ac7c | |||
| 88bb681339 | |||
| b2b586093d | |||
| e180adf21a | |||
| 44270b6cb7 | |||
| d0ed4e4c7e | |||
| ee8ec10119 | |||
| 0a81a9fe99 | |||
| c76bcc7293 | |||
| a51f6ee88a | |||
| 8e8d564469 | |||
| 8b76adc96e | |||
| e3be7fbfb5 | |||
| 9d05b2d011 | |||
| 57c176a251 | |||
| 16199ce53a | |||
| ef7f02e19f | |||
| b84e8f3ac2 | |||
| 00cf14c6e3 | |||
| 04bac7e13e |
@@ -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
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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!")
|
||||||
@@ -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."
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
|
||||||
|
|||||||
@@ -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 & 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 };
|
||||||
@@ -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 & 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
@@ -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 & 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 & 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 };
|
||||||
|
|||||||
@@ -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
@@ -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 & Ports</h3><span class="meta" id="m-count"></span></div>
|
<div class="card-h"><h3>Modelle & 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
@@ -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 };
|
|
||||||
|
|||||||
@@ -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 & Wartung</h1>
|
||||||
|
<div class="sub">Dienste steuern, Updates einspielen und live mitlesen — ohne SSH/Terminal.</div>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-h"><h3>Dienste & Applikation</h3></div>
|
||||||
|
<div class="card-sub">Neustarts sind harmlos und passwortlos — nichts geht dabei verloren.</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button id="w-restart-swap">LLM-Engine neustarten</button>
|
||||||
|
<button id="w-restart-mc">Dashboard neustarten</button>
|
||||||
|
<button id="w-update">Nach Updates suchen</button>
|
||||||
|
<button id="w-unload" class="ghost">Grafikspeicher leeren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<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 & 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 };
|
||||||
@@ -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!")
|
||||||
Binary file not shown.
Reference in New Issue
Block a user