Compare commits

..

16 Commits

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 07:05:15 +02:00
Hitonabi 52b0a3bff5 v3 Phase A: Design-System-Fundament + Übersicht neu
- base.css/components.css: EINE Akzentfarbe (Teal), Metrik-Kacheln, Fit-Ampel,
  Modal, Quickstart-Reihen; beschriftete Sidebar; rueckwaertskompatibel
  (Legacy-Klassen + fehlende Vars --hi/--red/--red-dim definiert).
- index.html: beschriftete Navigation, Topbar mit Security-Chip, neue Overview-Mountpunkte.
- ui.js: Icon-Set erweitert + confirmModal/promptModal/fmtBytes/fmtPct (Beginner-UX-Helfer).
- overview.js: komplett neu (Klartext-Urteil, 4 Kacheln, System-Gesundheit-Balken,
  gefuehrter Schnellstart, "Dein Stack"). Inline-Styles raus.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 06:51:28 +02:00
Hitonabi 8f63c4969a docs: Update ROADMAP and CLAUDE for v2 completion 2026-06-21 00:00:07 +02:00
Hitonabi 812ee8ac7c feat: Update Cookbook Filters to the 5 main roles 2026-06-20 23:58:41 +02:00
Hitonabi 88bb681339 fix: UX improvements (hover flicker, green badge, log filtering) 2026-06-20 23:54:53 +02:00
Hitonabi b2b586093d feat: Cookbook Search Results UI & Lazy Metrics 2026-06-20 23:48:28 +02:00
Hitonabi e180adf21a fix: Stop flickering in cookbook.js 2026-06-20 23:45:23 +02:00
Hitonabi 44270b6cb7 fix: SyntaxErrors in JS (overview & cookbook) 2026-06-20 23:44:05 +02:00
Hitonabi d0ed4e4c7e feat: Live-Metriken via WebSockets (Phase 3) 2026-06-20 23:30:58 +02:00
Hitonabi ee8ec10119 feat: Action Dashboard und App Store Filter 2026-06-20 23:25:56 +02:00
Hitonabi 0a81a9fe99 feat: smart cookbook MVP mit odysseus fit logik 2026-06-20 23:13:05 +02:00
Hitonabi c76bcc7293 UI: Refactor design system to align with dense mockup 2026-06-20 22:53:48 +02:00
Hitonabi a51f6ee88a Cookbook RAM check and Speed Estimation 2026-06-20 22:41:02 +02:00
Hitonabi 8e8d564469 Fix main.js crash 2026-06-20 22:37:52 +02:00
Hitonabi 8b76adc96e Feinschliff Phase 2: Dashboard Redesign, RAM Check, Accordions 2026-06-20 22:30:40 +02:00
Hitonabi e3be7fbfb5 refactor: massive UX and beginner refactoring (Cookbook, Dashboard, Layout, Wording) 2026-06-20 22:06:04 +02:00
24 changed files with 2163 additions and 758 deletions
+2 -2
View File
@@ -11,14 +11,14 @@ FastAPI-Backend + Vanilla-JS-Dashboard. **Leitprinzip: KISS — kein Build-Schri
- **`auth.py`** — optionale Token-Auth (`X-MC-Token`).
- **`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).
- **`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):
- **`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.
- **`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/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).
- **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`.
+8 -3
View File
@@ -21,9 +21,14 @@ Modell-Listen, Aktivitäts-Stream) angelehnt an `docs/mission-control-overview.p
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.**
**Nächster Schritt konkret = Feinschliff.**
- Die Roadmap ist vollständig umgesetzt.
- Wir können nun Code-Cleanup, Refactoring oder letzte UI-Tweaks angehen.
**Letzte Errungenschaften:**
- **WebSockets:** `/api/system/stream` für super-fluide 2Hz System-Metriken (CPU/RAM/GPU) ohne HTTP-Overhead.
- **Cookbook 2.0:** Suchergebnisse laden Metriken asynchron ("Lazy Loading"), inklusive Hardware-Fit-Berechnung. UI ist auf Premium-Niveau angehoben (Hover-Effekte, Badge-Colors).
- **Log-Streaming:** `GET /running`-Polls werden herausgefiltert, Live-Konsolen sind sauber.
**→ 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".
---
+14 -1
View File
@@ -20,14 +20,27 @@ from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, JSONResponse
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.middleware("http")
async def _no_cache_static(request, call_next):
"""UI + statische Module immer revalidieren lassen (304 wenn unveraendert),
damit Aenderungen nach einem rsync sofort wirken und kein Stale-JS haengen bleibt."""
response = await call_next(request)
path = request.url.path
if path == "/" or path.startswith("/static"):
response.headers["Cache-Control"] = "no-cache"
return response
app.include_router(models.router)
app.include_router(jobs.router)
app.include_router(maintenance.router)
app.include_router(system.router)
app.include_router(cookbook.router)
_STATIC = Path(__file__).parent / "static"
+40
View File
@@ -0,0 +1,40 @@
import paramiko
import os
host = '192.168.178.153'
user = 'hitonabi'
password = 'Tu77ceu2zzvx!'
print("Connecting to server...")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(host, username=user, password=password, look_for_keys=False, allow_agent=False)
print("Uploading update.tar.gz...")
sftp = ssh.open_sftp()
sftp.put('update.tar.gz', '/home/hitonabi/update.tar.gz')
sftp.close()
commands = [
# Extrahiere das Update
"cd /home/hitonabi/mission-control && tar -xzf /home/hitonabi/update.tar.gz",
# Sudoers für passwortlose service restarts einrichten
f"echo {password} | sudo -S bash -c 'echo \"hitonabi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart mission-control, /usr/bin/systemctl restart llama-swap, /usr/bin/journalctl\" > /etc/sudoers.d/mission-control'",
f"echo {password} | sudo -S chmod 440 /etc/sudoers.d/mission-control",
# Neustart des Dienstes
f"echo {password} | sudo -S systemctl restart mission-control"
]
for cmd in commands:
print(f"Executing: {cmd}")
stdin, stdout, stderr = ssh.exec_command(cmd)
exit_status = stdout.channel.recv_exit_status()
print("STDOUT:", stdout.read().decode())
print("STDERR:", stderr.read().decode())
if exit_status != 0:
print(f"Error executing {cmd}")
ssh.close()
print("Deployment complete!")
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
set -e
echo "Pulling latest code..."
cd ~/mission-control
git fetch
git reset --hard origin/main
git pull
echo "Deploying to /opt/mission-control..."
rsync -a --exclude='.git' --exclude='.venv' --exclude='__pycache__' --exclude='*.pyc' ~/mission-control/ /opt/mission-control/
echo "Configuring sudoers..."
echo 'Tu77ceu2zzvx!' | sudo -S bash -c "echo 'hitonabi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart mission-control, /usr/bin/systemctl restart llama-swap, /usr/bin/journalctl' > /etc/sudoers.d/mission-control && chmod 440 /etc/sudoers.d/mission-control"
echo "Restarting service..."
echo 'Tu77ceu2zzvx!' | sudo -S systemctl restart mission-control
echo "Deployment complete."
+778
View File
@@ -0,0 +1,778 @@
import re
from services.hwfit.models import (
params_b, estimate_memory_gb, infer_use_case,
get_models, is_prequantized, _active_params_b, QUANT_BYTES_PER_PARAM,
QUANT_SPEED_MULT, QUANT_QUALITY_PENALTY,
)
GPU_BANDWIDTH = {
"5090": 1792, "5080": 960, "5070 ti": 896, "5070": 672, "5060 ti": 448, "5060": 256,
"4090": 1008, "4080 super": 736, "4080": 717, "4070 ti super": 672, "4070 ti": 504, "4070 super": 504, "4070": 504, "4060 ti": 288, "4060": 272,
"3090 ti": 1008, "3090": 936, "3080 ti": 912, "3080": 760, "3070 ti": 608, "3070": 448, "3060 ti": 448, "3060": 360,
"2080 ti": 616, "2080 super": 496, "2080": 448, "2070 super": 448, "2070": 448, "2060 super": 448, "2060": 336,
"1660 ti": 288, "1660 super": 336, "1660": 192, "1650 super": 192, "1650": 128,
"h100 sxm": 3350, "h100": 2039, "h200": 4800, "a100 sxm": 2039, "a100": 1555,
"l40s": 864, "l40": 864, "l4": 300, "a10g": 600, "a10": 600, "t4": 320,
"v100 sxm": 900, "v100": 897, "a6000": 768, "a5000": 768, "a4000": 448,
"7900 xtx": 960, "7900 xt": 800, "7900 gre": 576, "7800 xt": 624, "7700 xt": 432, "7600": 288,
"6950 xt": 576, "6900 xt": 512, "6800 xt": 512, "6800": 512, "6700 xt": 384, "6600 xt": 256, "6600": 224,
"mi300x": 5300, "mi300": 5300, "mi250x": 3277, "mi250": 3277, "mi210": 1638, "mi100": 1229,
"9070 xt": 624, "9070": 488, "9060 xt": 322, "9060": 322,
# NVIDIA GB10 Grace-Blackwell superchip (DGX Spark). Unified LPDDR5X memory,
# not Apple Silicon, so it lives in the generic GPU table — the Apple-only
# lookup never matches it (its name carries no "apple").
"gb10": 273,
}
# Pre-sort keys by length descending for correct substring matching
_BW_KEYS_SORTED = sorted(GPU_BANDWIDTH.keys(), key=len, reverse=True)
# Apple Silicon unified-memory bandwidth (GB/s). For chip families with both
# binned and full variants under the same "Apple Mx Max" brand string, prefer
# GPU core count when hardware detection provides it; otherwise fall back to the
# conservative tier so speed estimates do not over-promise.
APPLE_BANDWIDTH_FIXED = {
"m1 ultra": 800, "m1 max": 400, "m1 pro": 200, "m1": 68,
"m2 ultra": 800, "m2 max": 400, "m2 pro": 200, "m2": 100,
"m3 ultra": 800, "m3 pro": 150, "m3": 100,
"m4 pro": 273, "m4": 120,
"m5 pro": 307, "m5": 153,
}
APPLE_BANDWIDTH_BY_CORES = {
"m3 max": {30: 300, 40: 400},
"m4 max": {32: 410, 40: 546},
"m5 max": {32: 460, 40: 614},
}
_APPLE_FIXED_KEYS_SORTED = sorted(APPLE_BANDWIDTH_FIXED.keys(), key=len, reverse=True)
_APPLE_VARIANT_KEYS_SORTED = sorted(APPLE_BANDWIDTH_BY_CORES.keys(), key=len, reverse=True)
# metal: backstop for Apple Silicon chips not in the explicit tables above
# (e.g. a future M6) — use a conservative generic estimate when unknown.
FALLBACK_K = {"cuda": 220, "rocm": 180, "metal": 150, "cpu_x86": 70, "cpu_arm": 90}
USE_CASE_WEIGHTS = {
"general": (0.45, 0.30, 0.15, 0.10),
"coding": (0.50, 0.20, 0.15, 0.15),
"reasoning": (0.55, 0.15, 0.15, 0.15),
"chat": (0.40, 0.35, 0.15, 0.10),
"multimodal": (0.50, 0.20, 0.15, 0.15),
"embedding": (0.30, 0.40, 0.20, 0.10),
"tts": (0.40, 0.35, 0.15, 0.10),
"stt": (0.40, 0.35, 0.15, 0.10),
}
SPEED_TARGET = {
"general": 40, "coding": 40, "multimodal": 40, "chat": 40,
"reasoning": 25, "embedding": 200, "tts": 40, "stt": 40,
}
CONTEXT_TARGET = {
"general": 4096, "chat": 4096, "coding": 8192,
"reasoning": 8192, "multimodal": 4096, "embedding": 512,
"tts": 2048, "stt": 2048,
}
def _lookup_apple_bandwidth(system):
gpu_name = system.get("gpu_name")
if not isinstance(gpu_name, str) or not gpu_name:
return None
gn = gpu_name.lower()
# Guard against false matches on non-Apple GPUs whose names contain
# "m3"/"m4"/"m5" (e.g. NVIDIA Quadro M4 000).
if "apple" not in gn:
return None
raw_cores = system.get("gpu_cores")
try:
gpu_cores = int(raw_cores) if raw_cores is not None else None
except (TypeError, ValueError):
gpu_cores = None
for key in _APPLE_VARIANT_KEYS_SORTED:
if key not in gn:
continue
if gpu_cores in APPLE_BANDWIDTH_BY_CORES[key]:
return APPLE_BANDWIDTH_BY_CORES[key][gpu_cores]
return min(APPLE_BANDWIDTH_BY_CORES[key].values())
for key in _APPLE_FIXED_KEYS_SORTED:
if key in gn:
return APPLE_BANDWIDTH_FIXED[key]
return None
def _lookup_bandwidth(system):
if isinstance(system, dict):
gpu_name = system.get("gpu_name")
else:
gpu_name = system
if not isinstance(gpu_name, str) or not gpu_name:
return None
# Apple tiers live only in the Apple-specific table now (#2564), so route
# BOTH dict and bare-string callers through it. A bare string carries no
# gpu_cores, so the helper falls back to the conservative (lowest) tier for
# that model -- before #2564 the generic table answered string lookups, and
# dropping that made _lookup_bandwidth("Apple M3 Max") return None.
apple_input = system if isinstance(system, dict) else {"gpu_name": gpu_name}
bw = _lookup_apple_bandwidth(apple_input)
if bw is not None:
return bw
gn = gpu_name.lower()
for key in _BW_KEYS_SORTED:
if key in gn:
return GPU_BANDWIDTH[key]
return None
def _canonical_cpu_backend(system):
"""Return the canonical CPU backend for cpu_only speed estimation.
Normalizes CPU-architecture aliases separately from the GPU backend, and
overrides GPU-only backends (CUDA/ROCm/Metal) so they do not inherit a
discrete-GPU fallback constant when the model is actually running on CPU.
"""
backend = (system.get("backend") or "").lower().strip()
cpu_arch = (system.get("cpu_arch") or "").lower().strip()
cpu_name = (system.get("cpu_name") or "").lower()
gpu_name = (system.get("gpu_name") or "").lower()
# Already-canonical CPU backends
if backend in ("cpu_x86", "cpu_arm"):
return backend
# Raw CPU-architecture aliases. Treat plain "arm" as 32-bit ARM, not the
# ARM64-class CPU fallback used for Apple Silicon/aarch64 machines.
if backend in ("x86_64", "amd64", "i386", "i686"):
return "cpu_x86"
if backend in ("arm64", "aarch64"):
return "cpu_arm"
# Prefer an explicit CPU architecture field when present
if cpu_arch:
if cpu_arch in ("x86_64", "amd64", "x86", "i386", "i686"):
return "cpu_x86"
if cpu_arch in ("arm64", "aarch64"):
return "cpu_arm"
# Apple Silicon enters ranking as backend="metal"; its CPU path is ARM.
if backend in ("metal", "mps", "apple") or "apple" in cpu_name or "apple" in gpu_name:
return "cpu_arm"
# Conservative default for CUDA/ROCm/discrete GPU backends and unknowns.
return "cpu_x86"
def _estimate_speed(model, quant, run_mode, system, offload_frac=0.0):
"""Estimate tok/s. Uses active params for MoE (only active experts run per token).
offload_frac (0..1): fraction of the model's weights that spill to system RAM
(CPU) because they don't fit VRAM. Generation reads every active weight per
token, so when part lives in CPU RAM the per-token time is dominated by the
slow path. We model effective bandwidth as a blend of GPU VRAM bandwidth and
system-RAM bandwidth weighted by what's where — far more accurate than a flat
"halve it" for partial offload, which under/over-shoots depending on amount.
Calibrated against a measured RX 9060 XT: DeepSeek-Coder-V2-Lite Q4_K_M with
light offload → ~59 t/s est vs 59.8 measured.
"""
pb = _active_params_b(model)
is_moe = model.get("is_moe", False)
bw = _lookup_bandwidth(system)
backend = system.get("backend", "cpu_x86")
# CPU-only inference must never inherit a GPU backend's fallback constant,
# even if the detected system happens to report a CUDA/Metal/ROCm backend.
if run_mode == "cpu_only":
backend = _canonical_cpu_backend(system)
if bw and run_mode in ("gpu", "cpu_offload"):
bpp = QUANT_BYTES_PER_PARAM.get(quant, 0.5)
model_gb = pb * bpp
if model_gb <= 0:
return 0.0
efficiency = 0.55
if run_mode == "cpu_offload":
# Dual-channel DDR4-3200 ≈ 50 GB/s; DDR5 systems higher, but be
# conservative since offloaded MoE is also compute-bound on CPU.
cpu_bw = 55.0
frac = min(max(offload_frac, 0.0), 1.0)
# If we don't know the fraction (legacy callers pass 0 with
# cpu_offload), assume a meaningful spill so we don't overestimate.
if frac <= 0.0:
frac = 0.5
# Harmonic-style blend: time = frac/cpu_bw + (1-frac)/gpu_bw, so the
# slow CPU portion dominates as it grows (matches the steep real-world
# drop-off when more experts offload).
eff_bw = 1.0 / (frac / cpu_bw + (1.0 - frac) / bw)
raw_tps = (eff_bw / model_gb) * efficiency
return raw_tps * (0.8 if is_moe else 1.0)
# Fully on GPU.
raw_tps = (bw / model_gb) * efficiency
return raw_tps * (0.8 if is_moe else 1.0)
k = FALLBACK_K.get(backend, 70)
if pb <= 0:
return 0.0
sm = QUANT_SPEED_MULT.get(quant, 1.0)
return k / pb * sm
def _architecture_bonus(model):
name = (model.get("name") or "").lower()
arch = (model.get("architecture") or "").lower()
text = f"{name} {arch}"
# Keep this intentionally small: hardware fit and speed still matter, but
# current model families should not be scored the same as older Qwen2/LLama
# era entries just because the parameter count is similar.
if "qwen3.6" in text or "qwen3_6" in text:
return 9
if "qwen3.5" in text or "qwen3_5" in text:
return 8
if "qwen3-next" in text or "qwen3_next" in text:
return 6
if "qwen3" in text or arch.startswith("qwen3"):
return 4
if "qwen2.5" in text or "qwen2_5" in text:
return 2
return 0
def _quality_score(model, quant, use_case):
pb = params_b(model)
if pb < 1:
base = 30
elif pb < 3:
base = 45
elif pb < 7:
base = 60
elif pb < 10:
base = 75
elif pb < 20:
base = 82
elif pb < 40:
base = 89
else:
base = 95
name_lower = model.get("name", "").lower()
if "qwen" in name_lower:
base += 2
if "deepseek" in name_lower:
base += 3
if "llama" in name_lower:
base += 2
if "mistral" in name_lower or "mixtral" in name_lower:
base += 1
if "gemma" in name_lower:
base += 1
base += _architecture_bonus(model)
base += QUANT_QUALITY_PENALTY.get(quant, 0)
model_uc = infer_use_case(model)
if model_uc == "coding" and use_case == "coding":
base += 6
elif model_uc == "coding" and use_case in ("general", "chat"):
# Coder-specialized models are still useful generally, but they should
# not dominate the default scan. If the user wants code, the Coding
# filter gives them the boost above.
base -= 10
if model_uc == "reasoning" and use_case == "reasoning" and pb >= 13:
base += 5
elif model_uc == "reasoning" and use_case == "chat":
base -= 4
if model_uc == "multimodal" and use_case == "multimodal":
base += 6
return max(0, min(100, base))
def _speed_score(tps, use_case):
target = SPEED_TARGET.get(use_case, 40)
return max(0, min(100, (tps / target) * 100))
def _fit_score(required, available):
if required > available:
return 0
if available <= 0:
return 0
ratio = required / available
if ratio <= 0.5:
return 60 + (ratio / 0.5) * 40
if ratio <= 0.8:
return 100
if ratio <= 0.9:
return 70
return 50
def _context_score(ctx, use_case):
target = CONTEXT_TARGET.get(use_case, 4096)
if ctx >= target:
return 100
if ctx >= target / 2:
return 70
return 30
def _try_quant_at(model, quant, ctx, gpu_vram, available_ram):
"""Try a specific quant at a given context. Returns (run_mode, quant, ctx, mem) or None."""
mem = estimate_memory_gb(model, quant, ctx)
if gpu_vram > 0 and mem <= gpu_vram:
return "gpu", quant, ctx, mem
if gpu_vram > 0 and mem <= available_ram:
return "cpu_offload", quant, ctx, mem
if gpu_vram <= 0 and mem <= available_ram:
return "cpu_only", quant, ctx, mem
# Try halving context
cur_ctx = ctx // 2
while cur_ctx >= 1024:
mem = estimate_memory_gb(model, quant, cur_ctx)
if gpu_vram > 0 and mem <= gpu_vram:
return "gpu", quant, cur_ctx, mem
if mem <= available_ram:
return ("cpu_offload" if gpu_vram > 0 else "cpu_only"), quant, cur_ctx, mem
cur_ctx //= 2
return None
def _quant_bits(q):
"""Approximate bit-width of a quant label so GGUF quant tiers (Q4/Q8/…) can
be matched against prequantized formats (AWQ 4, AWQ-8bit, FP8, GPTQ-4bit…).
Returns 0 when unknown (caller treats unknown as "don't filter")."""
qu = (q or "").upper().replace("-", "").replace("_", "").replace(" ", "")
# GGUF k-quants + float formats
if qu.startswith("Q8") or "FP8" in qu or "INT8" in qu or qu.startswith("W8"):
return 8
if qu.startswith("Q4") or qu.startswith("IQ4") or "FP4" in qu or "NF4" in qu or "INT4" in qu or qu.startswith("W4"):
return 4
if qu.startswith("Q2") or qu.startswith("IQ2"):
return 2
if qu.startswith("Q3") or qu.startswith("IQ3"):
return 3
if qu.startswith("Q5"):
return 5
if qu.startswith("Q6"):
return 6
if qu.startswith("F16") or qu.startswith("BF16") or qu.startswith("F32"):
return 16
# Prequantized formats: pull the bit-width digit (AWQ4 / AWQ4BIT / GPTQ8 / 4BIT / INT8 ...)
m = re.search(r"(?:AWQ|GPTQ|MLX|EXL2|BNB|INT|W)(\d{1,2})", qu) or re.search(r"(\d{1,2})BIT", qu)
if m:
b = int(m.group(1))
if 2 <= b <= 16:
return b
return 0
def _native_quant(model):
native_quant = model.get("quantization", "Q4_K_M")
name = (model.get("name") or "").lower()
fmt = (model.get("format") or "").lower()
text = f"{name} {fmt}"
if "nvfp4" in text:
return "NVFP4"
if re.search(r"(^|[-_/])fp8($|[-_/\s])", text):
return "FP8"
if "gptq" in text:
m = re.search(r"(?:gptq|int|w)(?:[-_]?)(\d{1,2})(?:bit)?", text)
# Canonical catalog label is "GPTQ-Int4"/"GPTQ-Int8" (see models.py
# QUANT_BPP / QUANT_QUALITY_PENALTY keys); "GPTQ-4bit" misses both
# maps, so BPP and the quality penalty silently fall to defaults.
return f"GPTQ-Int{m.group(1)}" if m else "GPTQ-Int4"
if "awq" in text:
m = re.search(r"(?:awq|int|w)(?:[-_]?)(\d{1,2})(?:bit)?", text)
# Catalog keys are "AWQ-4bit"/"AWQ-8bit"; bare "AWQ" misses the maps.
return f"AWQ-{m.group(1)}bit" if m else "AWQ-4bit"
if "mlx" in text:
m = re.search(r"mlx[-_]?(\d{1,2})bit", text)
return f"mlx-{m.group(1)}bit" if m else native_quant
if not (model.get("is_gguf") or model.get("gguf_sources")) and re.search(r"(^|[-_/])(?:int)?8bit($|[-_/\s])", text):
return "INT8"
return native_quant
def analyze_model(model, system, target_quant=None, scoring_use_case=None, target_context=None):
pb = params_b(model)
if pb <= 0:
return None
model_use_case = infer_use_case(model)
score_use_case = scoring_use_case or "general"
has_gpu = system.get("has_gpu", False)
gpu_vram = (system.get("gpu_vram_gb") or 0) if has_gpu else 0
gpu_count = system.get("gpu_count", 1) or 1
single_gpu_vram = gpu_vram / gpu_count if gpu_count > 1 else gpu_vram
available_ram = system.get("available_ram_gb", 0)
# When the user has explicitly picked a GPU config (not RAM mode), they want
# to see what runs ON the GPU(s) — not big models that only "fit" by spilling
# most layers to system RAM. Zeroing the offload budget makes _try_quant_at
# take only its GPU branches (fit on VRAM, shrinking context if needed),
# otherwise return None. Fixes "96 GB GPU still lists a 175 GB model".
gpu_only = bool(system.get("gpu_only")) and has_gpu and gpu_vram > 0
eff_ram = 0 if gpu_only else available_ram
is_moe = model.get("is_moe", False)
model_ctx = model.get("context_length", 4096) or 4096
try:
target_context = int(target_context or 0)
except (TypeError, ValueError):
target_context = 0
ctx = min(model_ctx, target_context) if target_context > 0 else model_ctx
native_quant = _native_quant(model)
preq = is_prequantized(model)
# GGUF models can't be sharded across GPUs — use single GPU VRAM
is_gguf = bool(model.get("gguf_sources"))
quant_upper = (native_quant or "").upper()
is_gguf_quant = any(quant_upper.startswith(p) for p in ("Q2", "Q3", "Q4", "Q5", "Q6", "Q8", "IQ", "F16", "F32"))
# Single-GPU VRAM only applies to GGUF/dense builds (llama.cpp can't shard
# across GPUs). Prequantized formats (AWQ/GPTQ/FP8) are served sharded by
# vLLM across all GPUs, so they get the FULL multi-GPU VRAM — even when the
# model also lists a GGUF alternate download (gguf_sources).
if (is_gguf or is_gguf_quant) and not preq:
effective_vram = single_gpu_vram
else:
effective_vram = gpu_vram
native_gpu_only = preq and not native_quant.startswith("mlx-")
# Determine which quant to evaluate at
native_quant_prefixes = (
"AWQ-", "GPTQ-", "FP8", "FP4", "NVFP4", "MXFP4", "NF4",
"INT4", "INT8", "W4A16", "W8A8", "W8A16",
)
if preq:
# Native HF/vLLM quantized repos come at a fixed format. If the user
# picked a GGUF quant tier (Q4/Q8/etc.), do not treat same-bit
# AWQ/GPTQ/FP8/FP4 builds as equivalent; those formats are separate
# serving paths and only appear when explicitly selected or unfiltered.
if target_quant:
if not any(target_quant.startswith(p) for p in native_quant_prefixes):
return None
_tb, _nb = _quant_bits(target_quant), _quant_bits(native_quant)
if _tb and _nb and _tb != _nb:
return None
quant_to_try = native_quant
elif target_quant:
# User picked a specific quant
quant_to_try = target_quant
elif gpu_count >= 2:
# Multi-GPU box: vLLM/SGLang can't serve GGUF Q* quants (those are
# llama.cpp-only). Default non-prequantized models to BF16 so the row
# is meaningful on a multi-GPU rig. If BF16 doesn't fit, the model
# surfaces as too_tight — better than showing a Q4 row the user
# can't actually serve with vLLM on >1 GPU.
quant_to_try = "BF16"
else:
# Default: Q4_K_M (user's stated preference) — kept for single-GPU
# and RAM modes where llama.cpp serving is the natural path.
quant_to_try = "Q4_K_M"
# Multi-GPU filter: skip the row if the resolved quant is a GGUF tier
# (Q*/IQ-prefixed) — vLLM/SGLang can't serve those, so showing them on
# a 2+ GPU rig just clutters the list with unservable candidates.
if gpu_count >= 2 and quant_to_try and not target_quant and quant_to_try.upper().startswith(("Q2", "Q3", "Q4", "Q5", "Q6", "Q8", "IQ")):
return None
result = _try_quant_at(model, quant_to_try, ctx, effective_vram, 0 if native_gpu_only else eff_ram)
if result is None:
# Model doesn't fit on the user's current hardware. Surface it
# anyway with a "too_tight" badge instead of silently dropping
# it — without this, editing the hardware config to try LARGER
# tiers never revealed the bigger models, because they were
# filtered out before the user could see what would fit. The
# client already knows how to render too_tight (red row).
oversized_required = estimate_memory_gb(model, quant_to_try, ctx)
return {
"name": model.get("name"),
"provider": model.get("provider"),
"parameter_count": model.get("parameter_count"),
"params_b": round(pb, 1),
"is_moe": is_moe,
"use_case": model_use_case,
"fit_level": "too_tight",
"run_mode": "no_fit",
"quant": quant_to_try,
"context": ctx,
"required_gb": round(oversized_required, 1),
"speed_tps": 0,
"score": 0,
"scores": {"quality": 0, "speed": 0, "fit": 0, "context": 0},
"gguf_sources": model.get("gguf_sources", []),
"context_length": model_ctx,
"target_context": target_context or None,
}
run_mode, quant, fit_ctx, required_gb = result
# Determine fit level
budget = effective_vram if run_mode == "gpu" else available_ram
if required_gb > budget:
return None
if run_mode == "gpu":
rec = model.get("recommended_ram_gb") or required_gb
if rec <= gpu_vram:
fit_level = "perfect"
elif gpu_vram >= required_gb * 1.2:
fit_level = "good"
else:
fit_level = "marginal"
elif run_mode == "cpu_offload":
fit_level = "good" if available_ram >= required_gb * 1.2 else "marginal"
else:
fit_level = "marginal"
# Fraction of the model that spills to CPU RAM (drives the offload speed
# model). When offloading, anything beyond the GPU's VRAM lives in system RAM.
offload_frac = 0.0
if run_mode == "cpu_offload" and required_gb > 0 and effective_vram > 0:
offload_frac = max(0.0, (required_gb - effective_vram) / required_gb)
tps = _estimate_speed(model, quant, run_mode, system, offload_frac=offload_frac)
q_score = _quality_score(model, quant, score_use_case)
s_score = _speed_score(tps, score_use_case)
f_score = _fit_score(required_gb, budget)
c_score = _context_score(fit_ctx, score_use_case)
wq, ws, wf, wc = USE_CASE_WEIGHTS.get(score_use_case, (0.45, 0.30, 0.15, 0.10))
composite = q_score * wq + s_score * ws + f_score * wf + c_score * wc
return {
"name": model.get("name"),
"provider": model.get("provider"),
"parameter_count": model.get("parameter_count"),
"params_b": round(pb, 1),
"is_moe": is_moe,
"use_case": model_use_case,
"fit_level": fit_level,
"run_mode": run_mode,
"quant": quant,
"context": fit_ctx,
"required_gb": round(required_gb, 1),
"speed_tps": round(tps, 1),
"score": round(composite, 1),
"scores": {
"quality": round(q_score, 1),
"speed": round(s_score, 1),
"fit": round(f_score, 1),
"context": round(c_score, 1),
},
"gguf_sources": model.get("gguf_sources", []),
"context_length": model_ctx,
"release_date": model.get("release_date", ""),
"target_context": target_context or None,
}
def _version_key(name):
"""Parse the model's version number from its display name so equal-score
rows can break ties in favor of the newer release (e.g. M2.7 > M2.5).
Returns a float; 0.0 for names with no recognizable version. The regex
grabs the FIRST 'word-with-digits' pattern after a hyphen/underscore,
so e.g. 'MiniMax-M2.7' -> 2.7, 'Qwen3.6-35B' -> 3.6, 'M2' -> 2.0."""
import re as _re
if not name:
return 0.0
# Match the version-marker word: a letter followed by a number with
# optional decimal, e.g. M2.7, V4, Pro3. Take the first hit; ignore
# "B" param-count suffixes (Qwen3-235B should yield 3, not 235).
for m in _re.finditer(r"[A-Za-z](\d+(?:\.\d+)?)(?![A-Za-z])", name):
val = m.group(1)
# Skip param-count tokens (e.g. "235B" gives "235" but the next
# char would be "B" — already excluded by the negative lookahead).
try:
f = float(val)
except ValueError:
continue
# Heuristic: bare integers >= 100 are almost certainly param counts
# (1B/3B/8B/70B/235B…), not version numbers. Skip them.
if "." not in val and f >= 100:
continue
return f
return 0.0
SORT_KEYS = {
# Score sort with version-aware tiebreaker — when two rows tie on
# composite score (a common case for the SAME base model in different
# versions, e.g. MiniMax-M2.5 vs M2.7 both at the same FP8 budget),
# prefer the newer version. Without this, ties resolved to whatever
# order they came out of the registry, which let older releases land
# above newer ones in user-facing lists.
"score": lambda r: (r["score"], _version_key(r.get("name") or "")),
"speed": lambda r: r["speed_tps"],
"vram": lambda r: r["required_gb"],
"params": lambda r: r["params_b"],
"context": lambda r: r["context"],
# Newest first. release_date is an ISO-ish string ("2026-05-30"); plain
# string sort is chronological. Missing dates sort last (empty < any date,
# and we sort reverse=True for newest, so "" lands at the bottom).
"newest": lambda r: r.get("release_date") or "",
}
def rank_models(system, use_case=None, limit=50, search=None, sort="score", quant=None, target_context=None, fit_only=False):
"""Rank all models against detected hardware. Returns sorted list of fit results.
fit_only: when True, drop rows whose fit_level is "too_tight" (model doesn't
actually fit on the chosen budget). When False (default), every model is
shown — sorting by Param means highest-param PERIOD, even ones that won't
run, so the user can see the truth.
"""
models = get_models()
results = []
# Include image gen models only when explicitly filtered
if use_case == "image_gen":
try:
from services.hwfit.image_models import rank_image_models
except ImportError:
rank_image_models = None
if rank_image_models:
img_results = rank_image_models(system, search=search)
else:
img_results = []
for im in img_results:
fit_map = {"perfect": "perfect", "good": "good", "tight": "marginal", "no_fit": "too_tight", "no_gpu": "too_tight"}
results.append({
"name": im["id"],
"provider": im["provider"],
"parameter_count": f"{im['params_b']}B",
"params_b": im["params_b"],
"is_moe": False,
"use_case": "image_gen",
"fit_level": fit_map.get(im["fit"], "too_tight"),
"run_mode": "gpu" if im["fits"] else "no_fit",
"quant": im.get("quant", "BF16"),
"context": 0,
"context_length": 0,
"required_gb": round(im.get("vram_needed") or 0, 1),
"speed_tps": 0,
"score": float(im["score"]),
"scores": {"quality": float(im["quality"]), "speed": float(im["speed"]), "fit": 0, "context": 0},
"gguf_sources": [],
"is_image_gen": True,
"capabilities": im.get("capabilities", []),
"description": im.get("description", ""),
})
if use_case == "image_gen":
sort_fn = SORT_KEYS.get(sort, SORT_KEYS["score"])
results.sort(key=sort_fn, reverse=True) # see main path below
return results[:limit]
# If user picked a native prequantized format, filter to only those models.
filter_native = quant and any(quant.startswith(p) for p in (
"AWQ-", "GPTQ-", "FP8", "FP4", "NVFP4", "MXFP4", "NF4",
"INT4", "INT8", "W4A16", "W8A8", "W8A16",
))
system_backend = (system.get("backend") or "").lower()
apple_silicon = system_backend in ("mps", "metal", "apple")
rocm = system_backend == "rocm"
is_windows = system.get("platform") == "windows"
# Consumer AMD Radeon (RDNA, gfx10/11/12): the practical local serving path
# is GGUF via llama.cpp. vLLM/SGLang on ROCm are validated for datacenter
# Instinct (CDNA, gfx9xx) but are unreliable on consumer RDNA — AWQ kernels
# are largely unsupported there and FP8 needs out-of-tree patches. So treat
# consumer RDNA like Apple Silicon (GGUF-only) and leave CDNA untouched.
# Unknown family (no rocminfo) is left untouched to avoid hiding models from
# a possibly-capable Instinct box on a misdetect.
gpu_family = (system.get("gpu_family") or "").lower()
consumer_amd = system_backend == "rocm" and gpu_family == "rdna"
for m in models:
native_q = _native_quant(m)
# MLX needs the mlx_lm runtime, which Odysseus does not generate serve
# commands for. Hide it on every backend, including Metal.
if native_q.startswith("mlx-") or "mlx" in (m.get("name") or "").lower():
continue
# ROCm support for vLLM/SGLang quantized safetensors is too brittle to
# recommend blindly in the default scan. Keep AWQ/GPTQ/FP8 discoverable
# only when the user explicitly picks that format from the quant filter;
# otherwise prefer GGUF/Q* entries that Odysseus can route through
# llama.cpp/Ollama without pretending "fits VRAM" means "servable".
if rocm and is_prequantized(m) and not filter_native:
continue
# On Apple Silicon the only serving engines are llama.cpp and Ollama,
# both GGUF-only (vLLM/SGLang are CUDA/ROCm and don't run on macOS). So
# a model is Metal-servable ONLY if it ships a real GGUF. Drop everything
# else — raw safetensors repos (which the catalog still tags with a
# default GGUF quant) and vLLM-only AWQ/GPTQ/FP8 builds alike. Without
# this the Cookbook recommends models the Mac can't run; on CUDA these
# stay visible because vLLM serves safetensors directly.
#
# Consumer AMD (RDNA) is the same story: GGUF via llama.cpp is the
# servable path, so a model needs a real GGUF to be recommended.
# Otherwise the Cookbook rates vLLM-only AWQ/GPTQ builds "GOOD" on a
# Radeon that can't actually serve them.
#
# Windows is the same: Odysseus only supports llama.cpp on Windows,
# which requires GGUF. vLLM/SGLang are explicitly blocked, so AWQ/GPTQ
# models without a GGUF source are unservable there.
if (apple_silicon or consumer_amd or is_windows) and not (m.get("is_gguf") or m.get("gguf_sources")):
continue
# Format filter: AWQ tab -> only AWQ models, FP4 tab -> FP4-family models, etc.
if filter_native:
if quant == "FP8" and native_q != "FP8":
continue
if quant == "FP4" and native_q not in ("FP4", "NVFP4", "MXFP4", "NF4"):
continue
if quant.startswith("AWQ") and not native_q.startswith("AWQ"):
continue
if quant.startswith("GPTQ") and not native_q.startswith("GPTQ"):
continue
if quant.startswith("NVFP4") and not native_q.startswith("NVFP4"):
continue
if quant in ("INT4", "INT8", "W4A16", "W8A8", "W8A16") and native_q != quant:
continue
if search:
name = m.get("name", "").lower()
provider = m.get("provider", "").lower()
if search.lower() not in name and search.lower() not in provider:
continue
result = analyze_model(m, system, target_quant=quant, scoring_use_case=(use_case or "general"), target_context=target_context)
if result is None:
continue
if use_case:
model_uc = infer_use_case(m)
if use_case != model_uc and use_case != "general":
continue
results.append(result)
# Pick the visible SET by the REQUESTED column. Per-user feedback: sorting
# by Param should show the highest-param models PERIOD, not just those that
# already fit. Same for every other column. Models that don't fit are still
# in the list with their fit_level marking the constraint, so the user can
# see the truth instead of a quietly-truncated view. Score sort is unchanged
# (it's the default ranking and naturally pushes non-fits to the bottom).
if fit_only:
# Hide rows that definitely don't fit (the "too_tight" badge) — user
# explicitly asked for a Fit-only view.
results = [r for r in results if r.get("fit_level") != "too_tight"]
sort_fn = SORT_KEYS.get(sort, SORT_KEYS["score"])
# Always sort descending then truncate top-N so each column shows the
# global highest by that metric. Before, vram was special-cased
# ascending → truncate kept the 50 SMALLEST models and "highest VRAM"
# could never appear, breaking the column-click toggle.
results.sort(key=sort_fn, reverse=True)
results = results[:limit]
return results
+71
View File
@@ -0,0 +1,71 @@
"""
Extrahierte Mathematik aus dem Odysseus Projekt zur VRAM/RAM Berechnung.
Abgestimmt auf APUs mit Unified Memory (Bosgame M5 / Strix Halo).
"""
# Annahme: Bytes per Parameter für GGUF Quants
QUANT_BYTES_PER_PARAM = {
"Q2_K": 0.35,
"Q3_K_S": 0.38,
"Q3_K_M": 0.42,
"Q3_K_L": 0.45,
"Q4_0": 0.50,
"Q4_1": 0.55,
"Q4_K_S": 0.50,
"Q4_K_M": 0.55,
"Q5_0": 0.62,
"Q5_1": 0.68,
"Q5_K_S": 0.62,
"Q5_K_M": 0.65,
"Q6_K": 0.75,
"Q8_0": 1.00,
"F16": 2.00,
"BF16": 2.00,
}
def estimate_memory_gb(params_b: float, quant: str, ctx: int) -> float:
"""Berechnet den geschätzten Speicherbedarf in GB (Gewichte + Kontext)."""
# Wenn unbekanntes Format, nimm sicherheitshalber Q5_K_M (0.65)
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.65)
weights = params_b * bpp
# Heuristik für Context-RAM: 8k Context bei 7B Parametern frisst ca. 0.8 GB
context_vram = (ctx / 8192) * (max(params_b, 7) / 7) * 0.8
return weights + context_vram
def estimate_speed(req_gb: float, sys_ram_gb: float) -> float:
"""Berechnet die geschätzte Tokens/s basierend auf der 273 GB/s Bandbreite der APU."""
# Strix Halo hat ca 273 GB/s Unified Memory Bandbreite.
bw = 273 if sys_ram_gb > 8 else 70
if req_gb <= 0:
return 0.0
# (Bandbreite / Modellgröße) * Effizienz (0.55)
raw_tps = (bw / req_gb) * 0.55
return raw_tps
def evaluate_fit(params_b: float, quant: str, ctx: int, sys_ram_gb: float) -> dict:
"""Berechnet den Fit für ein System mit Shared Memory (APU)."""
req_gb = estimate_memory_gb(params_b, quant, ctx)
tps = estimate_speed(req_gb, sys_ram_gb)
# Das OS und andere Prozesse brauchen RAM. Wir lassen 4GB Puffer.
usable_ram = max(sys_ram_gb - 4.0, 0)
if req_gb > usable_ram:
fit_level = "too_tight"
text = "Zu groß (OOM)"
elif req_gb > usable_ram * 0.8:
fit_level = "marginal"
text = "Könnte knapp werden"
else:
fit_level = "perfect"
text = "Passt perfekt"
return {
"level": fit_level,
"text": text,
"req_gb": round(req_gb, 1),
"tps": round(tps, 0)
}
+100
View File
@@ -0,0 +1,100 @@
"""
Cookbook Router: Verbindet die HuggingFace API mit der Odysseus-Hardware-Berechnung.
"""
import httpx
import re
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
import psutil
from auth import auth
from hw_math import evaluate_fit
router = APIRouter(prefix="/api/cookbook", dependencies=[Depends(auth)])
class AnalyzeRequest(BaseModel):
repo_id: str
ctx: int = 8192
class EvaluateRequest(BaseModel):
params_b: float
quant: str
ctx: int
def extract_params_b(repo_id: str) -> float:
"""Extrahiert die Parametergröße (in Milliarden) aus dem Repo-Namen."""
# z.B. Qwen2.5-Coder-32B -> 32
# 8x7B -> 56 (MoE)
moe = re.search(r"(\d+)x(\d+(?:\.\d+)?)[bB]", repo_id)
if moe:
return float(moe.group(1)) * float(moe.group(2))
m = re.search(r"(\d+(?:\.\d+)?)[bB](?![a-zA-Z])", repo_id)
if m:
return float(m.group(1))
return 7.0 # Fallback
def extract_quant(filename: str) -> str:
m = re.search(r"(Q\d_[A-Z0-9_]+|IQ\d_[A-Z0-9_]+|FP16|BF16)", filename, re.IGNORECASE)
return m.group(1).upper() if m else "Q4_K_M"
@router.post("/analyze")
async def analyze_repo(req: AnalyzeRequest):
"""Holt die GGUF Dateien von HuggingFace und berechnet den Hardware-Fit."""
url = f"https://huggingface.co/api/models/{req.repo_id}/tree/main"
async with httpx.AsyncClient() as client:
try:
resp = await client.get(url, timeout=10.0)
resp.raise_for_status()
tree = resp.json()
except Exception as e:
raise HTTPException(status_code=500, detail=f"HuggingFace Fehler: {str(e)}")
gguf_files = [f["path"] for f in tree if f.get("path", "").endswith(".gguf")]
if not gguf_files:
return {"files": []}
params_b = extract_params_b(req.repo_id)
# Ermittle RAM des Systems (da APU = Shared Memory)
ram_gb = psutil.virtual_memory().total / (1024**3)
results = []
for f in gguf_files:
quant = extract_quant(f)
fit = evaluate_fit(params_b, quant, req.ctx, ram_gb)
# Priority-Score, um den besten Fit an oberste Stelle zu setzen.
# "Q4_K_M" ist oft der Sweetspot.
priority = 0
if fit["level"] == "perfect":
priority += 10
if quant == "Q4_K_M": priority += 5
elif quant.startswith("Q4"): priority += 4
elif quant.startswith("Q5"): priority += 3
results.append({
"filename": f,
"quant": quant,
"fit": fit,
"priority": priority
})
# Sortieren: Highest priority first, dann nach tps (schnellste zuerst)
results.sort(key=lambda x: (x["priority"], x["fit"]["tps"]), reverse=True)
return {
"repo": req.repo_id,
"params_b": params_b,
"sys_ram_gb": round(ram_gb, 1),
"files": results
}
@router.post("/evaluate")
def evaluate_single(req: EvaluateRequest):
ram_gb = psutil.virtual_memory().total / (1024**3)
fit = evaluate_fit(req.params_b, req.quant, req.ctx, ram_gb)
return fit
+5 -1
View File
@@ -65,7 +65,11 @@ async def stream_logs(websocket: WebSocket, service: str):
line = await process.stdout.readline()
if not line:
break
await websocket.send_text(line.decode("utf-8", errors="replace"))
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:
+29 -1
View File
@@ -43,6 +43,11 @@ class ChatReq(BaseModel):
message: str
class UpdateReq(BaseModel):
alias: str
ctx: int
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@@ -61,11 +66,13 @@ def status():
size_bytes = None
quant = ""
filename = ""
m_path = re.search(r'-(?:m|-model)\s+([^\s]+)', cmd)
if m_path:
path = m_path.group(1).replace("'", "").replace('"', '')
if os.path.exists(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)
if q_match:
quant = q_match.group(1).upper()
@@ -86,7 +93,8 @@ def status():
"ctx": ctx,
"size_bytes": size_bytes,
"quant": quant,
"caps": caps
"caps": caps,
"filename": filename
}
}
swap_ok = True
@@ -141,6 +149,26 @@ def register(req: RegisterReq):
"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")
def unload(model: str | None = None):
path = f"/api/models/unload/{model}" if model else "/api/models/unload"
+14 -1
View File
@@ -4,9 +4,10 @@ Greift lokal auf psutil und sysfs zu.
"""
from pathlib import Path
import asyncio
import psutil
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from auth import auth
router = APIRouter(prefix="/api/system", dependencies=[Depends(auth)])
@@ -89,3 +90,15 @@ def system_status():
},
"gpu_temp": temps["gpu"]
}
@router.websocket("/stream")
async def system_stream(websocket: WebSocket):
await websocket.accept()
try:
while True:
# Sende Live-Daten alle 500ms
data = system_status()
await websocket.send_json(data)
await asyncio.sleep(0.5)
except WebSocketDisconnect:
pass
+75 -52
View File
@@ -1,116 +1,139 @@
/* =========================================================================
base.css — Design-Tokens, Reset, App-Layout (Sidebar + Topbar + Content)
Optik orientiert an docs/mission-control-overview.png (GitHub-Dark-Familie).
v3: dichtes Control-Plane-Design, EINE Akzentfarbe (Teal), Klartext-tauglich.
Orientierung: docs/mission-control-overview.png
========================================================================= */
:root{
/* Flächen */
--bg:#0a0d12; --bg2:#0d1117; --panel:#10151c; --panel2:#151b23; --inset:#0a0e13;
--line:rgba(255,255,255,.07); --line2:rgba(255,255,255,.13);
--bg:#0b0e13; --bg2:#0d1117; --panel:#141a24; --tile:#11161e; --panel2:#11161e; --inset:#0a0e13;
--line:rgba(255,255,255,.08); --line2:rgba(255,255,255,.14);
/* Text */
--tx:#d7dee7; --mut:#8b97a5; --dim:#5c6773;
/* Akzente */
--on:#3fb950; --act:#4493e0; --purple:#a371f7; --warn:#e0a32e; --err:#e5534b;
--teal:#2dd4bf;
/* Tints (für getönte Karten) */
--tx:#e6edf3; --mut:#8b97a5; --dim:#5d6b79;
/* EINE Akzentfarbe für alles Klickbare/Aktive */
--accent:#2dd4bf; --accent-ink:#06231f; --teal:#2dd4bf;
/* Status-/Datenfarben (NUR für Bedeutung, nicht für Buttons) */
--on:#3fb950; --act:#4493e0; --purple:#a371f7; --warn:#e0a32e; --err:#f0573f;
/* Kompat-Aliase (von Phase-B-Panels referenziert) */
--hi:var(--accent); --red:var(--err); --red-dim:rgba(240,87,63,.12);
/* Tints (getönte Karten, Legacy) */
--t-green:rgba(63,185,80,.07); --b-green:rgba(63,185,80,.22);
--t-blue:rgba(68,147,224,.07); --b-blue:rgba(68,147,224,.22);
--t-purple:rgba(163,113,247,.08);--b-purple:rgba(163,113,247,.24);
--t-red:rgba(229,83,75,.08); --b-red:rgba(229,83,75,.24);
--t-red:rgba(240,87,63,.08); --b-red:rgba(240,87,63,.24);
/* Schrift */
--mono:ui-monospace,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
--sans:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
/* Typografie-Skala */
--text-xs:11.5px; --text-sm:13px; --text-base:14px; --text-lg:16px; --text-xl:20px; --text-2xl:24px;
/* Spacing-Skala */
--sp-1:4px; --sp-2:8px; --sp-3:12px; --sp-4:16px; --sp-5:20px; --sp-6:24px; --sp-8:32px; --sp-10:40px;
/* Maße */
--side:62px; --radius:14px;
--side:212px; --radius:12px; --radius-sm:8px;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
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;
}
a{color:var(--act);text-decoration:none}
::selection{background:rgba(68,147,224,.32)}
a{color:var(--accent);text-decoration:none}
::selection{background:rgba(45,212,191,.28)}
/* ---- App-Shell: feste Sidebar + scrollender Main ---- */
/* ---- App-Shell: feste, beschriftete Sidebar + scrollender Main ---- */
#app{display:flex;min-height:100vh}
.sidebar{
width:var(--side);flex:0 0 var(--side);
background:var(--bg2);border-right:1px solid var(--line);
display:flex;flex-direction:column;align-items:center;
padding:14px 0;gap:6px;position:sticky;top:0;height:100vh;
display:flex;flex-direction:column;
padding:var(--sp-4) var(--sp-3);gap:2px;position:sticky;top:0;height:100vh;
}
.side-logo{
width:34px;height:34px;border-radius:9px;margin-bottom:10px;
display:grid;place-items:center;color:var(--teal);
background:rgba(45,212,191,.10);border:1px solid rgba(45,212,191,.22);
.brand{display:flex;align-items:center;gap:10px;padding:4px 8px 16px}
.brand-logo{
width:30px;height:30px;border-radius:9px;flex:0 0 auto;
display:grid;place-items:center;color:var(--accent-ink);background:var(--accent);
}
.side-nav{display:flex;flex-direction:column;gap:4px;flex:1}
.side-foot{margin-top:auto}
.brand-logo svg{width:18px;height:18px}
.brand-tx{font-size:15px;font-weight:500;letter-spacing:.2px}
.side-nav{display:flex;flex-direction:column;gap:2px;flex:1}
.side-foot{margin-top:auto;padding-top:var(--sp-2);border-top:1px solid var(--line)}
.nav-cap{font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);padding:8px 10px 6px}
.nav-item{
width:40px;height:40px;border-radius:10px;display:grid;place-items:center;
color:var(--mut);cursor:pointer;border:1px solid transparent;transition:.15s;
display:flex;align-items:center;gap:11px;
padding:9px 11px;border-radius:10px;color:var(--mut);cursor:pointer;
border:1px solid transparent;transition:.13s;font-size:13.5px;user-select:none;
}
.nav-item:hover{color:var(--tx);background:var(--panel)}
.nav-item.active{
color:var(--teal);background:rgba(45,212,191,.12);border-color:rgba(45,212,191,.22);
}
.nav-item.active{color:var(--tx);background:rgba(45,212,191,.12);border-color:rgba(45,212,191,.20);font-weight:500}
.nav-item.active .ni-ic{color:var(--accent)}
.ni-ic{display:grid;place-items:center;color:inherit;flex:0 0 auto}
.ni-ic svg{width:18px;height:18px}
.nav-item.disabled{opacity:.34;cursor:not-allowed}
.nav-item.disabled:hover{background:transparent;color:var(--mut)}
.nav-item svg{width:20px;height:20px}
/* ---- Main-Spalte ---- */
.main{flex:1;min-width:0;display:flex;flex-direction:column}
.topbar{
position:sticky;top:0;z-index:20;
display:flex;align-items:center;gap:14px;flex-wrap:wrap;
padding:12px 26px;background:rgba(10,13,18,.86);backdrop-filter:blur(8px);
display:flex;align-items:center;gap:var(--sp-3);flex-wrap:wrap;
padding:var(--sp-3) var(--sp-6);background:rgba(11,14,19,.86);backdrop-filter:blur(8px);
border-bottom:1px solid var(--line);
}
.spacer{flex:1}
.status-pill{
display:inline-flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12.5px;
padding:6px 12px;border:1px solid var(--line);border-radius:999px;color:var(--mut);background:var(--panel);
display:inline-flex;align-items:center;gap:var(--sp-2);font-family:var(--mono);font-size:var(--text-xs);
padding:5px 12px;border:1px solid var(--line);border-radius:999px;color:var(--mut);background:var(--panel);
}
.dot{width:8px;height:8px;border-radius:50%;background:var(--mut);flex:0 0 auto}
.dot.on{background:var(--on);box-shadow:0 0 0 0 rgba(63,185,80,.5);animation:pulse 2.2s infinite}
.dot.off{background:var(--err)}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(63,185,80,.45)}70%{box-shadow:0 0 0 7px rgba(63,185,80,0)}100%{box-shadow:0 0 0 0 rgba(63,185,80,0)}}
.top-stat{font-size:12.5px;color:var(--mut)}
.top-stat b{color:var(--tx);font-family:var(--mono);font-weight:600;margin-left:4px}
.top-clock{font-family:var(--mono);font-size:12.5px;color:var(--act)}
.tokin{
font-family:var(--mono);font-size:12.5px;background:var(--panel);border:1px solid var(--line);
color:var(--tx);border-radius:8px;padding:7px 10px;width:128px;
.top-stat{font-size:var(--text-sm);color:var(--mut)}
.top-stat b{color:var(--tx);font-family:var(--mono);font-weight:500;margin-left:4px}
.top-clock{font-family:var(--mono);font-size:var(--text-sm);color:var(--accent)}
/* Security-Chip (zeigt LAN/Token-Status) */
.sec-chip{
display:inline-flex;align-items:center;gap:7px;font-size:var(--text-xs);
padding:5px 11px;border-radius:999px;border:1px solid var(--line);
background:var(--panel);color:var(--mut);
}
.tokin:focus{outline:none;border-color:var(--act)}
.sec-chip.ok{background:rgba(63,185,80,.12);border-color:rgba(63,185,80,.25);color:#7ee29a}
.sec-chip .ti, .sec-chip svg{width:14px;height:14px}
.icon-btn{color:var(--dim);cursor:pointer;display:grid;place-items:center;background:none;border:0;padding:6px}
.icon-btn:hover{color:var(--tx);border-color:transparent}
.icon-btn svg{width:18px;height:18px}
/* ---- Content-Bereich ---- */
.content{padding: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{display:flex;flex-direction:column;gap:18px}
.view{display:flex;flex-direction:column;gap:var(--sp-4)}
/* Raster-Helfer */
.grid{display:grid;gap:18px}
.grid-3{grid-template-columns:1fr 1fr 1fr}
.grid-2{grid-template-columns:1fr 1fr}
.grid{display:grid;gap:var(--sp-4)}
.grid-3{grid-template-columns:repeat(auto-fit, minmax(300px, 1fr))}
.grid-2{grid-template-columns:repeat(auto-fit, minmax(400px, 1fr))}
.kpis{grid-template-columns:repeat(5,1fr)}
.tiles{display:grid;gap:var(--sp-3);grid-template-columns:repeat(auto-fit, minmax(165px, 1fr))}
.split{display:grid;gap:var(--sp-4);grid-template-columns:1.05fr 1fr}
@media(max-width:1180px){.grid-3{grid-template-columns:1fr 1fr}.kpis{grid-template-columns:repeat(2,1fr)}}
@media(max-width:760px){.grid-3,.grid-2,.kpis{grid-template-columns:1fr}}
@media(max-width:900px){.split{grid-template-columns:1fr}}
@media(max-width:760px){.grid-3,.grid-2,.kpis{grid-template-columns:1fr}
.sidebar{width:60px;flex-basis:60px}
.ni-tx,.brand-tx{display:none}
.nav-item{justify-content:center}.brand{justify-content:center}}
/* Alert-Banner */
.alert{
margin:14px 26px 0;padding:14px 18px;border-radius:12px;
background:linear-gradient(90deg,rgba(229,83,75,.16),rgba(229,83,75,.04));
border:1px solid rgba(229,83,75,.32);color:#ffcdc8;
display:flex;align-items:center;gap:12px;font-size:13.5px;
margin:var(--sp-4) var(--sp-6) 0;padding:var(--sp-3) var(--sp-4);border-radius:var(--radius);
background:rgba(240,87,63,.10);border:1px solid rgba(240,87,63,.26);color:#ffcdc8;
display:flex;align-items:center;gap:var(--sp-3);font-size:var(--text-sm);
}
.alert .a-dot{width:8px;height:8px;border-radius:50%;background:var(--err);flex:0 0 auto}
.alert b{color:#ffe2de}
.alert.warn{background:linear-gradient(90deg,rgba(224,163,46,.15),rgba(224,163,46,.03));
border-color:rgba(224,163,46,.32);color:#f3dca6}
.alert.warn{background:rgba(224,163,46,.10);border-color:rgba(224,163,46,.26);color:#f3dca6}
.alert.warn .a-dot{background:var(--warn)}
+138 -70
View File
@@ -1,75 +1,81 @@
/* =========================================================================
components.css — wiederverwendbare Bausteine (Karten, KPIs, Listen, Forms)
components.css — wiederverwendbare Bausteine.
v3: Teal-Primary, Metrik-Kacheln, Fit-Ampel, Modal, Quickstart.
Legacy-Klassen bleiben erhalten, bis alle Panels migriert sind.
========================================================================= */
/* ---- Karte (Grundbaustein) ---- */
.card{
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 h3{font-size:15px;font-weight:600;margin:0;flex:1;color:var(--tx)}
.card-h{display:flex;align-items:center;gap:10px;margin:0 0 4px}
.card-h h3{font-size:15px;font-weight:500;margin:0;flex:1;color:var(--tx)}
.card-h .meta{font-family:var(--mono);font-size:12px;color:var(--mut)}
.card-h .meta.ok{color:var(--on)}
/* Klartext-Einzeiler unter einer Sektionsüberschrift */
.card-sub{font-size:var(--text-xs);color:var(--mut);margin:0 0 14px}
/* ---- Hero (Overview-Kopf) ---- */
.hero{
background:
radial-gradient(120% 140% at 100% 0%,rgba(68,147,224,.10),transparent 60%),
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}
/* ---- Seiten-Kopf (Hero, Klartext-Urteil) ---- */
.pagehead{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:2px}
.pagehead h1{font-size:var(--text-xl);font-weight:500;margin:0;letter-spacing:.2px}
.pagehead .sub{font-size:var(--text-sm);color:var(--mut);margin-top:4px}
/* ---- KPI-Kacheln ---- */
.kpi{
position:relative;border-radius:var(--radius);padding:18px 20px;
border:1px solid var(--line);background:var(--panel);overflow:hidden;
}
.kpi .k-h{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}
.kpi .k-t{font-size:13.5px;color:var(--mut)}
/* Legacy-Hero (Overview alt) — bleibt für nicht migrierte Nutzung */
.hero{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);
padding:var(--sp-4) var(--sp-5);display:flex;justify-content:space-between;gap:var(--sp-6);flex-wrap:wrap}
.hero .eyebrow{font-size:11.5px;letter-spacing:.18em;text-transform:uppercase;color:var(--mut)}
.hero h1{font-size:22px;font-weight:500;margin:8px 0 6px}
.hero p{margin:0;color:var(--mut);font-size:14px;max-width:64ch}
/* ---- Metrik-Kachel ---- */
.tile{background:var(--tile);border:1px solid var(--line);border-radius:var(--radius);padding:14px 15px}
.tile .t-l{font-size:12px;color:var(--mut)}
.tile .t-v{font-family:var(--mono);font-size:20px;margin-top:6px;color:var(--tx);line-height:1.1}
.tile .t-v small{font-size:13px;color:var(--mut)}
.tile .t-s{font-size:11.5px;color:var(--mut);margin-top:3px}
.tile .t-v.ok{color:var(--on)} .tile .t-v.warn{color:var(--warn)} .tile .t-v.bad{color:var(--err)}
.tile .t-s.ok{color:#7ee29a}
/* ---- Chip (Status/Kontext) ---- */
.chip{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--mut);
background:var(--tile);border:1px solid var(--line);border-radius:999px;padding:6px 12px;white-space:nowrap}
.chip svg{width:14px;height:14px}
/* ---- KPI-Kacheln (Legacy, Activity-Panel) ---- */
.kpi{position:relative;border-radius:var(--radius);padding:var(--sp-3) var(--sp-4);
border:1px solid var(--line);background:var(--panel);overflow:hidden}
.kpi .k-h{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--sp-2)}
.kpi .k-t{font-size:var(--text-sm);color:var(--mut)}
.kpi .k-ic{color:var(--mut);opacity:.85}
.kpi .k-ic svg{width:20px;height:20px}
.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 small{font-size:15px;color:var(--mut);font-weight:400}
.kpi .k-s{font-size:12px;color:var(--mut)}
/* Farb-Varianten */
.kpi.green {background:linear-gradient(160deg,var(--t-green),transparent 70%);border-color:var(--b-green)}
.kpi.green .k-v,.kpi.green .k-ic{color:var(--on)}
.kpi.blue {background:linear-gradient(160deg,var(--t-blue),transparent 70%);border-color:var(--b-blue)}
.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 .k-ic svg{width:18px;height:18px}
.kpi .k-v{font-family:var(--mono);font-size:var(--text-2xl);font-weight:500;line-height:1.1;margin:var(--sp-3) 0 var(--sp-1);color:var(--tx)}
.kpi .k-v small{font-size:var(--text-base);color:var(--mut);font-weight:400}
.kpi .k-s{font-size:var(--text-xs);color:var(--mut)}
.kpi.green{border-top:2px solid var(--on)} .kpi.green .k-v,.kpi.green .k-ic{color:var(--on)}
.kpi.blue{border-top:2px solid var(--act)} .kpi.blue .k-v,.kpi.blue .k-ic{color:var(--act)}
.kpi.purple{border-top:2px solid var(--purple)} .kpi.purple .k-v,.kpi.purple .k-ic{color:var(--purple)}
.kpi.red{border-top:2px solid var(--err)} .kpi.red .k-v,.kpi.red .k-ic{color:var(--err)}
.kpi.muted .k-v{color:var(--dim)}
/* ---- Key-Value-Liste (Health-Signale) ---- */
/* ---- Key-Value-Liste + Balken (System-Gesundheit) ---- */
.kv{display:flex;flex-direction:column}
.kv-row{display:flex;align-items:center;justify-content:space-between;gap:12px;
padding:11px 2px;border-top:1px solid var(--line)}
.kv-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 2px;border-top:1px solid var(--line)}
.kv-row:first-child{border-top:0}
.kv-row .kv-k{color:var(--mut);font-size:13.5px}
.kv-row .kv-v{font-family:var(--mono);font-size:13.5px;color:var(--tx)}
.kv-v.ok{color:var(--on)} .kv-v.bad{color:var(--err)} .kv-v.na{color:var(--dim)}
.bar{height:4px;border-radius:3px;background:var(--panel2);margin-top:8px;overflow:hidden}
.bar > i{display:block;height:100%;background:var(--on);border-radius:3px;transition:width .4s}
.bar.blue > i{background:var(--act)} .bar.warn > i{background:var(--warn)}
.bar{height:6px;border-radius:4px;background:var(--inset);margin-top:8px;overflow:hidden}
.bar > i{display:block;height:100%;background:var(--accent);border-radius:4px;transition:width .4s}
.bar.blue > i{background:var(--act)} .bar.warn > i{background:var(--warn)} .bar.bad > i{background:var(--err)}
/* benannte Mess-Zeile mit Label + Wert + Balken */
.meter{margin-top:14px} .meter:first-child{margin-top:0}
.meter-h{display:flex;justify-content:space-between;font-size:12.5px}
.meter-h .mk{color:#aeb9c4} .meter-h .mv{font-family:var(--mono);color:var(--mut)}
/* ---- Listen-Items (Modelle / "Session Router") ---- */
/* ---- Listen-Items (Modelle / Stack) ---- */
.list{display:flex;flex-direction:column}
.li{display:flex;align-items:center;gap:12px;padding:12px 4px;border-top:1px solid var(--line)}
.li{display:flex;align-items:center;gap:12px;padding:11px 4px;border-top:1px solid var(--line)}
.li:first-child{border-top:0}
.li .li-dot{width:9px;height:9px;border-radius:50%;background:var(--dim);flex:0 0 auto}
.li .li-dot.on{background:var(--on)} .li .li-dot.load{background:var(--warn)}
@@ -80,10 +86,23 @@
.li .li-meta{font-family:var(--mono);font-size:12px;color:var(--mut)}
.li .li-time{font-family:var(--mono);font-size:11.5px;color:var(--dim);margin-top:2px}
/* ---- Quickstart (geführte Aktionen) ---- */
.qa{display:flex;align-items:center;gap:11px;padding:11px 0;border-top:1px solid var(--line);
width:100%;background:none;text-align:left;cursor:pointer;color:var(--tx);font-family:var(--sans)}
.qa:first-of-type{border-top:0}
.qa:hover{border-color:var(--line)} .qa:hover .qa-t{color:var(--accent)}
.qa-ic{width:32px;height:32px;border-radius:9px;display:grid;place-items:center;flex:0 0 auto}
.qa-ic svg{width:17px;height:17px}
.qa-ic.teal{background:rgba(45,212,191,.13);color:var(--accent)}
.qa-ic.amber{background:rgba(224,163,46,.13);color:var(--warn)}
.qa-ic.blue{background:rgba(68,147,224,.13);color:var(--act)}
.qa-main{flex:1;min-width:0}
.qa-t{font-size:13.5px} .qa-s{font-size:11.5px;color:var(--mut);margin-top:1px}
.qa-arrow{color:var(--dim)} .qa-arrow svg{width:16px;height:16px}
/* ---- Tabelle ---- */
table{width:100%;border-collapse:collapse;font-size:14px}
th{text-align:left;font-weight:500;color:var(--mut);font-size:11.5px;text-transform:uppercase;
letter-spacing:.06em;padding:0 10px 10px}
th{text-align:left;font-weight:400;color:var(--mut);font-size:11.5px;text-transform:uppercase;letter-spacing:.06em;padding:0 10px 10px}
td{padding:12px 10px;border-top:1px solid var(--line)}
.mid{font-family:var(--mono);font-size:13px;color:#e8eef5}
.port{font-family:var(--mono);color:var(--mut);font-size:13px}
@@ -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-idle{background:rgba(139,151,165,.14);color:var(--mut)}
.b-load{background:rgba(224,163,46,.16);color:var(--warn)}
.b-err{background:rgba(229,83,75,.16);color:var(--err)}
.b-err{background:rgba(240,87,63,.16);color:var(--err)}
.b-ok{background:rgba(63,185,80,.14);color:var(--on)}
/* Capability-Tags */
.tag{font-size:11px;border-radius:6px;padding:2px 8px}
.tag.code{color:#86b9ff;background:rgba(68,147,224,.13)}
.tag.text{color:#9aa7b4;background:rgba(139,151,165,.13)}
.tag.img{color:#c9a6f5;background:rgba(163,113,247,.14)}
/* ---- Fit-Ampel (Cookbook) ---- */
.fit-badge{font-size:11.5px;border-radius:7px;padding:3px 9px;white-space:nowrap}
.fit-badge.ok{background:rgba(63,185,80,.14);color:#7ee29a}
.fit-badge.warn{background:rgba(224,163,46,.15);color:#f0c570}
.fit-badge.bad{background:rgba(240,87,63,.14);color:#f3a08f}
.legend{display:flex;gap:14px;flex-wrap:wrap;font-size:11.5px;color:var(--mut)}
.legend span{display:flex;align-items:center;gap:6px}
.legend i{width:8px;height:8px;border-radius:50%;display:inline-block}
/* ---- Empty-States ---- */
.empty{color:var(--mut);font-size:13.5px;padding:14px 4px}
.empty-c{display:flex;flex-direction:column;align-items:center;justify-content:center;
text-align:center;padding:44px 16px;color:var(--mut)}
.empty-c .e-t{font-size:14px}
.empty-c .e-s{font-size:12.5px;color:var(--dim);margin-top:6px}
.empty-c{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:44px 16px;color:var(--mut)}
.empty-c .e-t{font-size:14px} .empty-c .e-s{font-size:12.5px;color:var(--dim);margin-top:6px}
/* ---- Forms ---- */
label{display:block;font-size:12px;color:var(--mut);margin:0 0 5px}
input,textarea,select{width:100%;background:var(--panel2);border:1px solid var(--line);color:var(--tx);
border-radius:8px;padding:9px 11px;font-family:var(--mono);font-size:13px;margin-bottom:12px}
input:focus,textarea:focus,select:focus{outline:none;border-color:var(--act)}
border-radius:var(--radius-sm);padding:9px 11px;font-family:var(--mono);font-size:13px;margin-bottom:12px}
input:focus,textarea:focus,select:focus{outline:none;border-color:var(--accent)}
textarea{resize:vertical;min-height:64px;font-family:var(--sans)}
.row{display:flex;gap:10px}.row>div{flex:1}
.hint{font-size:12px;color:var(--mut);margin:-4px 0 12px}
@@ -115,28 +146,65 @@ textarea{resize:vertical;min-height:64px;font-family:var(--sans)}
/* ---- Buttons ---- */
button{font-family:var(--sans);font-size:13.5px;font-weight:500;border:1px solid var(--line2);
background:var(--panel2);color:var(--tx);border-radius:8px;padding:9px 15px;cursor:pointer;transition:.15s}
button:hover{border-color:var(--act)}
button.primary{background:var(--act);border-color:var(--act);color:#fff}
button.primary:hover{filter:brightness(1.08)}
background:var(--panel2);color:var(--tx);border-radius:var(--radius-sm);padding:9px 15px;cursor:pointer;transition:.15s}
button:hover{border-color:var(--accent)}
button.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-ink)}
button.primary:hover{filter:brightness(1.06)}
button.danger{background:var(--err);border-color:var(--err);color:#fff}
button.danger:hover{filter:brightness(1.08)}
button.ghost{padding:6px 11px;font-size:12.5px}
button.danger:hover{filter:brightness(1.06)}
button.warn{background:var(--warn);border-color:var(--warn);color:#3a2a05}
button.ghost{padding:6px 11px;font-size:12.5px;background:none}
button:disabled{opacity:.5;cursor:not-allowed}
.btn-row{display:flex;gap:10px;flex-wrap:wrap}
/* ---- Reply / Log ---- */
.reply{margin-top:12px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;
/* ---- Reply / Log / Konsole ---- */
.reply{margin-top:12px;background:var(--panel2);border:1px solid var(--line);border-radius:var(--radius-sm);
padding:12px;white-space:pre-wrap;font-size:14px;min-height:20px;color:var(--tx)}
.log{font-family:var(--mono);font-size:12px;line-height:1.65;background:var(--inset);border:1px solid var(--line);
border-radius:8px;padding:12px;max-height:240px;overflow:auto;white-space:pre-wrap;color:#aeb9c4}
border-radius:var(--radius-sm);padding:12px;max-height:240px;overflow:auto;white-space:pre-wrap;color:#aeb9c4}
.console{background:var(--inset);border:1px solid var(--line);border-radius:var(--radius-sm);
font-family:var(--mono);font-size:12px;line-height:1.6;color:#9fe6c4;padding:13px;height:400px;overflow:auto;white-space:pre-wrap}
.job{border:1px solid var(--line);border-radius:10px;margin-bottom:8px;overflow:hidden;background:var(--bg2)}
.job-h{display:flex;align-items:center;gap:10px;padding:11px 13px;cursor:pointer}
.job-h .mid{flex:1}
/* ---- Modal ---- */
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.62);z-index:100;display:flex;align-items:center;justify-content:center;padding:20px}
.modal-card{background:var(--panel);border:1px solid var(--line2);border-radius:var(--radius);
width:100%;max-width:440px;padding:var(--sp-5) var(--sp-5);position:relative}
.modal-card h3{margin:0 0 8px;font-size:17px;font-weight:500}
.modal-card p{margin:0 0 18px;color:var(--mut);font-size:13.5px;line-height:1.55}
.modal-actions{display:flex;gap:10px;justify-content:flex-end}
/* ---- Toast ---- */
.toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:var(--panel2);
border:1px solid var(--line2);border-radius:10px;padding:11px 16px;font-size:13.5px;
opacity:0;transition:.25s;pointer-events:none;max-width:90vw;z-index:50}
opacity:0;transition:.25s;pointer-events:none;max-width:90vw;z-index:120}
.toast.show{opacity:1}
.toast.err{border-color:var(--err);color:#ffb4ae}
/* ---- Accordion (Guides) ---- */
.guide-acc{border-bottom:1px solid var(--line)}
.guide-acc:last-child{border-bottom:none}
.guide-acc summary{padding:16px 20px;font-weight: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)}
+37 -23
View File
@@ -10,19 +10,19 @@
<body>
<div id="app">
<!-- Sidebar: Bereichs-Navigation. Platzhalter-Items sind die kommenden Roadmap-Bereiche. -->
<!-- Sidebar: beschriftete Bereichs-Navigation -->
<aside class="sidebar">
<div class="side-logo" id="logo"></div>
<div class="brand"><span class="brand-logo" id="logo"></span><span class="brand-tx">Mission Control</span></div>
<nav class="side-nav">
<a class="nav-item" data-view="overview" title="Übersicht" data-ic="grid"></a>
<a class="nav-item" data-view="models" title="Modelle" data-ic="cpu"></a>
<a class="nav-item" data-view="activity" title="Aktivität" data-ic="pulse"></a>
<a class="nav-item" data-view="server" title="Server" data-ic="server"></a>
<a class="nav-item" data-view="cookbook" title="Cookbook" data-ic="book"></a>
<a class="nav-item" data-view="guides" title="Guides" data-ic="help"></a>
<a class="nav-item" data-view="overview"><span class="ni-ic" data-ic="grid"></span><span class="ni-tx">Übersicht</span></a>
<a class="nav-item" data-view="models"><span class="ni-ic" data-ic="cpu"></span><span class="ni-tx">Modelle</span></a>
<a class="nav-item" data-view="activity"><span class="ni-ic" data-ic="pulse"></span><span class="ni-tx">Aktivität</span></a>
<a class="nav-item" data-view="server"><span class="ni-ic" data-ic="server"></span><span class="ni-tx">Server</span></a>
<a class="nav-item" data-view="cookbook"><span class="ni-ic" data-ic="book"></span><span class="ni-tx">Cookbook</span></a>
<a class="nav-item" data-view="guides"><span class="ni-ic" data-ic="help"></span><span class="ni-tx">Guides</span></a>
</nav>
<div class="side-foot">
<span class="nav-item 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>
</aside>
@@ -31,10 +31,8 @@
<header class="topbar">
<span class="status-pill"><span id="swdot" class="dot"></span><span id="swlabel">verbinde…</span></span>
<span class="spacer"></span>
<span class="top-stat">Modelle<b id="top-models"></b></span>
<span class="top-stat">Jobs<b id="top-jobs">0</b></span>
<span class="top-clock" id="clock">--:--</span>
<input id="token" class="tokin" placeholder="Token" autocomplete="off">
<span class="top-stat" id="top-active-text">Kein Modell geladen</span>
<span class="sec-chip" id="sec-chip"><span data-ic="shield"></span><span id="sec-chip-tx">Nur im Heimnetz</span></span>
</header>
<!-- Alert-Banner (wird per JS ein-/ausgeblendet) -->
@@ -44,15 +42,19 @@
<main class="content">
<section class="view" data-view="overview">
<div id="hero"></div>
<div class="grid kpis" id="kpis"></div>
<div class="grid grid-2">
<div class="card" id="health"></div>
<div class="card" id="ov-models"></div>
<div id="ov-hero"></div>
<div class="tiles" id="ov-tiles"></div>
<div class="split">
<div class="card" id="ov-health"></div>
<div class="card" id="ov-quickstart"></div>
</div>
<div class="card" id="ov-stack"></div>
</section>
<section class="view" data-view="activity" hidden>
<div 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>
@@ -61,9 +63,8 @@
</section>
<section class="view" data-view="models" hidden>
<div class="grid grid-2" id="m-top-grid">
<div class="card" id="m-chat" style="grid-column: span 2;"></div>
</div>
<div id="m-head"></div>
<div class="card" id="m-chat"></div>
<div class="card" id="m-table"></div>
</section>
@@ -79,13 +80,26 @@
</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>
<!-- Icons in die Nav/Logo einsetzen, bevor das Haupt-Modul laedt -->
<!-- Icons in Nav/Logo/Chip einsetzen, bevor das Haupt-Modul laedt -->
<script type="module">
import { ICON } from "/static/js/core/ui.js";
document.getElementById("logo").innerHTML = ICON.logo;
document.querySelectorAll(".nav-item[data-ic]").forEach(n => (n.innerHTML = ICON[n.dataset.ic] || ""));
document.querySelectorAll("[data-ic]").forEach(n => (n.innerHTML = ICON[n.dataset.ic] || ""));
</script>
<script type="module" src="/static/js/main.js"></script>
</body>
+83 -1
View File
@@ -22,7 +22,7 @@ export function toast(msg, err = false) {
export function badge(state) {
if (state === "running" || state === "ready") return '<span class="badge b-run">geladen</span>';
if (state === "loading" || state === "starting") return '<span class="badge b-load">lädt…</span>';
return '<span class="badge b-idle">bereit</span>';
return '<span class="badge b-run">bereit</span>';
}
// 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"/>'),
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"/>'),
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] || ""; }
// ---------------------------------------------------------------------------
// 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();
});
}
+46 -14
View File
@@ -24,22 +24,28 @@ function applyStatus(s) {
if (!s) {
dot.className = "dot off";
label.textContent = "Backend nicht erreichbar";
$("#top-models").textContent = "";
showAlert("Backend nicht erreichbar läuft uvicorn?", false);
$("#top-active-text").textContent = "Backend offline";
showAlert("Backend nicht erreichbar läuft uvicorn?", false);
} else {
const host = s.swap_url.replace(/^https?:\/\//, "");
dot.className = "dot " + (s.swap_ok ? "on" : "off");
label.textContent = (s.swap_ok ? "llama-swap online · " : "llama-swap offline · ") + host;
$("#top-models").textContent = (s.models || []).length;
label.textContent = (s.swap_ok ? "LLM-Engine: Online " : "LLM-Engine: Offline ") + host;
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();
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);
}
function applyJobs(jobs) {
lastJobs = jobs || [];
$("#top-jobs").textContent = lastJobs.filter(j => j.state === "running" || j.state === "queued").length;
for (const p of panels) p.onJobs?.(lastJobs);
}
@@ -66,19 +72,45 @@ async function pollJobs() {
catch { /* still */ }
}
async function pollSystem() {
try { applySystem(await api("/api/system/status")); }
catch { /* still */ }
function connectSystemStream() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
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 ----
function bootToken() {
const i = $("#token");
i.value = getToken();
i.addEventListener("change", e => { setToken(e.target.value); pollStatus(); });
if (i) {
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() {
$("#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?.();
@@ -89,8 +121,8 @@ document.addEventListener("mc:refresh", pollStatus);
pollStatus();
pollJobs();
pollSystem();
connectSystemStream();
setInterval(tickClock, 1000);
setInterval(pollStatus, 3000);
setInterval(pollJobs, 1500);
setInterval(pollSystem, 3000);
+248 -203
View File
@@ -1,225 +1,270 @@
import { api } from "../core/api.js";
import { $ } from "../core/ui.js";
// 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.
const COOKBOOK_MODELS = [
{
id: "qwen-coder-32b",
name: "Qwen 2.5 Coder 32B",
repo: "unsloth/Qwen2.5-Coder-32B-Instruct-GGUF",
file: "Qwen2.5-Coder-32B-Instruct-Q4_K_M.gguf",
desc: "Top-Tier lokales Coder-Modell. Braucht viel Speicher.",
params_b: 32,
quant: "Q4_K_M",
ctx: 32768,
alias: "coder"
},
{
id: "qwen-coder-7b",
name: "Qwen 2.5 Coder 7B",
repo: "unsloth/Qwen2.5-Coder-7B-Instruct-GGUF",
file: "Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf",
desc: "Schneller Coder. Perfekte Balance aus Speed und Qualität.",
params_b: 7,
quant: "Q4_K_M",
ctx: 32768,
alias: "coder-fast"
},
{
id: "llama3-vision-11b",
name: "Llama 3.2 Vision 11B",
repo: "unsloth/Llama-3.2-11B-Vision-Instruct-GGUF",
file: "Llama-3.2-11B-Vision-Instruct-Q4_K_M.gguf",
desc: "Modell für Bilderkennung und multimodale Tasks.",
params_b: 11,
quant: "Q4_K_M",
ctx: 8192,
alias: "vision"
},
{
id: "qwen-general-7b",
name: "Qwen 2.5 7B",
repo: "unsloth/Qwen2.5-7B-Instruct-GGUF",
file: "Qwen2.5-7B-Instruct-Q4_K_M.gguf",
desc: "Hervorragendes Generalist/Scout Modell.",
params_b: 7,
quant: "Q4_K_M",
ctx: 8192,
alias: "scout"
}
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" },
];
// Odysseus Hardware Fit Logic
function estimateMemoryGB(params_b, quant, ctx) {
// Q4_K_M is roughly 0.6 bytes per param
const bpp = 0.6;
const weights = params_b * bpp;
// Context roughly: 1GB per 8K for 7B
const context = (ctx / 8192) * (params_b / 7) * 0.8;
return weights + context;
}
function getFit(m, sys) {
const req = estimateMemoryGB(m.params_b, m.quant, m.ctx);
// Falls keine sys-Daten da sind (Backend mock), nimm Standardwerte an
const vram_bytes = sys?.gpu?.vram?.total || 0;
const vram = vram_bytes / (1024 ** 3);
const ram_bytes = sys?.ram?.total || 0;
const ram_used = sys?.ram?.used || 0;
const ram = (ram_bytes) / (1024 ** 3);
const freeRam = (ram_bytes - ram_used) / (1024 ** 3);
// Wenn gar keine echten Metriken kommen (Windows Dummy Backend), immer "Fits" für Demo
if (vram === 0 && ram === 0) return { level: "perfect", class: "green", text: "Fits (Mock)", req };
if (vram > 0 && req <= vram) return { level: "perfect", class: "green", text: "Fits VRAM", req };
if (req <= (vram + freeRam)) return { level: "good", class: "yellow", text: "RAM Offload", req };
return { level: "too_tight", class: "red", text: "OOM (Zu groß)", req };
}
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 dlPoll = null;
let currentResults = [];
let currentAnalysis = null;
let activeFilter = "";
const fitCls = lvl => lvl === "perfect" ? "ok" : lvl === "marginal" ? "warn" : "bad";
function mount() {
render();
}
function unmount() {
if (dlPoll) clearInterval(dlPoll);
}
function onSystem(sys) {
lastSys = sys;
renderGrid();
}
export default { mount, unmount, onSystem };
function render() {
const c = document.querySelector(".view[data-view='cookbook']");
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 class="card" style="margin-top: 24px;">
<div class="card-h"><h3>Custom Download</h3></div>
<div style="display:flex; gap:12px; margin-bottom:12px;">
<input id="cb-repo" class="tokin" placeholder="HuggingFace Repo (z.B. unsloth/Qwen2.5-Coder-7B-Instruct-GGUF)" style="flex:2">
<input id="cb-file" class="tokin" placeholder="Dateipfad (z.B. Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf)" style="flex:1">
<button class="btn" id="cb-btn-dl">Herunterladen</button>
</div>
<div id="cb-dl-prog" style="display:none; color:var(--text-dim); font-size:12px; margin-bottom:12px;"></div>
<div id="cb-register-box" style="display:none; padding-top:12px; border-top:1px solid var(--border);">
<div style="margin-bottom:8px;">Modell heruntergeladen! Jetzt einpflegen:</div>
<div style="display:flex; gap:12px;">
<input id="cb-reg-alias" class="tokin" placeholder="Alias (z.B. coder)" style="flex:1">
<input id="cb-reg-path" class="tokin" placeholder="Modell-Pfad" style="flex:2" readonly>
<input id="cb-reg-ctx" class="tokin" placeholder="Ctx (z.B. 8192)" type="number" style="width:100px;">
<button class="btn" id="cb-btn-reg">Einpflegen</button>
<div id="cb-modal" class="modal-overlay" style="display:none">
<div class="modal-card" style="max-width:560px">
<button id="cb-modal-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
<h3 id="cb-m-title">Modell</h3>
<p class="mono-sm" id="cb-m-repo" style="margin:-4px 0 16px">repo/name</p>
<label>Quantisierung (GGUF-Datei) wählen</label>
<select id="cb-m-files"></select>
<div class="hint" id="cb-m-loading" style="display:none">Lade Dateien von HuggingFace…</div>
<div class="row" style="margin-top:4px">
<div><label>Alias (Rolle)</label><input id="cb-m-alias" placeholder="z.B. coder"></div>
<div><label>Kontext-Größe</label><input id="cb-m-ctx" type="number" value="8192"></div>
</div>
<div id="cb-m-fit" class="tile" style="display:flex;justify-content:space-between;align-items:center;margin:8px 0 18px">
<div><div style="font-size:13px">Ressourcen-Check</div>
<div class="hint" id="cb-m-fit-text" style="margin:4px 0 0">Berechne…</div></div>
<span id="cb-m-fit-badge"></span>
</div>
<button class="primary" id="cb-m-download" style="width:100%">Herunterladen &amp; Einpflegen</button>
</div>
</div>
`;
</div>`;
document.getElementById("cb-btn-dl").onclick = () => {
startDownload(
document.getElementById("cb-repo").value,
document.getElementById("cb-file").value,
"", 8192
);
};
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);
document.getElementById("cb-btn-reg").onclick = async () => {
const alias = document.getElementById("cb-reg-alias").value;
const path = document.getElementById("cb-reg-path").value;
const ctx = document.getElementById("cb-reg-ctx").value;
if (!alias || !path) return;
try {
await api("/api/models/register", {
method: "POST",
body: JSON.stringify({ alias, model_path: path, ctx_len: parseInt(ctx)||4096 })
});
alert("Erfolgreich eingepflegt!");
document.getElementById("cb-register-box").style.display = "none";
} catch(e) {
alert("Fehler: " + e);
}
};
renderGrid();
renderHwChip();
renderCurated();
}
function renderGrid() {
const grid = document.getElementById("cb-grid");
if (!grid) return;
grid.innerHTML = COOKBOOK_MODELS.map(m => {
const fit = getFit(m, lastSys);
return `
<div class="card" style="display:flex; flex-direction:column;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<h3 style="margin:0">${m.name}</h3>
<span style="font-size:11px; padding:2px 6px; border-radius:4px; background:${fit.class === 'green' ? 'rgba(0,255,0,0.1)' : fit.class === 'yellow' ? 'rgba(255,255,0,0.1)' : 'rgba(255,0,0,0.1)'}; color:${fit.class === 'green' ? '#4ade80' : fit.class === 'yellow' ? '#fde047' : '#f87171'};">
${fit.text}
</span>
</div>
<div style="font-size:12px; color:var(--text-dim); margin-top:8px; flex:1;">
${m.desc}<br><br>
<b>Größe:</b> ~${fit.req.toFixed(1)} GB (inkl. Context)<br>
<b>Quant:</b> ${m.quant}
</div>
<button class="btn cb-btn-preset" data-id="${m.id}" style="margin-top:16px; width:100%;">Herunterladen</button>
</div>
`;
}).join("");
document.querySelectorAll(".cb-btn-preset").forEach(b => {
b.onclick = () => {
const m = COOKBOOK_MODELS.find(x => x.id === b.dataset.id);
if(m) {
document.getElementById("cb-repo").value = m.repo;
document.getElementById("cb-file").value = m.file;
startDownload(m.repo, m.file, m.alias, m.ctx);
}
};
});
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(); }));
}
async function startDownload(repo, file, autoAlias, autoCtx) {
if(!repo || !file) return;
document.getElementById("cb-dl-prog").style.display = "block";
document.getElementById("cb-dl-prog").innerText = "Starte Download...";
document.getElementById("cb-register-box").style.display = "none";
try {
const res = await api("/api/models/download", {
method: "POST",
body: JSON.stringify({ repo_id: repo, filename: file })
});
if (res.job_id) {
if(dlPoll) clearInterval(dlPoll);
dlPoll = setInterval(() => checkProg(res.job_id, autoAlias, autoCtx), 2000);
}
} catch(e) {
document.getElementById("cb-dl-prog").innerText = "Fehler: " + e;
function renderHwChip() {
const el = $("#cb-hw"); if (!el) return;
if (lastSys?.ram?.total) {
el.innerHTML = `${icon("cpu")}${Math.round(lastSys.ram.total / 1024 ** 3)} GB Speicher`;
}
}
async function checkProg(jobId, autoAlias, autoCtx) {
try {
const res = await api("/api/jobs");
const j = res.find(x => x.id === jobId);
if (!j) {
clearInterval(dlPoll);
document.getElementById("cb-dl-prog").innerText = "Job verschwunden.";
return;
}
document.getElementById("cb-dl-prog").innerText = j.status + (j.error ? (" - " + j.error) : "");
if (j.status === "done") {
clearInterval(dlPoll);
document.getElementById("cb-register-box").style.display = "block";
document.getElementById("cb-reg-path").value = j.result || "";
if(autoAlias) document.getElementById("cb-reg-alias").value = autoAlias;
if(autoCtx) document.getElementById("cb-reg-ctx").value = autoCtx;
} else if (j.status === "failed") {
clearInterval(dlPoll);
}
} catch(e) {}
// ---- 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 };
+70 -65
View File
@@ -1,84 +1,89 @@
// guides.js — Integrations-Anleitungen (v3): Copy-Paste-Configs für externe Tools.
import { $ } from "../core/ui.js";
let currentUrl = "http://localhost:8000";
// Basis-URL aus dem Browser ableiten (zeigt die echte Bosgame-Adresse statt localhost).
function apiBase() {
const host = location.hostname || "localhost";
return `${location.protocol}//${host}:8080/v1`;
}
function render() {
const c = document.querySelector(".view[data-view='guides']");
const c = $(".view[data-view='guides']");
if (!c) return;
const url = apiBase();
c.innerHTML = `
<div class="grid grid-2">
<div class="card">
<div class="card-h"><h3>Cline / Cursor</h3></div>
<p>Nutze llama-swap als lokalen OpenAI-kompatiblen Provider in Cursor oder Cline.</p>
<label>API Provider</label>
<input class="tokin mono-sm" readonly value="OpenAI Compatible">
<label>Base URL</label>
<input class="tokin mono-sm" readonly value="${currentUrl}/v1">
<label>API Key</label>
<input class="tokin mono-sm" readonly value="Dein_Mission_Control_Token_oder_leer">
</div>
<div class="pagehead"><div>
<h1>Guides &amp; Integrationen</h1>
<div class="sub">Binde deine lokalen Modelle in andere Tools ein — fertige Configs zum Kopieren.</div></div></div>
<div class="card">
<div class="card-h"><h3>OpenWebUI</h3></div>
<p>Füge llama-swap in OpenWebUI als externe OpenAI-Verbindung hinzu.</p>
<label>Settings -> Connections -> OpenAI API</label>
<input class="tokin mono-sm" readonly value="${currentUrl}/v1">
<label>API Key</label>
<input class="tokin mono-sm" readonly value="dummy-key">
<p style="margin-top:12px; font-size:12px; color:var(--text-dim);">
OpenWebUI zieht sich danach automatisch alle geladenen Modelle.
</p>
</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>
<div class="card" style="grid-column: span 2;">
<div class="card-h"><h3>Python / LangChain (OpenAI SDK)</h3></div>
<p>Nutze das offizielle <code>openai</code> Package, um mit llama-swap zu sprechen.</p>
<pre style="background: var(--bg); padding: 12px; border-radius: 8px; border: 1px solid var(--border); overflow-x: auto; color: var(--text); font-family: monospace; font-size: 13px;"><code>from openai import OpenAI
<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="${currentUrl}/v1",
api_key="Dein_Token" # Optional, falls in Mission Control konfiguriert
base_url="${url}",
api_key="dein_token" # optional
)
response = client.chat.completions.create(
model="coder", # Alias aus deinem Cookbook
resp = client.chat.completions.create(
model="coder", # Alias aus dem Cookbook
messages=[{"role": "user", "content": "Hallo Modell!"}]
)
print(resp.choices[0].message.content)</code></div>
</div>
</details>
print(response.choices[0].message.content)</code></pre>
</div>
<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>
<div class="card" style="grid-column: span 2;">
<div class="card-h"><h3>n8n / Zapier / HTTP Node</h3></div>
<p>Führe direkte HTTP POST Requests gegen den Endpunkt aus.</p>
<label>Methode & URL</label>
<input class="tokin mono-sm" readonly value="POST ${currentUrl}/v1/chat/completions">
<label>Headers</label>
<input class="tokin mono-sm" readonly value="Content-Type: application/json">
<label>Body (JSON)</label>
<pre style="background: var(--bg); padding: 12px; border-radius: 8px; border: 1px solid var(--border); overflow-x: auto; color: var(--text); font-family: monospace; font-size: 13px;"><code>{
"model": "coder",
"messages": [
{ "role": "user", "content": "Erkläre mir Quantisierung." }
]
}</code></pre>
</div>
</div>
`;
<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();
}
function mount() { render(); }
function onStatus(s) {
if (s && s.swap_url) {
if (currentUrl !== s.swap_url) {
currentUrl = s.swap_url;
render(); // Neu rendern, wenn sich die URL ändert
}
}
}
export default { id: "guides", mount, onStatus };
export default { id: "guides", mount };
+77 -42
View File
@@ -1,67 +1,102 @@
// jobs.js — Aktivitaets-Stream ("Incident Stream"): Hintergrund-Jobs mit Live-Log.
// Exportiert track(id), damit andere Panels einen frisch gestarteten Job auto-aufklappen.
// jobs.js — Aktivität (v3): Live-System-Metriken + Hintergrund-Jobs mit Log.
// 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();
let JOBS = [];
let SYS = null;
export function track(id) {
tracked.add(id);
render();
export function track(id) { tracked.add(id); renderJobs(); }
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) {
if (state === "done") return '<span class="badge b-run">fertig</span>';
if (state === "failed") return '<span class="badge b-err">fehler</span>';
return '<span class="badge b-load">läuft…</span>';
function gpuPct() {
const g = SYS?.gpu;
if (g && (g.vram.total + g.gtt.total) > 0) return ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100;
return 0;
}
function dotClass(state) {
if (state === "done") return "on";
if (state === "failed") return "";
return "load";
function renderSys() {
if (!SYS) return;
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() {
$("#v-activity").innerHTML = `
<div class="card-h"><h3>Aktivität</h3><span class="meta" id="job-count"></span></div>
<div id="jobs"></div>
<div id="jobs-empty" class="empty-c">
<div class="e-t">Noch nichts losgemacht.</div>
<div class="e-s">Downloads, Updates &amp; Co. erscheinen hier mit Live-Log.</div>
</div>`;
$("#act-head").innerHTML = `<div class="pagehead"><div>
<h1>Aktivität</h1>
<div class="sub">Live-Auslastung und laufende Aufgaben (Downloads, Updates) mit Protokoll.</div></div></div>`;
// Klicks auf Job-Kopf -> auf/zuklappen (Event-Delegation)
$("#jobs").addEventListener("click", e => {
const h = e.target.closest(".job-h");
if (!h) return;
$("#v-activity").innerHTML = `
<div class="card-h"><h3>Hintergrund-Aufgaben</h3><span class="meta" id="job-count"></span></div>
<div class="card-sub">Downloads &amp; Updates erscheinen hier mit Live-Protokoll — zum Aufklappen klicken.</div>
<div id="jobs"></div>
<div id="jobs-empty" class="empty-c"><div class="e-t">Gerade nichts los.</div><div class="e-s">Alles ruhig — keine laufenden Aufgaben.</div></div>`;
$("#v-activity").addEventListener("click", e => {
const h = e.target.closest(".job-h"); if (!h) return;
const id = h.getAttribute("data-id");
tracked.has(id) ? tracked.delete(id) : tracked.add(id);
render();
renderJobs();
});
if (SYS) renderSys();
}
function render() {
const c = $("#jobs");
if (!c) return;
function renderJobs() {
const c = $("#jobs"); if (!c) return;
$("#jobs-empty").style.display = JOBS.length ? "none" : "flex";
const failed = JOBS.filter(j => j.state === "failed").length;
$("#job-count").textContent = JOBS.length ? (failed ? failed + " Fehler" : JOBS.length + " gesamt") : "";
c.innerHTML = JOBS.map(j => {
const open = tracked.has(j.id);
const log = open ? `<div class="log">${esc((j.log || []).join("\n"))}</div>` : "";
return `<div class="job">
<div class="job-h" data-id="${esc(j.id)}">
<span class="li-dot ${dotClass(j.state)}"></span>
<span class="mid">${esc(j.label)}</span>${statusBadge(j.state)}
</div>${log}</div>`;
const log = tracked.has(j.id) ? `<div class="log">${esc((j.log || []).join("\n"))}</div>` : "";
return `<div class="job"><div class="job-h" data-id="${esc(j.id)}">
<span class="li-dot ${dotClass(j.state)}"></span><span class="mid">${esc(j.label)}</span>${statusBadge(j.state)}</div>${log}</div>`;
}).join("");
}
function onJobs(jobs) {
JOBS = jobs || [];
render();
}
function onJobs(jobs) { JOBS = jobs || []; renderJobs(); }
function onSystem(sys) { SYS = sys; if ($("#act-sys")) renderSys(); }
export default { id: "jobs", mount, onJobs };
export default { id: "jobs", mount, onJobs, onSystem };
+86 -61
View File
@@ -1,87 +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 { $, badge, esc, toast } from "../core/ui.js";
import { $, badge, esc, toast, confirmModal } from "../core/ui.js";
let ALL = [];
function refreshSoon() { document.dispatchEvent(new Event("mc:refresh")); }
function capTags(caps) {
if (!caps?.length) return "";
return caps.map(c =>
c === "Code" ? `<span class="tag code">Code</span>`
: c === "Bild" ? `<span class="tag img">Bild</span>`
: `<span class="tag text">Text</span>`).join(" ");
}
function details(meta) {
if (!meta) return "";
const q = meta.quant || "?";
const c = meta.ctx ? Math.round(meta.ctx / 1024) + "K" : "?";
const s = meta.size_bytes ? (meta.size_bytes / 1024 ** 3).toFixed(1) + " GB" : "?";
return `<span class="mono-sm">${esc(q)} · ${c} · ${s}</span>`;
}
function mount() {
$("#m-head").innerHTML = `<div class="pagehead"><div>
<h1>Modelle</h1>
<div class="sub">Deine konfigurierten Modelle — testen, Kontext anpassen oder aus dem Speicher werfen.</div></div></div>`;
$("#m-chat").innerHTML = `
<div class="card-h"><h3>Schnelltest</h3></div>
<div class="card-sub">Schreib eine Nachricht — das gewählte Modell wird automatisch geweckt.</div>
<label>Modell</label>
<select id="chat-model"></select>
<label>Nachricht</label>
<textarea id="chat-msg" placeholder="Schreib was, um ein Modell zu wecken…"></textarea>
<textarea id="chat-msg" placeholder="z.B. „Erklär mir kurz, was du kannst."></textarea>
<button class="primary" id="chat-btn">Senden</button>
<div id="chat-reply" class="reply" style="display:none"></div>`;
$("#m-table").innerHTML = `
<div class="card-h"><h3>Modelle &amp; Ports</h3><span class="meta" id="m-count"></span></div>
<div class="card-sub">Modelle laden automatisch, sobald eine Anfrage kommt — du musst nichts manuell starten.</div>
<table>
<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>
</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>`;
$("#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) {
const models = s?.models || [];
const tb = $("#models");
if (!tb) return;
tb.innerHTML = "";
$("#models-empty").style.display = models.length ? "none" : "block";
$("#m-count").textContent = models.length ? models.length + " konfiguriert" : "";
ALL = s?.models || [];
const tb = $("#models"); if (!tb) return;
$("#models-empty").style.display = ALL.length ? "none" : "flex";
$("#m-count").textContent = ALL.length ? ALL.length + " konfiguriert" : "";
const sel = $("#chat-model");
const cur = sel.value;
sel.innerHTML = "";
for (const m of models) {
const tr = document.createElement("tr");
let capsHtml = "";
if (m.meta && m.meta.caps) {
capsHtml = m.meta.caps.map(c => {
if (c === "Text") return `<span class="meta" title="Text">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>
const sel = $("#chat-model"), cur = sel.value; sel.innerHTML = "";
tb.innerHTML = ALL.map(m => {
const fn = m.meta?.filename ? `<div class="li-sub mono-sm">${esc(m.meta.filename)}</div>` : "";
return `<tr>
<td class="mid" style="font-weight:500">${esc(m.name)}${fn}</td>
<td>${capTags(m.meta?.caps)}</td>
<td>${details(m.meta)}</td>
<td>${badge(m.state)}</td>
<td class="port">${m.port ?? "auto"}</td>
<td style="text-align:right"><button class="ghost" data-unload="${esc(m.name)}">Entladen</button></td>`;
tb.appendChild(tr);
sel.insertAdjacentHTML("beforeend", `<option>${esc(m.name)}</option>`);
}
<td style="text-align:right;white-space:nowrap">
<button class="ghost" data-cfg="${esc(m.name)}">Konfigurieren</button>
<button class="ghost" data-unload="${esc(m.name)}">Entladen</button>
</td></tr>`;
}).join("");
for (const m of ALL) sel.insertAdjacentHTML("beforeend", `<option>${esc(m.name)}</option>`);
if (cur) sel.value = cur;
tb.querySelectorAll("[data-unload]").forEach(b =>
b.addEventListener("click", () => unloadOne(b.getAttribute("data-unload")))
);
tb.querySelectorAll("[data-unload]").forEach(b => b.addEventListener("click", () => unloadOne(b.getAttribute("data-unload"))));
tb.querySelectorAll("[data-cfg]").forEach(b => b.addEventListener("click", () => openConfig(b.getAttribute("data-cfg"))));
}
function openConfig(alias) {
const m = ALL.find(x => x.name === alias); if (!m) return;
$("#cfg-model-name").textContent = m.name;
$("#cfg-ctx").value = m.meta?.ctx || 8192;
$("#cfg-modal").style.display = "flex";
}
async function saveConfig() {
const alias = $("#cfg-model-name").textContent, ctx = parseInt($("#cfg-ctx").value) || 8192;
$("#cfg-save").disabled = true;
try { await api("/api/update_model", { method: "POST", body: JSON.stringify({ alias, ctx }) }); toast("Gespeichert — aktiv beim nächsten Modell-Start."); $("#cfg-modal").style.display = "none"; refreshSoon(); }
catch (e) { toast(e.message, true); }
$("#cfg-save").disabled = false;
}
async function unloadOne(m) {
try {
await api("/api/unload?model=" + encodeURIComponent(m), { method: "POST" });
toast("Entladen: " + m);
setTimeout(refreshSoon, 600);
} catch (e) { toast(e.message, true); }
if (!await confirmModal({ title: `${m}" entladen?`, body: "Das Modell wird aus dem Grafikspeicher geworfen und lädt beim nächsten Aufruf automatisch neu.", confirmLabel: "Entladen" })) return;
try { await api("/api/unload?model=" + encodeURIComponent(m), { method: "POST" }); toast("Entladen: " + m); setTimeout(refreshSoon, 600); }
catch (e) { toast(e.message, true); }
}
async function sendChat() {
@@ -89,14 +118,10 @@ async function sendChat() {
if (!model) return toast("Kein Modell vorhanden.", true);
if (!message) return;
const btn = $("#chat-btn"); btn.disabled = true; btn.textContent = "…";
const box = $("#chat-reply"); box.style.display = "block";
box.textContent = "(wecke Modell, kann beim Swap kurz dauern…)";
try {
const r = await api("/api/chat", { method: "POST", body: JSON.stringify({ model, message }) });
box.textContent = r.reply;
} catch (e) { box.textContent = "Fehler: " + e.message; }
btn.disabled = false; btn.textContent = "Senden";
refreshSoon();
const box = $("#chat-reply"); box.style.display = "block"; box.textContent = "(wecke Modell, kann beim Laden kurz dauern…)";
try { const r = await api("/api/chat", { method: "POST", body: JSON.stringify({ model, message }) }); box.textContent = r.reply; }
catch (e) { box.textContent = "Fehler: " + e.message; }
btn.disabled = false; btn.textContent = "Senden"; refreshSoon();
}
export default { id: "models", mount, onStatus };
+125 -124
View File
@@ -1,142 +1,143 @@
// overview.js — Dashboard-Kopf: Hero + Mini-Stats, KPI-Reihe, Stack-Gesundheit,
// kompakte Modell-Liste ("Session Router"). Speist sich aus /api/status + /api/jobs.
// overview.js — Dashboard (v3): Klartext-Urteil, Metrik-Kacheln, System-Gesundheit,
// geführter Schnellstart, "Dein Stack". Speist sich aus /api/status + /api/system/stream.
import { $, 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 J = []; // letzte Job-Liste
let SYS = null; // letzte System-Auslastung
let S = null; // letzter Status
let SYS = null; // letzte System-Metriken
const RUNNING = new Set(["running", "ready", "loading", "starting"]);
function counts() {
const models = S?.models || [];
return {
total: models.length,
running: models.filter(m => RUNNING.has(m.state)).length,
jobsRun: J.filter(j => j.state === "running" || j.state === "queued").length,
jobsErr: J.filter(j => j.state === "failed").length,
swap: !!S?.swap_ok,
};
}
function models() { return S?.models || []; }
function activeModel() { return models().find(m => RUNNING.has(m.state)); }
function mini(label, val, tone = "") {
const v = tone ? `<b style="${tone === "bad" ? "color:var(--err)" : ""}">${val}</b>` : val;
return `<div class="mini"><div class="l">${label}</div><div class="v">${v}</div></div>`;
// ---- Aktionen (geführt) ----
function go(view) { document.querySelector(`.nav-item[data-view="${view}"]`)?.click(); }
async function freeMemory() {
const ok = await confirmModal({
title: "Speicher freigeben?",
body: "Alle aktuell geladenen Modelle werden aus dem Grafikspeicher geworfen. " +
"Beim nächsten Aufruf lädt das jeweilige Modell einfach neu — es geht nichts verloren.",
confirmLabel: "Speicher freigeben",
});
if (!ok) return;
try { await api("/api/unload", { method: "POST" }); toast("Speicher freigegeben."); setTimeout(() => document.dispatchEvent(new Event("mc:refresh")), 600); }
catch (e) { toast(e.message, true); }
}
window.mcOv = { go, freeMemory };
// ---- Hero (menschliches Urteil) ----
function renderHero() {
const c = counts();
$("#hero").innerHTML = `<div class="hero">
<div>
<div class="eyebrow">Übersicht</div>
<h1>Mission Control</h1>
<p>Steuerzentrale für deinen lokalen llama-swap-Stack — Modelle, Downloads,
Wartung und Schnelltest an einem Ort.</p>
</div>
<div class="hero-stats">
${mini("Modelle", c.total)}
${mini("Aktiv", c.running, "on")}
${mini("Jobs", c.jobsRun)}
${mini("Fehler", c.jobsErr, c.jobsErr ? "bad" : "")}
</div>
</div>`;
}
function kpi(cls, title, ic, value, sub) {
return `<div class="kpi ${cls}">
<div class="k-h"><span class="k-t">${title}</span><span class="k-ic">${icon(ic)}</span></div>
<div class="k-v">${value}</div>
<div class="k-s">${sub}</div>
</div>`;
}
function renderKpis() {
const c = counts();
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}`);
let title = "Verbinde…", sub = "";
if (S) {
const total = models().length, act = activeModel();
if (!S.swap_ok) { title = "LLM-Engine offline"; sub = "Der llama-swap-Dienst antwortet gerade nicht."; }
else if (SYS && SYS.ram.percent >= 90) { title = "Achtung: Speicher wird knapp."; sub = `Arbeitsspeicher bei ${Math.round(SYS.ram.percent)} % — eventuell Speicher freigeben.`; }
else {
title = "Alles läuft rund.";
sub = `${total} ${total === 1 ? "Modell" : "Modelle"} bereit · ${act ? `${act.name}" geladen` : "keins geladen"} · keine Warnungen.`;
}
}
$("#health").innerHTML = `
<div class="card-h"><h3>Stack-Gesundheit</h3>
<span class="meta ${c.swap ? "ok" : ""}">${c.swap ? "Connected" : "Offline"}</span></div>
<div class="kv">
${kvRow("llama-swap", c.swap ? "Connected" : "Offline", c.swap ? "ok" : "bad")}
${kvRow("Modelle (gesamt)", c.total)}
${kvRow("Aktiv", c.running, c.running ? "ok" : "")}
${kvRow("Jobs (laufend)", c.jobsRun)}
${kvRow("Fehler", c.jobsErr, c.jobsErr ? "bad" : "")}
${sysRow}
</div>`;
$("#ov-hero").innerHTML = `<div class="pagehead"><div>
<h1>${esc(title)}</h1><div class="sub">${esc(sub)}</div></div></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 dot = m.state === "loading" || m.state === "starting" ? "load" : on ? "on" : "";
const state = on ? (m.state === "loading" ? "lädt…" : "geladen") : "bereit";
let caps = "";
if (m.meta && m.meta.caps) {
caps = m.meta.caps.map(c => {
if (c === "Code") return `<span title="Code" style="color:var(--blue);font-size:0.9em;margin-left:6px">{ }</span>`;
if (c === "Bild") return `<span title="Vision" style="color:var(--purple);font-size:0.9em;margin-left:6px">👁</span>`;
return "";
}).join("");
}
return `<div class="li">
<span class="li-dot ${dot}"></span>
<div class="li-main">
<div class="li-id" style="font-weight:500">${esc(m.name)}${caps}</div>
<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>`;
const status = on ? (m.state === "loading" ? "lädt…" : `geladen${m.port ? " · Port " + m.port : ""}`) : "bereit";
return `<div class="li"><span class="li-dot ${dot}"></span>
<span class="li-id" style="flex:1">${esc(m.name)}</span>
${capTag(m.meta?.caps)}
<span class="li-meta" style="width:120px;text-align:right">${status}</span></div>`;
}
function renderStack() {
const ms = models();
$("#ov-stack").innerHTML =
`<div class="card-h"><h3>Dein Stack</h3><span class="meta">${ms.length ? ms.length + " konfiguriert" : ""}</span></div>` +
(ms.length
? `<div class="list">${ms.map(stackRow).join("")}</div>`
: `<div class="empty-c"><div class="e-t">Noch keine Modelle</div>
<div class="e-s">Hol dir unter „Cookbook" ein Modell, das auf deine Hardware passt.</div></div>`);
}
function renderModels() {
const models = S?.models || [];
$("#ov-models").innerHTML = `
<div class="card-h"><h3>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(); renderTiles(); renderHealth(); renderQuickstart(); renderStack(); }
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(); }
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 };
export default { id: "overview", mount, onStatus, onSystem };
+77 -94
View File
@@ -1,122 +1,105 @@
// server.js — Server & Wartung (v3): Dienste, OS-Updates, Reboot, Live-Konsole.
// Heikle Aktionen mit Klartext-Bestätigung (confirmModal/promptModal).
import { api, getToken } from "../core/api.js";
import { $, toast } from "../core/ui.js";
import { $, toast, icon, confirmModal, promptModal } from "../core/ui.js";
import { track } from "./jobs.js";
function refreshSoon() { document.dispatchEvent(new Event("mc:refresh")); }
function mount() {
$("#wartung").innerHTML = `
<div class="card-h"><h3>Dienste & Applikation</h3></div>
<div class="btn-row" style="margin-bottom:20px">
<button id="w-restart-swap">llama-swap neustarten</button>
<button id="w-restart-mc">Mission Control neustarten</button>
</div>
<div class="btn-row">
<button id="w-update">Container aktualisieren</button>
<button id="w-unload" class="ghost">Modelle entladen</button>
</div>
<div class="hint" style="margin-top:12px; margin-bottom:32px">
Dienste starten via passwortlosem Sudo neu.
$("#wartung").outerHTML = `<div id="wartung" style="display:flex;flex-direction:column;gap:var(--sp-4)">
<div class="pagehead"><div>
<h1>Server &amp; Wartung</h1>
<div class="sub">Dienste steuern, Updates einspielen und live mitlesen — ohne SSH/Terminal.</div>
</div></div>
<div class="card">
<div class="card-h"><h3>Dienste &amp; Applikation</h3></div>
<div class="card-sub">Neustarts sind harmlos und passwortlos — nichts geht dabei verloren.</div>
<div class="btn-row">
<button id="w-restart-swap">LLM-Engine neustarten</button>
<button id="w-restart-mc">Dashboard neustarten</button>
<button id="w-update">Nach Updates suchen</button>
<button id="w-unload" class="ghost">Grafikspeicher leeren</button>
</div>
</div>
<div class="card-h"><h3>Betriebssystem (Bosgame)</h3></div>
<div class="btn-row">
<button id="w-os-update">OS-Updates installieren (apt update)</button>
<button id="w-reboot" class="danger">Server Reboot</button>
<div class="card">
<div class="card-h"><h3>Betriebssystem (Bosgame)</h3></div>
<div class="card-sub">Tiefe Eingriffe — das Dashboard fragt einmalig nach deinem sudo-Passwort.</div>
<div class="btn-row">
<button id="w-os-update">OS-Updates installieren</button>
<button id="w-reboot" class="danger">Server neustarten (Reboot)</button>
</div>
</div>
<div class="hint" style="margin-top:12px; margin-bottom:32px">
Für tiefe Eingriffe fragt das Dashboard einmalig das sudo-Passwort ab.
</div>
<div class="card-h">
<h3>Live-Konsole</h3>
<select id="w-console-sel" style="margin-left:auto; width:200px">
<option value="llama-swap">llama-swap</option>
<option value="mission-control">mission-control</option>
</select>
</div>
<div id="w-console" style="background:#111; color:#0f0; font-family:monospace; font-size:12px; padding:12px; border-radius:8px; height:400px; overflow-y:auto; white-space:pre-wrap;">
Verbinde...
</div>`;
$("#w-restart-swap").addEventListener("click", () => restartService("llama-swap"));
$("#w-restart-mc").addEventListener("click", () => restartService("mission-control"));
<div class="card">
<div class="card-h"><h3>Live-Konsole</h3>
<select id="w-console-sel" style="margin:0 0 0 auto;width:220px">
<option value="llama-swap">LLM-Engine (llama-swap)</option>
<option value="mission-control">Dashboard (mission-control)</option>
</select>
</div>
<div class="card-sub">Was der Dienst gerade tut — live mitlesen.</div>
<div id="w-console" class="console">Verbinde…</div>
</div>
</div>`;
$("#w-restart-swap").addEventListener("click", () => restartService("llama-swap",
"LLM-Engine neustarten?", "Die Engine startet neu. Geladene Modelle werden kurz entladen (~5 Sekunden), laden danach automatisch wieder."));
$("#w-restart-mc").addEventListener("click", () => restartService("mission-control",
"Dashboard neustarten?", "Diese Oberfläche trennt sich kurz und verbindet automatisch wieder. Laufende Downloads laufen weiter."));
$("#w-update").addEventListener("click", update);
$("#w-unload").addEventListener("click", unloadAll);
$("#w-os-update").addEventListener("click", osUpdate);
$("#w-reboot").addEventListener("click", rebootServer);
$("#w-console-sel").addEventListener("change", () => connectConsole());
$("#w-console-sel").addEventListener("change", connectConsole);
connectConsole();
}
let ws = null;
function connectConsole() {
if (ws) {
ws.close();
ws = null;
}
const svc = $("#w-console-sel").value;
const out = $("#w-console");
out.innerHTML = "Verbinde mit " + svc + "...\n";
if (ws) { ws.close(); ws = null; }
const svc = $("#w-console-sel").value, out = $("#w-console");
out.textContent = "Verbinde mit " + svc + "…\n";
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const url = `${proto}//${location.host}/api/logs/${svc}?token=${getToken()}`;
ws = new WebSocket(url);
ws.onmessage = (e) => {
out.innerHTML += e.data;
out.scrollTop = out.scrollHeight;
};
ws.onclose = () => {
out.innerHTML += "\n--- Verbindung getrennt ---";
};
ws = new WebSocket(`${proto}//${location.host}/api/logs/${svc}?token=${encodeURIComponent(getToken())}`);
ws.onmessage = e => { out.textContent += e.data; out.scrollTop = out.scrollHeight; };
ws.onclose = () => { out.textContent += "\n— Verbindung getrennt —"; };
}
async function restartService(name) {
try {
await api("/api/service/" + name + "/restart", { method: "POST" });
toast("Neustart ausgelöst: " + name);
setTimeout(refreshSoon, 2000);
} catch (e) { toast(e.message, true); }
}
async function osUpdate() {
const pwd = window.prompt("Bitte sudo Passwort eingeben (für apt-get update & upgrade):");
if (!pwd) return;
try {
const r = await api("/api/os-update", { method: "POST", body: JSON.stringify({ password: pwd }) });
toast("OS-Update gestartet.");
track(r.job_id);
} catch (e) { toast(e.message, true); }
}
async function rebootServer() {
if (!window.confirm("ACHTUNG: Server wird komplett neu gestartet. Fortfahren?")) return;
const pwd = window.prompt("Bitte sudo Passwort eingeben (für reboot):");
if (!pwd) return;
try {
await api("/api/reboot", { method: "POST", body: JSON.stringify({ password: pwd }) });
toast("Reboot ausgelöst. UI ist gleich offline.");
} catch (e) { toast(e.message, true); }
}
async function update() {
try {
const r = await api("/api/update", { method: "POST" });
toast("Update läuft.");
track(r.job_id);
} catch (e) { toast(e.message, true); }
async function restartService(name, title, body) {
if (!await confirmModal({ title, body, confirmLabel: "Neustarten" })) return;
try { await api(`/api/service/${name}/restart`, { method: "POST" }); toast("Neustart ausgelöst: " + name); setTimeout(refreshSoon, 2000); }
catch (e) { toast(e.message, true); }
}
async function unloadAll() {
try {
await api("/api/unload", { method: "POST" });
toast("Alle Modelle entladen.");
setTimeout(refreshSoon, 600);
} catch (e) { toast(e.message, true); }
if (!await confirmModal({ title: "Grafikspeicher leeren?", body: "Alle geladenen Modelle werden entladen. Sie laden beim nächsten Aufruf automatisch neu — es geht nichts verloren.", confirmLabel: "Leeren" })) return;
try { await api("/api/unload", { method: "POST" }); toast("Grafikspeicher geleert."); setTimeout(refreshSoon, 600); }
catch (e) { toast(e.message, true); }
}
async function update() {
try { const r = await api("/api/update", { method: "POST" }); toast("Update läuft — siehe Aktivität."); track(r.job_id); }
catch (e) { toast(e.message, true); }
}
async function osUpdate() {
if (!await confirmModal({ title: "OS-Updates installieren?", body: "Führt <code>apt update &amp; upgrade</code> aus. Das kann ein paar Minuten dauern; der Fortschritt erscheint in der Aktivität." })) return;
const pwd = await promptModal({ title: "sudo-Passwort", body: "Für die System-Updates wird einmalig dein sudo-Passwort gebraucht.", placeholder: "sudo-Passwort", password: true, confirmLabel: "Installieren" });
if (!pwd) return;
try { const r = await api("/api/os-update", { method: "POST", body: JSON.stringify({ password: pwd }) }); toast("OS-Update gestartet."); track(r.job_id); }
catch (e) { toast(e.message, true); }
}
async function rebootServer() {
if (!await confirmModal({ title: "Server wirklich neu starten?", body: "Der ganze Bosgame startet physisch neu. Alles ist für ~1 Minute offline — auch dieses Dashboard.", confirmLabel: "Reboot", danger: true })) return;
const pwd = await promptModal({ title: "sudo-Passwort", body: "Für den Reboot wird einmalig dein sudo-Passwort gebraucht.", placeholder: "sudo-Passwort", password: true, confirmLabel: "Jetzt neustarten", danger: true });
if (!pwd) return;
try { await api("/api/reboot", { method: "POST", body: JSON.stringify({ password: pwd }) }); toast("Reboot ausgelöst — bis gleich."); }
catch (e) { toast(e.message, true); }
}
export default { id: "server", mount };
+21
View File
@@ -0,0 +1,21 @@
import paramiko
host = '192.168.178.153'
user = 'hitonabi'
password = 'Tu77ceu2zzvx!'
print("Connecting to server...")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(host, username=user, password=password, look_for_keys=False, allow_agent=False)
cmd = f"echo {password} | sudo -S bash /home/hitonabi/mission-control/deploy_bosgame.sh"
print(f"Executing deployment script on server...")
stdin, stdout, stderr = ssh.exec_command(cmd)
exit_status = stdout.channel.recv_exit_status()
print("STDOUT:", stdout.read().decode())
print("STDERR:", stderr.read().decode())
ssh.close()
print("Deployment complete!")
BIN
View File
Binary file not shown.