From 364939466fbab96086761bbc844c5f43d325ecd6 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sat, 20 Jun 2026 20:46:45 +0200 Subject: [PATCH] =?UTF-8?q?Mission=20Control=20v2=20=E2=80=93=20Schritt=20?= =?UTF-8?q?1:=20SoC-Refactor=20+=20Design=202.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architektur auf Separation of Concerns umgestellt – ohne Build-Schritt, ohne neues Framework, ohne DB (KISS bleibt). Endpoint-URLs unveraendert, daher 1:1-kompatibel zum bisherigen Stand. Backend (Top-Level-Helfer + ein Router je Bereich): - app.py auf duennen Einstieg reduziert (FastAPI + include_router + static) - config/auth/jobengine/llamaswap als getrennte Helfer-Module - Endpoints in routers/{models,jobs,maintenance}.py Frontend (native ES-Module statt Single-File): - index.html = Huelle: Sidebar-Nav, Topbar, Alert-Banner, Hash-Routing - css/{base,components}.css – Tokens + Komponenten - js/core/{api,ui,nav}.js + js/panels/{overview,models,maintenance,jobs}.js + main.js - Panel-Vertrag: { id, mount?(), onStatus?(s), onJobs?(jobs) } - Optik an docs/mission-control-overview.png angelehnt (Hero, KPI-Kacheln, Listen, Aktivitaets-Stream, getoente Karten) Doku: CLAUDE.md + README auf die neue Struktur aktualisiert. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + CLAUDE.md | 23 ++- README.md | 2 +- app.py | 273 +++--------------------------- auth.py | 15 ++ config.py | 34 ++++ jobengine.py | 58 +++++++ llamaswap.py | 34 ++++ routers/__init__.py | 0 routers/jobs.py | 24 +++ routers/maintenance.py | 25 +++ routers/models.py | 133 +++++++++++++++ static/css/base.css | 116 +++++++++++++ static/css/components.css | 142 ++++++++++++++++ static/index.html | 285 +++++++------------------------- static/js/core/api.js | 24 +++ static/js/core/nav.js | 28 ++++ static/js/core/ui.js | 59 +++++++ static/js/main.js | 81 +++++++++ static/js/panels/jobs.js | 67 ++++++++ static/js/panels/maintenance.js | 40 +++++ static/js/panels/models.js | 123 ++++++++++++++ static/js/panels/overview.js | 117 +++++++++++++ 23 files changed, 1218 insertions(+), 486 deletions(-) create mode 100644 auth.py create mode 100644 config.py create mode 100644 jobengine.py create mode 100644 llamaswap.py create mode 100644 routers/__init__.py create mode 100644 routers/jobs.py create mode 100644 routers/maintenance.py create mode 100644 routers/models.py create mode 100644 static/css/base.css create mode 100644 static/css/components.css create mode 100644 static/js/core/api.js create mode 100644 static/js/core/nav.js create mode 100644 static/js/core/ui.js create mode 100644 static/js/main.js create mode 100644 static/js/panels/jobs.js create mode 100644 static/js/panels/maintenance.js create mode 100644 static/js/panels/models.js create mode 100644 static/js/panels/overview.js diff --git a/.gitignore b/.gitignore index 77ac754..626bcbc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .venv/ __pycache__/ *.pyc +.claude/launch.json diff --git a/CLAUDE.md b/CLAUDE.md index ccd4264..92db666 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,12 +1,25 @@ # Mission Control Web-Dashboard zur Verwaltung eines **lokalen LLM-Stacks (`llama-swap`)** auf dem Bosgame M5. -FastAPI-Backend + Single-File-HTML-Dashboard. **Leitprinzip: KISS — kein Build-Schritt, kein Frontend-Framework, keine Datenbank.** +FastAPI-Backend + Vanilla-JS-Dashboard. **Leitprinzip: KISS — kein Build-Schritt, kein Frontend-Framework, keine Datenbank.** Concerns sind getrennt (SoC) — aber *ohne* Build: native ES-Module im Frontend, FastAPI-`APIRouter` im Backend. ## Architektur -- **`app.py`** — FastAPI-Backend. Endpoints unter `/api/*`: `status`, `download`, `register`, `unload`, `update`, `chat`, `jobs`. Ein In-Memory-Job-System (Threads + Subprocess) fährt Downloads/Updates mit Live-Log. Spricht `llama-swap` an (`/running`, `/v1/models`, unload) und editiert dessen `config.yaml` per `ruamel.yaml` (Kommentare bleiben erhalten). -- **`static/index.html`** — das komplette Dashboard. Vanilla JS, inline CSS, kein Build. Sektionen: Modelle/Ports, Download + Einpflegen, Wartung, Schnelltest-Chat, Aktivität. +**Backend** (Top-Level-Helfer + ein Router je Bereich): +- **`app.py`** — dünner Einstieg: baut `FastAPI`, hängt die Router ein, liefert das statische UI aus, registriert den Exception-Handler. Sonst nichts. +- **`config.py`** — alle Env-Vars + die gemeinsame `ruamel.yaml`-Instanz. +- **`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`). 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. + - **`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`. @@ -31,7 +44,9 @@ FastAPI-Backend + Single-File-HTML-Dashboard. **Leitprinzip: KISS — kein Build ## Konventionen -- KISS über alles: kein schweres Framework, solange es ohne geht. Ein HTML-File fürs UI. +- KISS über alles: kein schweres Framework, kein Build-Schritt, solange es ohne geht. +- **SoC ohne Build**: neuer Bereich = ein `routers/.py` (FastAPI-Router) + ein `static/js/panels/.js` (ES-Modul nach Panel-Vertrag) + ein Nav-Eintrag in `index.html`. Gemeinsame Backend-Logik in `config/auth/jobengine/llamaswap`, gemeinsame Frontend-Logik in `js/core/*`. Keine schweren Libs, kein CDN — nur relative `import`s. +- Endpoint-URLs bleiben unter `/api/*`; neue Bereiche degradieren sauber, wenn ihre Quelle (sysfs, `amd-smi`, `systemctl`) fehlt (z. B. beim Entwickeln auf Windows). - Funktion darf **nicht** von `localStorage` abhängen (nur das Token-Feld nutzt es, das ist ok). - **Sicherheit**: Das Backend führt Shell-Befehle aus → ausschließlich im vertrauenswürdigen LAN betreiben, niemals offen ins Internet. diff --git a/README.md b/README.md index 38ac3ad..0208c4d 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ schon `/ui` und `/log`. Mission Control ergänzt nur, was fehlt. ```bash sudo mkdir -p /opt/mission-control && sudo chown $USER /opt/mission-control -cp -r app.py static /opt/mission-control/ +cp -r *.py routers static /opt/mission-control/ cd /opt/mission-control python3 -m venv .venv && . .venv/bin/activate pip install -r requirements.txt diff --git a/app.py b/app.py index ff1304b..92b7395 100644 --- a/app.py +++ b/app.py @@ -1,277 +1,42 @@ """ Mission Control - eine schlanke Steuerzentrale fuer einen lokalen llama-swap Stack. -Was sie macht: - - zeigt konfigurierte + laufende Modelle und ihre Ports (liest llama-swap /running + /v1/models) - - laedt neue GGUF-Modelle von HuggingFace (hf download, als Hintergrund-Job) - - pflegt ein heruntergeladenes Modell automatisch in die llama-swap config.yaml ein - - stoesst Updates an (Container / Toolbox refresh) - - laedt Modelle aus dem Speicher (unload) und hat einen kleinen Chat-Test +Dieser Einstieg haelt nur noch das Geruest zusammen: er baut die FastAPI-App, +haengt die Router ein und liefert das statische UI aus. Die eigentliche Logik +liegt nach Concern getrennt in: + - config.py Env-Vars / Konstanten + - auth.py optionale Token-Auth + - jobengine.py Hintergrund-Jobs mit Live-Log + - llamaswap.py Reden mit llama-swap + config.yaml lesen/schreiben + - routers/* ein Router je Bereich (models, jobs, maintenance, ...) -Bewusst KISS: ein File, In-Memory Jobs, keine Datenbank. +Bewusst KISS: kein Build-Schritt, kein Framework ueber FastAPI hinaus, keine DB. +Neue Bereiche kommen als routers/.py + static/js/panels/.js dazu. """ -import os -import shlex -import subprocess -import threading -import time -import uuid from pathlib import Path -import httpx -from fastapi import Depends, FastAPI, Header, HTTPException +from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel -from ruamel.yaml import YAML -from ruamel.yaml.scalarstring import LiteralScalarString -# --------------------------------------------------------------------------- -# Konfiguration (alles ueber Umgebungsvariablen ueberschreibbar) -# --------------------------------------------------------------------------- -LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/") -CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml")) -MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models")) -# Befehl, der zum Starten eines Modells in die config.yaml geschrieben wird. -# {model} = Pfad zur GGUF-Datei, {ctx} = Kontextlaenge, ${PORT} bleibt fuer llama-swap stehen. -# WICHTIG: an deinen Container-/llama-server-Aufruf anpassen (siehe README). -CMD_TEMPLATE = os.environ.get( - "MC_CMD_TEMPLATE", - "llama-server -m {model} --host 127.0.0.1 --port ${PORT} " - "-c {ctx} -ngl 999 -fa 1 --no-mmap", -) -# Befehl fuer "Container/Toolbox aktualisieren". Standard: kyuz0 refresh-Skript. -UPDATE_CMD = os.environ.get("MC_UPDATE_CMD", "") -DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300")) -TOKEN = os.environ.get("MC_TOKEN", "") # leer = keine Auth (nur LAN!) - -yaml = YAML() -yaml.preserve_quotes = True +from routers import jobs, maintenance, models app = FastAPI(title="Mission Control") -# --------------------------------------------------------------------------- -# Mini Job-System (Hintergrund-Prozesse mit Live-Log) -# --------------------------------------------------------------------------- -JOBS: dict[str, dict] = {} -_LOG_CAP = 400 +app.include_router(models.router) +app.include_router(jobs.router) +app.include_router(maintenance.router) + +_STATIC = Path(__file__).parent / "static" -def _run_job(job_id: str, args: list[str], env: dict | None = None): - job = JOBS[job_id] - job["state"] = "running" - try: - proc = subprocess.Popen( - args, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - env={**os.environ, **(env or {})}, - ) - for line in proc.stdout: # type: ignore[union-attr] - job["log"].append(line.rstrip("\n")) - if len(job["log"]) > _LOG_CAP: - del job["log"][0] - proc.wait() - job["returncode"] = proc.returncode - job["state"] = "done" if proc.returncode == 0 else "failed" - except Exception as exc: # noqa: BLE001 - job["log"].append(f"[mission-control] Fehler: {exc}") - job["state"] = "failed" - job["returncode"] = -1 - job["finished_at"] = time.time() - - -def start_job(args: list[str], label: str, env: dict | None = None) -> str: - job_id = uuid.uuid4().hex[:12] - JOBS[job_id] = { - "id": job_id, - "label": label, - "state": "queued", - "log": [f"$ {' '.join(shlex.quote(a) for a in args)}"], - "returncode": None, - "started_at": time.time(), - "finished_at": None, - } - threading.Thread(target=_run_job, args=(job_id, args, env), daemon=True).start() - return job_id - - -# --------------------------------------------------------------------------- -# Auth (optional) -# --------------------------------------------------------------------------- -def auth(x_mc_token: str = Header(default="")): - if TOKEN and x_mc_token != TOKEN: - raise HTTPException(status_code=401, detail="Falsches oder fehlendes Token.") - - -# --------------------------------------------------------------------------- -# llama-swap Helfer -# --------------------------------------------------------------------------- -def _swap_get(path: str): - with httpx.Client(timeout=5.0) as c: - r = c.get(f"{LLAMA_SWAP_URL}{path}") - r.raise_for_status() - return r.json() - - -def read_config() -> dict: - if not CONFIG_PATH.exists(): - return {"models": {}} - with CONFIG_PATH.open("r", encoding="utf-8") as f: - data = yaml.load(f) or {} - if "models" not in data or data["models"] is None: - data["models"] = {} - return data - - -# --------------------------------------------------------------------------- -# Request-Modelle -# --------------------------------------------------------------------------- -class DownloadReq(BaseModel): - repo: str - file: str - subdir: str | None = None - - -class RegisterReq(BaseModel): - alias: str - model_path: str - ctx: int = 8192 - ttl: int | None = None - - -class ChatReq(BaseModel): - model: str - message: str - - -# --------------------------------------------------------------------------- -# API -# --------------------------------------------------------------------------- -@app.get("/api/status", dependencies=[Depends(auth)]) -def status(): - cfg = read_config() - configured = {} - for name, spec in (cfg.get("models") or {}).items(): - spec = spec or {} - configured[name] = { - "name": name, - "ttl": spec.get("ttl", cfg.get("globalTTL", 0)), - "cmd": str(spec.get("cmd", "")).strip(), - "state": "idle", - "port": None, - } - swap_ok = True - try: - running = _swap_get("/running") - items = running.get("running", running) if isinstance(running, dict) else running - for item in items or []: - mid = item.get("model") or item.get("id") or item.get("name") - if mid in configured: - configured[mid]["state"] = item.get("state", "running") - configured[mid]["port"] = item.get("port") - elif mid: - configured[mid] = { - "name": mid, "ttl": None, "cmd": "", - "state": item.get("state", "running"), "port": item.get("port"), - } - except Exception: # noqa: BLE001 - swap_ok = False - return { - "swap_ok": swap_ok, - "swap_url": LLAMA_SWAP_URL, - "config_path": str(CONFIG_PATH), - "models_dir": str(MODELS_DIR), - "models": list(configured.values()), - } - - -@app.post("/api/download", dependencies=[Depends(auth)]) -def download(req: DownloadReq): - sub = req.subdir or req.repo.split("/")[-1] - target = MODELS_DIR / sub - target.mkdir(parents=True, exist_ok=True) - args = ["hf", "download", req.repo, req.file, "--local-dir", str(target)] - job_id = start_job(args, f"download {req.repo}/{req.file}", - env={"HF_XET_HIGH_PERFORMANCE": "1"}) - JOBS[job_id]["result_path"] = str(target / req.file) - return {"job_id": job_id, "expected_path": str(target / req.file)} - - -@app.post("/api/register", dependencies=[Depends(auth)]) -def register(req: RegisterReq): - if not Path(req.model_path).exists(): - raise HTTPException(404, f"Datei nicht gefunden: {req.model_path}") - cfg = read_config() - cmd = CMD_TEMPLATE.replace("{model}", req.model_path).replace("{ctx}", str(req.ctx)) - cfg["models"][req.alias] = { - "cmd": LiteralScalarString(cmd + "\n"), - "ttl": req.ttl if req.ttl is not None else DEFAULT_TTL, - } - CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - with CONFIG_PATH.open("w", encoding="utf-8") as f: - yaml.dump(cfg, f) - return {"ok": True, "alias": req.alias, - "note": "In config.yaml geschrieben. llama-swap mit -watch-config laedt automatisch neu."} - - -@app.post("/api/unload", dependencies=[Depends(auth)]) -def unload(model: str | None = None): - path = f"/api/models/unload/{model}" if model else "/api/models/unload" - try: - with httpx.Client(timeout=10.0) as c: - r = c.post(f"{LLAMA_SWAP_URL}{path}") - return {"ok": r.status_code < 400, "status": r.status_code} - except Exception as exc: # noqa: BLE001 - raise HTTPException(502, f"llama-swap nicht erreichbar: {exc}") - - -@app.post("/api/update", dependencies=[Depends(auth)]) -def update(): - if not UPDATE_CMD: - raise HTTPException(400, "Kein Update-Befehl gesetzt (MC_UPDATE_CMD).") - job_id = start_job(shlex.split(UPDATE_CMD), "update containers") - return {"job_id": job_id} - - -@app.post("/api/chat", dependencies=[Depends(auth)]) -def chat(req: ChatReq): - payload = {"model": req.model, "messages": [{"role": "user", "content": req.message}]} - try: - with httpx.Client(timeout=120.0) as c: - r = c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=payload) - r.raise_for_status() - data = r.json() - return {"reply": data["choices"][0]["message"]["content"]} - except Exception as exc: # noqa: BLE001 - raise HTTPException(502, f"Anfrage fehlgeschlagen: {exc}") - - -@app.get("/api/jobs/{job_id}", dependencies=[Depends(auth)]) -def job_status(job_id: str): - job = JOBS.get(job_id) - if not job: - raise HTTPException(404, "Job nicht gefunden.") - return job - - -@app.get("/api/jobs", dependencies=[Depends(auth)]) -def jobs_list(): - return sorted(JOBS.values(), key=lambda j: j["started_at"], reverse=True)[:20] - - -# --------------------------------------------------------------------------- -# Statisches UI -# --------------------------------------------------------------------------- @app.get("/") def index(): - return FileResponse(Path(__file__).parent / "static" / "index.html") + return FileResponse(_STATIC / "index.html") -app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static") +app.mount("/static", StaticFiles(directory=_STATIC), name="static") @app.exception_handler(HTTPException) diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..7b6d095 --- /dev/null +++ b/auth.py @@ -0,0 +1,15 @@ +""" +Optionale Token-Auth. + +Wenn MC_TOKEN gesetzt ist, muss jeder API-Call den Header X-MC-Token mitschicken. +Leer = keine Auth (nur im vertrauenswuerdigen LAN betreiben!). +""" + +from fastapi import Header, HTTPException + +from config import TOKEN + + +def auth(x_mc_token: str = Header(default="")): + if TOKEN and x_mc_token != TOKEN: + raise HTTPException(status_code=401, detail="Falsches oder fehlendes Token.") diff --git a/config.py b/config.py new file mode 100644 index 0000000..736cb7c --- /dev/null +++ b/config.py @@ -0,0 +1,34 @@ +""" +Zentrale Konfiguration fuer Mission Control. + +Alles ueber Umgebungsvariablen ueberschreibbar. Wird von allen Modulen importiert, +damit es genau eine Quelle der Wahrheit fuer Pfade, URLs und Defaults gibt. +""" + +import os +from pathlib import Path + +from ruamel.yaml import YAML + +# --------------------------------------------------------------------------- +# Env-Vars (siehe README fuer Bedeutung) +# --------------------------------------------------------------------------- +LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/") +CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml")) +MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models")) +# Befehl, der zum Starten eines Modells in die config.yaml geschrieben wird. +# {model} = Pfad zur GGUF-Datei, {ctx} = Kontextlaenge, ${PORT} bleibt fuer llama-swap stehen. +# WICHTIG: an deinen Container-/llama-server-Aufruf anpassen (siehe README). +CMD_TEMPLATE = os.environ.get( + "MC_CMD_TEMPLATE", + "llama-server -m {model} --host 127.0.0.1 --port ${PORT} " + "-c {ctx} -ngl 999 -fa 1 --no-mmap", +) +# Befehl fuer "Container/Toolbox aktualisieren". Standard: kyuz0 refresh-Skript. +UPDATE_CMD = os.environ.get("MC_UPDATE_CMD", "") +DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300")) +TOKEN = os.environ.get("MC_TOKEN", "") # leer = keine Auth (nur LAN!) + +# Gemeinsame YAML-Instanz (preserve_quotes haelt Kommentare/Quotes in config.yaml). +yaml = YAML() +yaml.preserve_quotes = True diff --git a/jobengine.py b/jobengine.py new file mode 100644 index 0000000..96ee93a --- /dev/null +++ b/jobengine.py @@ -0,0 +1,58 @@ +""" +Mini Job-System: Hintergrund-Prozesse mit Live-Log. + +Bewusst KISS: ein In-Memory-Dict, ein Daemon-Thread je Job, Subprocess mit +zeilenweisem Log-Capture. Keine Persistenz, kein Broker. Genutzt von allen +Routern, die laenger laufende Shell-Befehle anstossen (Download, Update, ...). +""" + +import os +import shlex +import subprocess +import threading +import time +import uuid + +JOBS: dict[str, dict] = {} +_LOG_CAP = 400 + + +def _run_job(job_id: str, args: list[str], env: dict | None = None): + job = JOBS[job_id] + job["state"] = "running" + try: + proc = subprocess.Popen( + args, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + env={**os.environ, **(env or {})}, + ) + for line in proc.stdout: # type: ignore[union-attr] + job["log"].append(line.rstrip("\n")) + if len(job["log"]) > _LOG_CAP: + del job["log"][0] + proc.wait() + job["returncode"] = proc.returncode + job["state"] = "done" if proc.returncode == 0 else "failed" + except Exception as exc: # noqa: BLE001 + job["log"].append(f"[mission-control] Fehler: {exc}") + job["state"] = "failed" + job["returncode"] = -1 + job["finished_at"] = time.time() + + +def start_job(args: list[str], label: str, env: dict | None = None) -> str: + job_id = uuid.uuid4().hex[:12] + JOBS[job_id] = { + "id": job_id, + "label": label, + "state": "queued", + "log": [f"$ {' '.join(shlex.quote(a) for a in args)}"], + "returncode": None, + "started_at": time.time(), + "finished_at": None, + } + threading.Thread(target=_run_job, args=(job_id, args, env), daemon=True).start() + return job_id diff --git a/llamaswap.py b/llamaswap.py new file mode 100644 index 0000000..a23d128 --- /dev/null +++ b/llamaswap.py @@ -0,0 +1,34 @@ +""" +Helfer rund um llama-swap und dessen config.yaml. + +- _swap_get: liest llama-swap-Endpoints (/running, /v1/models, ...) +- read_config / write_config: lesen/schreiben der config.yaml ueber ruamel.yaml, + sodass Kommentare und Quotes erhalten bleiben. +""" + +import httpx + +from config import CONFIG_PATH, LLAMA_SWAP_URL, yaml + + +def _swap_get(path: str): + with httpx.Client(timeout=5.0) as c: + r = c.get(f"{LLAMA_SWAP_URL}{path}") + r.raise_for_status() + return r.json() + + +def read_config() -> dict: + if not CONFIG_PATH.exists(): + return {"models": {}} + with CONFIG_PATH.open("r", encoding="utf-8") as f: + data = yaml.load(f) or {} + if "models" not in data or data["models"] is None: + data["models"] = {} + return data + + +def write_config(cfg: dict) -> None: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + with CONFIG_PATH.open("w", encoding="utf-8") as f: + yaml.dump(cfg, f) diff --git a/routers/__init__.py b/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routers/jobs.py b/routers/jobs.py new file mode 100644 index 0000000..fb4f0a9 --- /dev/null +++ b/routers/jobs.py @@ -0,0 +1,24 @@ +""" +Jobs-Router: Status einzelner Hintergrund-Jobs + Liste der letzten Jobs. +Liefert die Daten fuer das Aktivitaets-Panel mit Live-Log. +""" + +from fastapi import APIRouter, Depends, HTTPException + +from auth import auth +from jobengine import JOBS + +router = APIRouter(prefix="/api", dependencies=[Depends(auth)]) + + +@router.get("/jobs/{job_id}") +def job_status(job_id: str): + job = JOBS.get(job_id) + if not job: + raise HTTPException(404, "Job nicht gefunden.") + return job + + +@router.get("/jobs") +def jobs_list(): + return sorted(JOBS.values(), key=lambda j: j["started_at"], reverse=True)[:20] diff --git a/routers/maintenance.py b/routers/maintenance.py new file mode 100644 index 0000000..1397faa --- /dev/null +++ b/routers/maintenance.py @@ -0,0 +1,25 @@ +""" +Wartungs-Router: Container/Toolbox aktualisieren. + +Der konkrete Befehl steckt in MC_UPDATE_CMD (z.B. kyuz0 refresh-Skript) und +laeuft als Hintergrund-Job mit Live-Log. Spaeter wandert hier ggf. mehr +Server-Wartung hinein (siehe Roadmap: Server-Management). +""" + +import shlex + +from fastapi import APIRouter, Depends, HTTPException + +from auth import auth +from config import UPDATE_CMD +from jobengine import start_job + +router = APIRouter(prefix="/api", dependencies=[Depends(auth)]) + + +@router.post("/update") +def update(): + if not UPDATE_CMD: + raise HTTPException(400, "Kein Update-Befehl gesetzt (MC_UPDATE_CMD).") + job_id = start_job(shlex.split(UPDATE_CMD), "update containers") + return {"job_id": job_id} diff --git a/routers/models.py b/routers/models.py new file mode 100644 index 0000000..8bbd632 --- /dev/null +++ b/routers/models.py @@ -0,0 +1,133 @@ +""" +Modelle-Router: Status, Download, Einpflegen, Unload, Schnelltest-Chat. + +Bildet den Kern von Mission Control ab — alles, was direkt mit den llama-swap- +Modellen und ihrer config.yaml zu tun hat. +""" + +from pathlib import Path + +import httpx +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from ruamel.yaml.scalarstring import LiteralScalarString + +from auth import auth +from config import CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, LLAMA_SWAP_URL, MODELS_DIR +from jobengine import JOBS, start_job +from llamaswap import _swap_get, read_config, write_config + +router = APIRouter(prefix="/api", dependencies=[Depends(auth)]) + + +# --------------------------------------------------------------------------- +# Request-Modelle +# --------------------------------------------------------------------------- +class DownloadReq(BaseModel): + repo: str + file: str + subdir: str | None = None + + +class RegisterReq(BaseModel): + alias: str + model_path: str + ctx: int = 8192 + ttl: int | None = None + + +class ChatReq(BaseModel): + model: str + message: str + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- +@router.get("/status") +def status(): + cfg = read_config() + configured = {} + for name, spec in (cfg.get("models") or {}).items(): + spec = spec or {} + configured[name] = { + "name": name, + "ttl": spec.get("ttl", cfg.get("globalTTL", 0)), + "cmd": str(spec.get("cmd", "")).strip(), + "state": "idle", + "port": None, + } + swap_ok = True + try: + running = _swap_get("/running") + items = running.get("running", running) if isinstance(running, dict) else running + for item in items or []: + mid = item.get("model") or item.get("id") or item.get("name") + if mid in configured: + configured[mid]["state"] = item.get("state", "running") + configured[mid]["port"] = item.get("port") + elif mid: + configured[mid] = { + "name": mid, "ttl": None, "cmd": "", + "state": item.get("state", "running"), "port": item.get("port"), + } + except Exception: # noqa: BLE001 + swap_ok = False + return { + "swap_ok": swap_ok, + "swap_url": LLAMA_SWAP_URL, + "config_path": str(CONFIG_PATH), + "models_dir": str(MODELS_DIR), + "models": list(configured.values()), + } + + +@router.post("/download") +def download(req: DownloadReq): + sub = req.subdir or req.repo.split("/")[-1] + target = MODELS_DIR / sub + target.mkdir(parents=True, exist_ok=True) + args = ["hf", "download", req.repo, req.file, "--local-dir", str(target)] + job_id = start_job(args, f"download {req.repo}/{req.file}", + env={"HF_XET_HIGH_PERFORMANCE": "1"}) + JOBS[job_id]["result_path"] = str(target / req.file) + return {"job_id": job_id, "expected_path": str(target / req.file)} + + +@router.post("/register") +def register(req: RegisterReq): + if not Path(req.model_path).exists(): + raise HTTPException(404, f"Datei nicht gefunden: {req.model_path}") + cfg = read_config() + cmd = CMD_TEMPLATE.replace("{model}", req.model_path).replace("{ctx}", str(req.ctx)) + cfg["models"][req.alias] = { + "cmd": LiteralScalarString(cmd + "\n"), + "ttl": req.ttl if req.ttl is not None else DEFAULT_TTL, + } + write_config(cfg) + return {"ok": True, "alias": req.alias, + "note": "In config.yaml geschrieben. llama-swap mit -watch-config laedt automatisch neu."} + + +@router.post("/unload") +def unload(model: str | None = None): + path = f"/api/models/unload/{model}" if model else "/api/models/unload" + try: + with httpx.Client(timeout=10.0) as c: + r = c.post(f"{LLAMA_SWAP_URL}{path}") + return {"ok": r.status_code < 400, "status": r.status_code} + except Exception as exc: # noqa: BLE001 + raise HTTPException(502, f"llama-swap nicht erreichbar: {exc}") + + +@router.post("/chat") +def chat(req: ChatReq): + payload = {"model": req.model, "messages": [{"role": "user", "content": req.message}]} + try: + with httpx.Client(timeout=120.0) as c: + r = c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=payload) + r.raise_for_status() + data = r.json() + return {"reply": data["choices"][0]["message"]["content"]} + except Exception as exc: # noqa: BLE001 + raise HTTPException(502, f"Anfrage fehlgeschlagen: {exc}") diff --git a/static/css/base.css b/static/css/base.css new file mode 100644 index 0000000..01fdaac --- /dev/null +++ b/static/css/base.css @@ -0,0 +1,116 @@ +/* ========================================================================= + base.css — Design-Tokens, Reset, App-Layout (Sidebar + Topbar + Content) + Optik orientiert an docs/mission-control-overview.png (GitHub-Dark-Familie). + ========================================================================= */ + +: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); + /* 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) */ + --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); + /* Schrift */ + --mono:ui-monospace,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace; + --sans:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; + /* Maße */ + --side:62px; --radius:14px; +} + +*{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; + -webkit-font-smoothing:antialiased; +} +a{color:var(--act);text-decoration:none} +::selection{background:rgba(68,147,224,.32)} + +/* ---- App-Shell: feste 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; +} +.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); +} +.side-nav{display:flex;flex-direction:column;gap:4px;flex:1} +.side-foot{margin-top:auto} +.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; +} +.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.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); + 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); +} +.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; +} +.tokin:focus{outline:none;border-color:var(--act)} + +/* ---- Content-Bereich ---- */ +.content{padding:22px 26px 64px;max-width:1500px;width:100%} +.view[hidden]{display:none} +.view{display:flex;flex-direction:column;gap:18px} + +/* Raster-Helfer */ +.grid{display:grid;gap:18px} +.grid-3{grid-template-columns:1fr 1fr 1fr} +.grid-2{grid-template-columns:1fr 1fr} +.kpis{grid-template-columns:repeat(5,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}} + +/* 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; +} +.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 .a-dot{background:var(--warn)} diff --git a/static/css/components.css b/static/css/components.css new file mode 100644 index 0000000..f40bd60 --- /dev/null +++ b/static/css/components.css @@ -0,0 +1,142 @@ +/* ========================================================================= + components.css — wiederverwendbare Bausteine (Karten, KPIs, Listen, Forms) + ========================================================================= */ + +/* ---- Karte (Grundbaustein) ---- */ +.card{ + background:var(--panel);border:1px solid var(--line);border-radius:var(--radius); + padding:18px 20px; +} +.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 .meta{font-family:var(--mono);font-size:12px;color:var(--mut)} +.card-h .meta.ok{color:var(--on)} + +/* ---- 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} + +/* ---- 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)} +.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.muted .k-v{color:var(--dim)} + +/* ---- Key-Value-Liste (Health-Signale) ---- */ +.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: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)} + +/* ---- Listen-Items (Modelle / "Session Router") ---- */ +.list{display:flex;flex-direction:column} +.li{display:flex;align-items:center;gap:12px;padding:12px 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)} +.li .li-main{flex:1;min-width:0} +.li .li-id{font-family:var(--mono);font-size:13px;color:#e8eef5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.li .li-sub{font-size:12px;color:var(--mut);margin-top:2px} +.li .li-right{text-align:right;flex:0 0 auto} +.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} + +/* ---- 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} +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} + +/* ---- Badges ---- */ +.badge{font-family:var(--mono);font-size:11px;padding:3px 9px;border-radius:6px;display:inline-block} +.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-ok{background:rgba(63,185,80,.14);color:var(--on)} + +/* ---- 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} + +/* ---- 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)} +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} +.mono-sm{font-family:var(--mono);font-size:11.5px;color:var(--mut)} + +/* ---- 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)} +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: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; + 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} +.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} + +/* ---- 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} +.toast.show{opacity:1} +.toast.err{border-color:var(--err);color:#ffb4ae} diff --git a/static/index.html b/static/index.html index 065f35b..396fef4 100644 --- a/static/index.html +++ b/static/index.html @@ -4,245 +4,76 @@ Mission Control - + + -
-
- Mission Control - verbinde… - - -
+
-
- -
-

