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:
@@ -1,3 +1,4 @@
|
|||||||
.venv/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
.claude/launch.json
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.")
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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]
|
||||||
@@ -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}
|
||||||
@@ -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}")
|
||||||
@@ -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)}
|
||||||
@@ -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
@@ -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 & 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,"<")}</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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 '<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] || ""; }
|
||||||
@@ -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);
|
||||||
@@ -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 & 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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 & 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 };
|
||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user