Mission Control v2 – Schritt 1: SoC-Refactor + Design 2.0

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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-20 20:46:45 +02:00
parent e46380d99f
commit 364939466f
23 changed files with 1218 additions and 486 deletions
+1
View File
@@ -1,3 +1,4 @@
.venv/ .venv/
__pycache__/ __pycache__/
*.pyc *.pyc
.claude/launch.json
+19 -4
View File
@@ -1,12 +1,25 @@
# Mission Control # Mission Control
Web-Dashboard zur Verwaltung eines **lokalen LLM-Stacks (`llama-swap`)** auf dem Bosgame M5. 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 ## 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). **Backend** (Top-Level-Helfer + ein Router je Bereich):
- **`static/index.html`** — das komplette Dashboard. Vanilla JS, inline CSS, kein Build. Sektionen: Modelle/Ports, Download + Einpflegen, Wartung, Schnelltest-Chat, Aktivität. - **`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). - **`mission-control.service`** — systemd-Unit (uvicorn auf Port 9000).
- **Konfiguration** rein über Env-Vars: `MC_LLAMA_SWAP_URL`, `MC_CONFIG_PATH`, `MC_MODELS_DIR`, `MC_CMD_TEMPLATE`, `MC_UPDATE_CMD`, `MC_DEFAULT_TTL`, `MC_TOKEN`. - **Konfiguration** rein über Env-Vars: `MC_LLAMA_SWAP_URL`, `MC_CONFIG_PATH`, `MC_MODELS_DIR`, `MC_CMD_TEMPLATE`, `MC_UPDATE_CMD`, `MC_DEFAULT_TTL`, `MC_TOKEN`.
@@ -31,7 +44,9 @@ FastAPI-Backend + Single-File-HTML-Dashboard. **Leitprinzip: KISS — kein Build
## Konventionen ## 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/<bereich>.py` (FastAPI-Router) + ein `static/js/panels/<bereich>.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). - 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. - **Sicherheit**: Das Backend führt Shell-Befehle aus → ausschließlich im vertrauenswürdigen LAN betreiben, niemals offen ins Internet.
+1 -1
View File
@@ -24,7 +24,7 @@ schon `/ui` und `/log`. Mission Control ergänzt nur, was fehlt.
```bash ```bash
sudo mkdir -p /opt/mission-control && sudo chown $USER /opt/mission-control 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 cd /opt/mission-control
python3 -m venv .venv && . .venv/bin/activate python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
+19 -254
View File
@@ -1,277 +1,42 @@
""" """
Mission Control - eine schlanke Steuerzentrale fuer einen lokalen llama-swap Stack. Mission Control - eine schlanke Steuerzentrale fuer einen lokalen llama-swap Stack.
Was sie macht: Dieser Einstieg haelt nur noch das Geruest zusammen: er baut die FastAPI-App,
- zeigt konfigurierte + laufende Modelle und ihre Ports (liest llama-swap /running + /v1/models) haengt die Router ein und liefert das statische UI aus. Die eigentliche Logik
- laedt neue GGUF-Modelle von HuggingFace (hf download, als Hintergrund-Job) liegt nach Concern getrennt in:
- pflegt ein heruntergeladenes Modell automatisch in die llama-swap config.yaml ein - config.py Env-Vars / Konstanten
- stoesst Updates an (Container / Toolbox refresh) - auth.py optionale Token-Auth
- laedt Modelle aus dem Speicher (unload) und hat einen kleinen Chat-Test - 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/<bereich>.py + static/js/panels/<bereich>.js dazu.
""" """
import os
import shlex
import subprocess
import threading
import time
import uuid
from pathlib import Path from pathlib import Path
import httpx from fastapi import FastAPI, HTTPException
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from ruamel.yaml import YAML
from ruamel.yaml.scalarstring import LiteralScalarString
# --------------------------------------------------------------------------- from routers import jobs, maintenance, models
# 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
app = FastAPI(title="Mission Control") app = FastAPI(title="Mission Control")
# --------------------------------------------------------------------------- app.include_router(models.router)
# Mini Job-System (Hintergrund-Prozesse mit Live-Log) app.include_router(jobs.router)
# --------------------------------------------------------------------------- app.include_router(maintenance.router)
JOBS: dict[str, dict] = {}
_LOG_CAP = 400 _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("/") @app.get("/")
def index(): 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) @app.exception_handler(HTTPException)
+15
View File
@@ -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.")
+34
View File
@@ -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
+58
View File
@@ -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
+34
View File
@@ -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)
View File
+24
View File
@@ -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]
+25
View File
@@ -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}
+133
View File
@@ -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}")
+116
View File
@@ -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)}
+142
View File
@@ -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}
+56 -225
View File
@@ -4,245 +4,76 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mission Control</title> <title>Mission Control</title>
<style> <link rel="stylesheet" href="/static/css/base.css">
:root{ <link rel="stylesheet" href="/static/css/components.css">
--bg:#0d1117; --panel:#151b23; --panel2:#1b222c; --line:rgba(255,255,255,.08);
--line2:rgba(255,255,255,.14); --tx:#d7dee7; --mut:#8b97a5;
--on:#46c06a; --warn:#e0a32e; --err:#e5534b; --act:#4493e0;
--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--sans:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--tx);font-family:var(--sans);font-size:15px;line-height:1.5}
a{color:var(--act)}
.wrap{max-width:1040px;margin:0 auto;padding:0 20px 64px}
header{display:flex;align-items:center;gap:16px;padding:20px 0 18px;border-bottom:1px solid var(--line);
position:sticky;top:0;background:var(--bg);z-index:5;flex-wrap:wrap}
.brand{font-weight:600;letter-spacing:.2px;font-size:18px}
.brand b{color:var(--on)}
.pill{display:inline-flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12.5px;
padding:5px 11px;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)}
.dot.on{background:var(--on);box-shadow:0 0 0 0 rgba(70,192,106,.5);animation:pulse 2.2s infinite}
.dot.off{background:var(--err)}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(70,192,106,.45)}70%{box-shadow:0 0 0 7px rgba(70,192,106,0)}100%{box-shadow:0 0 0 0 rgba(70,192,106,0)}}
.spacer{flex:1}
.tokin{font-family:var(--mono);font-size:12.5px;background:var(--panel);border:1px solid var(--line);
color:var(--tx);border-radius:8px;padding:6px 9px;width:130px}
h2{font-size:14px;font-weight:600;letter-spacing:.4px;text-transform:uppercase;color:var(--mut);margin:0 0 12px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:22px}
@media(max-width:780px){.grid{grid-template-columns:1fr}}
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:18px}
.card.full{grid-column:1/-1}
table{width:100%;border-collapse:collapse;font-size:14px}
th{text-align:left;font-weight:500;color:var(--mut);font-size:12px;text-transform:uppercase;
letter-spacing:.4px;padding:0 10px 9px}
td{padding:11px 10px;border-top:1px solid var(--line)}
.mid{font-family:var(--mono);font-size:13px;color:#e8eef5}
.badge{font-family:var(--mono);font-size:11.5px;padding:3px 9px;border-radius:6px;display:inline-block}
.b-run{background:rgba(70,192,106,.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)}
.port{font-family:var(--mono);color:var(--mut);font-size:13px}
.empty{color:var(--mut);font-size:14px;padding:14px 4px}
label{display:block;font-size:12.5px;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}
textarea{resize:vertical;min-height:64px;font-family:var(--sans)}
.row{display:flex;gap:10px}.row>div{flex:1}
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}
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.ghost{padding:5px 11px;font-size:12.5px}
button:disabled{opacity:.5;cursor:not-allowed}
.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:#0a0e13;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:8px;margin-bottom:8px;overflow:hidden}
.job-h{display:flex;align-items:center;gap:10px;padding:10px 12px;cursor:pointer}
.job-h .mid{flex:1}
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1b222c;
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}
.toast.show{opacity:1}
.toast.err{border-color:var(--err);color:#ffb4ae}
.hint{font-size:12px;color:var(--mut);margin:-4px 0 12px}
.mono-sm{font-family:var(--mono);font-size:11.5px;color:var(--mut)}
</style>
</head> </head>
<body> <body>
<div class="wrap"> <div id="app">
<header>
<span class="brand">Mission <b>Control</b></span> <!-- Sidebar: Bereichs-Navigation. Platzhalter-Items sind die kommenden Roadmap-Bereiche. -->
<span class="pill"><span id="hdot" class="dot"></span><span id="hlabel">verbinde…</span></span> <aside class="sidebar">
<div class="side-logo" id="logo"></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>
<span class="nav-item disabled" title="Aktivität — siehe Übersicht" data-ic="pulse"></span>
<span class="nav-item disabled" title="Server (bald)" data-ic="server"></span>
<span class="nav-item disabled" title="Cookbook (bald)" data-ic="book"></span>
<span class="nav-item disabled" title="Guides (bald)" data-ic="help"></span>
</nav>
<div class="side-foot">
<span class="nav-item disabled" title="Einstellungen (bald)" data-ic="settings"></span>
</div>
</aside>
<div class="main">
<!-- Topbar -->
<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="spacer"></span>
<input id="token" class="tokin" placeholder="Token (optional)" autocomplete="off"> <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">
</header> </header>
<div class="grid"> <!-- Alert-Banner (wird per JS ein-/ausgeblendet) -->
<!-- MODELLE --> <div id="alert" class="alert" style="display:none"></div>
<div class="card full">
<h2>Modelle &amp; Ports</h2>
<table>
<thead><tr><th>Modell</th><th>Status</th><th>Port</th><th style="text-align:right">Aktion</th></tr></thead>
<tbody id="models"></tbody>
</table>
<div id="models-empty" class="empty" style="display:none">Noch keine Modelle konfiguriert — zieh dir unten eins rein. 👇</div>
</div>
<!-- DOWNLOAD --> <!-- Content: genau eine .view ist sichtbar (Hash-Routing) -->
<div class="card"> <main class="content">
<h2>Modell holen</h2>
<label>HuggingFace-Repo</label>
<input id="dl-repo" placeholder="unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF">
<label>Datei (GGUF)</label>
<input id="dl-file" placeholder="Q4_K_M/Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf">
<button class="primary" onclick="pull()">Modell herunterladen</button>
<div id="register-box" style="display:none;margin-top:16px;border-top:1px solid var(--line);padding-top:14px">
<h2>Einpflegen</h2>
<div class="row">
<div><label>Alias</label><input id="rg-alias"></div>
<div><label>Kontext</label><input id="rg-ctx" value="8192"></div>
</div>
<input id="rg-path" class="mono-sm" readonly>
<button class="primary" onclick="register()">In Config eintragen</button>
</div>
</div>
<!-- WARTUNG + TEST --> <section class="view" data-view="overview">
<div class="card"> <div id="hero"></div>
<h2>Wartung</h2> <div class="grid kpis" id="kpis"></div>
<button onclick="update()">Container aktualisieren</button> <div class="grid grid-3">
<button onclick="unloadAll()">Alles aus dem Speicher</button> <div class="card" id="health"></div>
<div class="hint" style="margin-top:10px">Update-Befehl wird per <span class="mono-sm">MC_UPDATE_CMD</span> gesetzt.</div> <div class="card" id="ov-models"></div>
<h2 style="margin-top:18px">Schnelltest</h2> <div class="card" id="ov-activity"></div>
<select id="chat-model"></select>
<textarea id="chat-msg" placeholder="Schreib was, um ein Modell zu wecken…"></textarea>
<button class="primary" onclick="sendChat()" id="chat-btn">Senden</button>
<div id="chat-reply" class="reply" style="display:none"></div>
</div> </div>
<div class="card" id="wartung"></div>
</section>
<!-- AKTIVITAET --> <section class="view" data-view="models" hidden>
<div class="card full"> <div class="grid grid-2">
<h2>Aktivität</h2> <div class="card" id="m-download"></div>
<div id="jobs"></div> <div class="card" id="m-chat"></div>
<div id="jobs-empty" class="empty">Noch nichts losgemacht.</div>
</div> </div>
<div class="card" id="m-table"></div>
</section>
</main>
</div> </div>
</div> </div>
<div id="toast" class="toast"></div> <div id="toast" class="toast"></div>
<script> <!-- Icons in die Nav/Logo einsetzen, bevor das Haupt-Modul laedt -->
const $ = s => document.querySelector(s); <script type="module">
let TOKEN = localStorage.getItem("mc_token") || ""; import { ICON } from "/static/js/core/ui.js";
$("#token").value = TOKEN; document.getElementById("logo").innerHTML = ICON.logo;
$("#token").addEventListener("change", e => { TOKEN = e.target.value.trim(); localStorage.setItem("mc_token", TOKEN); refresh(); }); document.querySelectorAll(".nav-item[data-ic]").forEach(n => (n.innerHTML = ICON[n.dataset.ic] || ""));
function hdr(){ return TOKEN ? {"Content-Type":"application/json","X-MC-Token":TOKEN} : {"Content-Type":"application/json"}; }
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;
}
let _tt;
function toast(msg, err=false){
const t = $("#toast"); t.textContent = msg; t.className = "toast show" + (err?" err":"");
clearTimeout(_tt); _tt = setTimeout(()=>t.className="toast",3200);
}
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>';
}
async function refresh(){
let s;
try{ s = await api("/api/status"); }
catch(e){ $("#hdot").className="dot off"; $("#hlabel").textContent="Backend nicht erreichbar"; return; }
const ok = s.swap_ok;
$("#hdot").className = "dot " + (ok?"on":"off");
$("#hlabel").textContent = (ok?"llama-swap online · ":"llama-swap offline · ") + s.swap_url.replace(/^https?:\/\//,"");
const tb = $("#models"); tb.innerHTML = "";
$("#models-empty").style.display = s.models.length ? "none" : "block";
const sel = $("#chat-model"); const cur = sel.value; sel.innerHTML = "";
for(const m of s.models){
const tr = document.createElement("tr");
tr.innerHTML = `<td class="mid">${m.name}</td><td>${badge(m.state)}</td>
<td class="port">${m.port ?? "auto"}</td>
<td style="text-align:right"><button class="ghost" onclick="unloadOne('${m.name}')">Entladen</button></td>`;
tb.appendChild(tr);
sel.insertAdjacentHTML("beforeend", `<option>${m.name}</option>`);
}
if(cur) sel.value = cur;
}
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";
trackJob(r.job_id);
}catch(e){ toast(e.message, true); }
}
async function register(){
const alias = $("#rg-alias").value.trim(), model_path = $("#rg-path").value, 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."); refresh();
}catch(e){ toast(e.message, true); }
}
async function unloadOne(m){ try{ await api("/api/unload?model="+encodeURIComponent(m),{method:"POST"}); toast("Entladen: "+m); setTimeout(refresh,600);}catch(e){toast(e.message,true);} }
async function unloadAll(){ try{ await api("/api/unload",{method:"POST"}); toast("Alle Modelle entladen."); setTimeout(refresh,600);}catch(e){toast(e.message,true);} }
async function update(){ try{ const r = await api("/api/update",{method:"POST"}); toast("Update läuft."); trackJob(r.job_id);}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"; refresh();
}
// --- Jobs ---
const tracked = new Set();
function trackJob(id){ tracked.add(id); renderJobs(); }
async function renderJobs(){
let jobs;
try{ jobs = await api("/api/jobs"); }catch(e){ return; }
$("#jobs-empty").style.display = jobs.length ? "none" : "block";
const c = $("#jobs"); c.innerHTML = "";
for(const j of jobs){
const open = tracked.has(j.id);
const st = j.state==="done" ? '<span class="badge b-run">fertig</span>'
: j.state==="failed" ? '<span class="badge" style="background:rgba(229,83,75,.16);color:#e5534b">fehler</span>'
: '<span class="badge b-load">läuft…</span>';
const div = document.createElement("div"); div.className="job";
div.innerHTML = `<div class="job-h" onclick="toggleJob('${j.id}')">
<span class="mid">${j.label}</span>${st}</div>
${open ? `<div class="log">${(j.log||[]).join("\n").replace(/</g,"&lt;")}</div>` : ""}`;
c.appendChild(div);
}
}
function toggleJob(id){ tracked.has(id) ? tracked.delete(id) : tracked.add(id); renderJobs(); }
refresh(); renderJobs();
setInterval(refresh, 3000);
setInterval(renderJobs, 1500);
</script> </script>
<script type="module" src="/static/js/main.js"></script>
</body> </body>
</html> </html>
+24
View File
@@ -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;
}
+28
View File
@@ -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 };
}
+59
View File
@@ -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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[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 '<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>';
}
// 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) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" ` +
`stroke-linecap="round" stroke-linejoin="round">${p}</svg>`;
// Icon-Set (Stroke-Style, an die Referenz angelehnt)
export const ICON = {
logo: _svg('<circle cx="12" cy="12" r="3.2"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/>'),
grid: _svg('<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>'),
cpu: _svg('<rect x="6" y="6" width="12" height="12" rx="2"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M1 9h3M1 15h3M20 9h3M20 15h3"/>'),
pulse: _svg('<path d="M3 12h4l2 6 4-14 2 8h6"/>'),
server: _svg('<rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/>'),
book: _svg('<path d="M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2z"/><path d="M19 19H6a2 2 0 0 0-2 2"/>'),
help: _svg('<circle cx="12" cy="12" r="9"/><path d="M9.5 9.2a2.5 2.5 0 0 1 4.8 1c0 1.7-2.3 2-2.3 3.4"/><path d="M12 17h.01"/>'),
settings: _svg('<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-2.7 1.1V21a2 2 0 1 1-4 0v-.1A1.6 1.6 0 0 0 6.6 19l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0-1.1-2.7H2a2 2 0 1 1 0-4h.1A1.6 1.6 0 0 0 4 6.6l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1A1.6 1.6 0 0 0 9 4.6V4a2 2 0 1 1 4 0v.1A1.6 1.6 0 0 0 17.4 6l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0 1.1 2.7H21a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1z"/>'),
swap: _svg('<path d="M7 4 3 8l4 4"/><path d="M3 8h12a4 4 0 0 1 0 8h-1"/><path d="m17 20 4-4-4-4"/>'),
monitor: _svg('<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/>'),
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"/>'),
};
export function icon(name) { return ICON[name] || ""; }
+81
View File
@@ -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 <b>${host}</b> — 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 = `<span class="a-dot"></span><span>${html}</span>`;
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);
+67
View File
@@ -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 '<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 dotClass(state) {
if (state === "done") return "on";
if (state === "failed") return "";
return "load";
}
function mount() {
$("#ov-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>`;
// 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 ? `<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();
}
export default { id: "jobs", mount, onJobs };
+40
View File
@@ -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 = `
<div class="card-h"><h3>Wartung</h3></div>
<div class="btn-row">
<button id="w-update">Container aktualisieren</button>
<button id="w-unload" class="danger">Alles aus dem Speicher</button>
</div>
<div class="hint" style="margin-top:12px">
Update-Befehl wird per <span class="mono-sm">MC_UPDATE_CMD</span> gesetzt.
Server-Steuerung (Dienste, OS-Updates, Reboot) folgt als eigener Bereich.
</div>`;
$("#w-update").addEventListener("click", update);
$("#w-unload").addEventListener("click", unloadAll);
}
async function update() {
try {
const r = await api("/api/update", { method: "POST" });
toast("Update läuft.");
track(r.job_id);
} catch (e) { toast(e.message, true); }
}
async function unloadAll() {
try {
await api("/api/unload", { method: "POST" });
toast("Alle Modelle entladen.");
setTimeout(() => document.dispatchEvent(new Event("mc:refresh")), 600);
} catch (e) { toast(e.message, true); }
}
export default { id: "maintenance", mount };
+123
View File
@@ -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 = `
<div class="card-h"><h3>Modell holen</h3></div>
<label>HuggingFace-Repo</label>
<input id="dl-repo" placeholder="unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF">
<label>Datei (GGUF)</label>
<input id="dl-file" placeholder="Q4_K_M/Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf">
<button class="primary" id="dl-btn">Modell herunterladen</button>
<div id="register-box" style="display:none;margin-top:16px;border-top:1px solid var(--line);padding-top:14px">
<div class="card-h"><h3>Einpflegen</h3></div>
<div class="row">
<div><label>Alias</label><input id="rg-alias"></div>
<div><label>Kontext</label><input id="rg-ctx" value="8192"></div>
</div>
<input id="rg-path" class="mono-sm" readonly>
<button class="primary" id="rg-btn">In Config eintragen</button>
</div>`;
$("#m-chat").innerHTML = `
<div class="card-h"><h3>Schnelltest</h3></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>
<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>
<table>
<thead><tr><th>Modell</th><th>Status</th><th>Port</th><th style="text-align:right">Aktion</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>`;
$("#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 = `<td class="mid">${esc(m.name)}</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>`);
}
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 };
+117
View File
@@ -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 ? `<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>`;
}
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();
$("#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("muted", "System-Last", "gauge", "n/a", "bald · Live-Auslastung");
}
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();
$("#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" : "")}
${kvRow("Auslastung (RAM/GPU/Disk)", "folgt", "na")}
</div>`;
}
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 `<div class="li">
<span class="li-dot ${dot}"></span>
<div class="li-main">
<div class="li-id">${esc(m.name)}</div>
<div class="li-sub">TTL ${m.ttl ?? "—"}${typeof m.ttl === "number" ? "s" : ""}</div>
</div>
<div class="li-right">
<div class="li-meta">${m.port ?? "auto"}</div>
<div class="li-time">${state}</div>
</div>
</div>`;
}
function renderModels() {
const models = S?.models || [];
$("#ov-models").innerHTML = `
<div class="card-h"><h3>Modelle</h3><span class="meta">${models.length || ""}</span></div>
${models.length
? `<div class="list">${models.map(modelRow).join("")}</div>`
: `<div class="empty-c"><div class="e-t">Keine Modelle konfiguriert</div>
<div class="e-s">Hol dir unter „Modelle" eins von HuggingFace.</div></div>`}`;
}
function renderAll() { renderHero(); renderKpis(); renderHealth(); renderModels(); }
function mount() { renderAll(); }
function onStatus(s) { S = s; renderAll(); }
function onJobs(jobs) { J = jobs || []; renderHero(); renderKpis(); renderHealth(); }
export default { id: "overview", mount, onStatus, onJobs };