Modelle & Ports

- - - -
ModellStatusPortAktion
- + + - -
-

Modell holen

- - - - - -
+
- + diff --git a/static/js/core/api.js b/static/js/core/api.js new file mode 100644 index 0000000..0596bc8 --- /dev/null +++ b/static/js/core/api.js @@ -0,0 +1,24 @@ +// api.js — zentraler Fetch-Wrapper + Token-Handling. +// Einziges Modul, das localStorage nutzt (nur fuers Token-Feld — laut Konvention ok). + +let TOKEN = localStorage.getItem("mc_token") || ""; + +export function getToken() { return TOKEN; } + +export function setToken(t) { + TOKEN = (t || "").trim(); + localStorage.setItem("mc_token", TOKEN); +} + +export function hdr() { + const h = { "Content-Type": "application/json" }; + if (TOKEN) h["X-MC-Token"] = TOKEN; + return h; +} + +export async function api(path, opts = {}) { + const r = await fetch(path, { headers: hdr(), ...opts }); + const data = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(data.error || ("HTTP " + r.status)); + return data; +} diff --git a/static/js/core/nav.js b/static/js/core/nav.js new file mode 100644 index 0000000..ef54893 --- /dev/null +++ b/static/js/core/nav.js @@ -0,0 +1,28 @@ +// nav.js — minimaler Hash-basierter View-Switch fuer die Sidebar. +// Zeigt genau eine .view[data-view] und markiert das aktive Nav-Item. + +import { $$ } from "./ui.js"; + +export function initNav(defaultView = "overview") { + const items = $$(".nav-item[data-view]:not(.disabled)"); + const views = $$(".view[data-view]"); + const valid = new Set(views.map(v => v.dataset.view)); + + function show(view) { + if (!valid.has(view)) view = defaultView; + views.forEach(v => (v.hidden = v.dataset.view !== view)); + items.forEach(i => i.classList.toggle("active", i.dataset.view === view)); + if (location.hash !== "#/" + view) location.hash = "#/" + view; + } + + items.forEach(i => + i.addEventListener("click", e => { + e.preventDefault(); + show(i.dataset.view); + }) + ); + window.addEventListener("hashchange", () => show(location.hash.replace("#/", ""))); + + show(location.hash.replace("#/", "") || defaultView); + return { show }; +} diff --git a/static/js/core/ui.js b/static/js/core/ui.js new file mode 100644 index 0000000..3e3f1f2 --- /dev/null +++ b/static/js/core/ui.js @@ -0,0 +1,59 @@ +// ui.js — kleine DOM-Helfer, Toast, Badge, Escaping + Inline-Icon-Set. +// Bewusst kein Icon-Font / kein CDN: SVGs als Strings, faerben via currentColor. + +export const $ = (s, r = document) => r.querySelector(s); +export const $$ = (s, r = document) => [...r.querySelectorAll(s)]; + +export function esc(s) { + return String(s ?? "").replace(/[&<>]/g, c => ({ "&": "&", "<": "<", ">": ">" }[c])); +} + +let _tt; +export function toast(msg, err = false) { + const t = $("#toast"); + if (!t) return; + t.textContent = msg; + t.className = "toast show" + (err ? " err" : ""); + clearTimeout(_tt); + _tt = setTimeout(() => (t.className = "toast"), 3200); +} + +// Modell-Status -> Badge-HTML +export function badge(state) { + if (state === "running" || state === "ready") return 'geladen'; + if (state === "loading" || state === "starting") return 'lädt…'; + return 'bereit'; +} + +// relative Zeit aus Unix-Sekunden (z.B. "2m", "13h") +export function ago(ts) { + if (!ts) return ""; + const s = Math.max(0, Math.floor(Date.now() / 1000 - ts)); + if (s < 60) return s + "s"; + if (s < 3600) return Math.floor(s / 60) + "m"; + if (s < 86400) return Math.floor(s / 3600) + "h"; + return Math.floor(s / 86400) + "d"; +} + +const _svg = (p) => + `${p}`; + +// Icon-Set (Stroke-Style, an die Referenz angelehnt) +export const ICON = { + logo: _svg(''), + grid: _svg(''), + cpu: _svg(''), + pulse: _svg(''), + server: _svg(''), + book: _svg(''), + help: _svg(''), + settings: _svg(''), + swap: _svg(''), + monitor: _svg(''), + layers: _svg(''), + alert: _svg(''), + gauge: _svg(''), +}; + +export function icon(name) { return ICON[name] || ""; } diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..75cabf5 --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,81 @@ +// main.js — App-Boot: Panels mounten, Nav starten, Topbar/Alert pflegen, Polling fahren. +// Panel-Vertrag: { id, mount?(), onStatus?(s), onJobs?(jobs) }. + +import { api, getToken, setToken } from "./core/api.js"; +import { $ } from "./core/ui.js"; +import { initNav } from "./core/nav.js"; + +import overview from "./panels/overview.js"; +import models from "./panels/models.js"; +import maintenance from "./panels/maintenance.js"; +import jobs from "./panels/jobs.js"; + +const panels = [overview, models, maintenance, jobs]; + +let lastJobs = []; + +// ---- Topbar / Alert aus dem Status ableiten ---- +function applyStatus(s) { + const dot = $("#swdot"), label = $("#swlabel"), alert = $("#alert"); + + if (!s) { + dot.className = "dot off"; + label.textContent = "Backend nicht erreichbar"; + $("#top-models").textContent = "–"; + 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; + if (s.swap_ok) hideAlert(); + else showAlert(`llama-swap nicht erreichbar unter ${host} — läuft der 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); +} + +function showAlert(html, warn) { + const a = $("#alert"); + a.className = "alert" + (warn ? " warn" : ""); + a.innerHTML = `${html}`; + a.style.display = "flex"; +} +function hideAlert() { $("#alert").style.display = "none"; } + +// ---- Polling ---- +async function pollStatus() { + try { applyStatus(await api("/api/status")); } + catch { applyStatus(null); } +} +async function pollJobs() { + try { applyJobs(await api("/api/jobs")); } + catch { /* still */ } +} + +// ---- Boot ---- +function bootToken() { + const i = $("#token"); + i.value = getToken(); + i.addEventListener("change", e => { setToken(e.target.value); pollStatus(); }); +} +function tickClock() { + $("#clock").textContent = new Date().toTimeString().slice(0, 5); +} + +for (const p of panels) p.mount?.(); +initNav("overview"); +bootToken(); +tickClock(); +document.addEventListener("mc:refresh", pollStatus); + +pollStatus(); +pollJobs(); +setInterval(tickClock, 1000); +setInterval(pollStatus, 3000); +setInterval(pollJobs, 1500); diff --git a/static/js/panels/jobs.js b/static/js/panels/jobs.js new file mode 100644 index 0000000..dd748c4 --- /dev/null +++ b/static/js/panels/jobs.js @@ -0,0 +1,67 @@ +// jobs.js — Aktivitaets-Stream ("Incident Stream"): Hintergrund-Jobs mit Live-Log. +// Exportiert track(id), damit andere Panels einen frisch gestarteten Job auto-aufklappen. + +import { $, esc } from "../core/ui.js"; + +const tracked = new Set(); +let JOBS = []; + +export function track(id) { + tracked.add(id); + render(); +} + +function statusBadge(state) { + if (state === "done") return 'fertig'; + if (state === "failed") return 'fehler'; + return 'läuft…'; +} +function dotClass(state) { + if (state === "done") return "on"; + if (state === "failed") return ""; + return "load"; +} + +function mount() { + $("#ov-activity").innerHTML = ` +

