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
+19 -254
View File
@@ -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/<bereich>.py + static/js/panels/<bereich>.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)