Aktivität

+
+
+
Noch nichts losgemacht.
+
Downloads, Updates & Co. erscheinen hier mit Live-Log.
+
`; + + // Klicks auf Job-Kopf -> auf/zuklappen (Event-Delegation) + $("#jobs").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(); + }); +} + +function render() { + 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 ? `
${esc((j.log || []).join("\n"))}
` : ""; + return `
+
+ + ${esc(j.label)}${statusBadge(j.state)} +
${log}
`; + }).join(""); +} + +function onJobs(jobs) { + JOBS = jobs || []; + render(); +} + +export default { id: "jobs", mount, onJobs }; diff --git a/static/js/panels/maintenance.js b/static/js/panels/maintenance.js new file mode 100644 index 0000000..ae22dd4 --- /dev/null +++ b/static/js/panels/maintenance.js @@ -0,0 +1,40 @@ +// maintenance.js — Wartungs-Karte: Container aktualisieren + alle Modelle entladen. +// Spaeter waechst hier das Server-Management an (Roadmap Feature 1). + +import { api } from "../core/api.js"; +import { $, toast } from "../core/ui.js"; +import { track } from "./jobs.js"; + +function mount() { + $("#wartung").innerHTML = ` +

Wartung

+
+ + +
+
+ Update-Befehl wird per MC_UPDATE_CMD gesetzt. + Server-Steuerung (Dienste, OS-Updates, Reboot) folgt als eigener Bereich. +
`; + + $("#w-update").addEventListener("click", update); + $("#w-unload").addEventListener("click", unloadAll); +} + +async function update() { + try { + const r = await api("/api/update", { method: "POST" }); + toast("Update läuft."); + track(r.job_id); + } catch (e) { toast(e.message, true); } +} + +async function unloadAll() { + try { + await api("/api/unload", { method: "POST" }); + toast("Alle Modelle entladen."); + setTimeout(() => document.dispatchEvent(new Event("mc:refresh")), 600); + } catch (e) { toast(e.message, true); } +} + +export default { id: "maintenance", mount }; diff --git a/static/js/panels/models.js b/static/js/panels/models.js new file mode 100644 index 0000000..09c55e8 --- /dev/null +++ b/static/js/panels/models.js @@ -0,0 +1,123 @@ +// models.js — "Modelle"-Ansicht: Download + Einpflegen, Schnelltest-Chat, Modell-Tabelle. + +import { api } from "../core/api.js"; +import { $, badge, esc, toast } from "../core/ui.js"; +import { track } from "./jobs.js"; + +function refreshSoon() { document.dispatchEvent(new Event("mc:refresh")); } + +function mount() { + $("#m-download").innerHTML = ` +

Modell holen

+ + + + + + `; + + $("#m-chat").innerHTML = ` +

Schnelltest

+ + + + + + `; + + $("#m-table").innerHTML = ` +

Modelle & Ports

+ + + +
ModellStatusPortAktion
+ `; + + $("#dl-btn").addEventListener("click", pull); + $("#rg-btn").addEventListener("click", register); + $("#chat-btn").addEventListener("click", sendChat); +} + +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" : ""; + + const sel = $("#chat-model"); + const cur = sel.value; + sel.innerHTML = ""; + for (const m of models) { + const tr = document.createElement("tr"); + tr.innerHTML = `${esc(m.name)}${badge(m.state)} + ${m.port ?? "auto"} + `; + tb.appendChild(tr); + sel.insertAdjacentHTML("beforeend", ``); + } + if (cur) sel.value = cur; + tb.querySelectorAll("[data-unload]").forEach(b => + b.addEventListener("click", () => unloadOne(b.getAttribute("data-unload"))) + ); +} + +async function pull() { + const repo = $("#dl-repo").value.trim(), file = $("#dl-file").value.trim(); + if (!repo || !file) return toast("Repo und Datei angeben.", true); + try { + const r = await api("/api/download", { method: "POST", body: JSON.stringify({ repo, file }) }); + toast("Download gestartet."); + const stem = file.split("/").pop().replace(/\.gguf$/i, ""); + $("#rg-alias").value = stem; + $("#rg-path").value = r.expected_path; + $("#register-box").style.display = "block"; + track(r.job_id); + } catch (e) { toast(e.message, true); } +} + +async function register() { + const alias = $("#rg-alias").value.trim(); + const model_path = $("#rg-path").value; + const ctx = parseInt($("#rg-ctx").value) || 8192; + if (!alias) return toast("Alias angeben.", true); + try { + await api("/api/register", { method: "POST", body: JSON.stringify({ alias, model_path, ctx }) }); + toast("Eingepflegt — llama-swap lädt neu."); + refreshSoon(); + } catch (e) { toast(e.message, true); } +} + +async function unloadOne(m) { + 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() { + const model = $("#chat-model").value, message = $("#chat-msg").value.trim(); + 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(); +} + +export default { id: "models", mount, onStatus }; diff --git a/static/js/panels/overview.js b/static/js/panels/overview.js new file mode 100644 index 0000000..96510e6 --- /dev/null +++ b/static/js/panels/overview.js @@ -0,0 +1,117 @@ +// overview.js — Dashboard-Kopf: Hero + Mini-Stats, KPI-Reihe, Stack-Gesundheit, +// kompakte Modell-Liste ("Session Router"). Speist sich aus /api/status + /api/jobs. + +import { $, icon, esc } from "../core/ui.js"; + +let S = null; // letzter Status +let J = []; // letzte Job-Liste + +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 mini(label, val, tone = "") { + const v = tone ? `${val}` : val; + return `
${label}
${v}
`; +} + +function renderHero() { + const c = counts(); + $("#hero").innerHTML = `
+
+
Übersicht
+

Mission Control

+

Steuerzentrale für deinen lokalen llama-swap-Stack — Modelle, Downloads, + Wartung und Schnelltest an einem Ort.

+
+
+ ${mini("Modelle", c.total)} + ${mini("Aktiv", c.running, "on")} + ${mini("Jobs", c.jobsRun)} + ${mini("Fehler", c.jobsErr, c.jobsErr ? "bad" : "")} +
+
`; +} + +function kpi(cls, title, ic, value, sub) { + return `
+
${title}${icon(ic)}
+
${value}
+
${sub}
+
`; +} + +function renderKpis() { + const c = counts(); + $("#kpis").innerHTML = + kpi(c.swap ? "green" : "red", "llama-swap", "swap", + c.swap ? "Online" : "Offline", "Transport-Status") + + kpi("blue", "Modelle", "monitor", + `${c.running}/${c.total}`, "aktiv / gesamt") + + kpi("purple", "Jobs", "layers", c.jobsRun, "laufend") + + kpi(c.jobsErr ? "red" : "muted", "Fehler", "alert", c.jobsErr, "in der Aktivität") + + kpi("muted", "System-Last", "gauge", "n/a", "bald · Live-Auslastung"); +} + +function kvRow(k, v, cls = "") { + return `
${k}${v}
`; +} + +function renderHealth() { + const c = counts(); + $("#health").innerHTML = ` +

Stack-Gesundheit

+ ${c.swap ? "Connected" : "Offline"}
+
+ ${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" : "")} + ${kvRow("Auslastung (RAM/GPU/Disk)", "folgt", "na")} +
`; +} + +function modelRow(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"; + return `
+ +
+
${esc(m.name)}
+
TTL ${m.ttl ?? "—"}${typeof m.ttl === "number" ? "s" : ""}
+
+
+
${m.port ?? "auto"}
+
${state}
+
+
`; +} + +function renderModels() { + const models = S?.models || []; + $("#ov-models").innerHTML = ` +

Modelle

${models.length || ""}
+ ${models.length + ? `
${models.map(modelRow).join("")}
` + : `
Keine Modelle konfiguriert
+
Hol dir unter „Modelle" eins von HuggingFace.
`}`; +} + +function renderAll() { renderHero(); renderKpis(); renderHealth(); renderModels(); } + +function mount() { renderAll(); } +function onStatus(s) { S = s; renderAll(); } +function onJobs(jobs) { J = jobs || []; renderHero(); renderKpis(); renderHealth(); } + +export default { id: "overview", mount, onStatus, onJobs };