feat(2.0): Phase 2 — System/OS + Connect

System: services/system.py (psutil CPU/RAM/Disk, sysfs GPU/Temp guarded),
routers/system.py GET status + restart (Whitelist) + self-update (systemd-
USER, sudo-frei). Connect: services/connect.py (Cline/OpenCode/Zed/Continue/
Claude Code/Memory-MCP → Gateway model:auto, LAN-IP-Override), routers/
connect.py. Frontend: SystemView (Metrik-Bars) + ConnectView (Tool-Tabs,
Copy, IP-Override).

Lokal verifiziert: Backend-Smoke + Frontend-Build + Browser (System-Bars,
Connect-Snippets). Docs aktualisiert (README/STATUS/Plan).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-25 08:08:43 +02:00
parent cff3f0b1a8
commit 6c8b6d81fe
17 changed files with 617 additions and 150 deletions
+6 -2
View File
@@ -7,7 +7,7 @@ siehe Architektur-Plan (`docs/` bzw. der genehmigte Plan).
Mission Control 2.0 (FastAPI + React/shadcn) · Hermes Agent + hermes-webui ·
Shared Memory (SQLite via MCP). Jede Schicht hinter stabilem Vertrag austauschbar.
## Status: Phase 1 (Engine + Routing) ✅
## Status: Phase 2 (System/OS + Connect) ✅
Fortschritt & Resume-Guide: siehe [`docs/STATUS.md`](docs/STATUS.md).
@@ -16,7 +16,11 @@ Fortschritt & Resume-Guide: siehe [`docs/STATUS.md`](docs/STATUS.md).
**Engine-Write** (register + `groups`/Ko-Residenz), **LiteLLM-Gateway** (Config + Service,
`model: auto` + Fallbacks), Frontend **Modelle & Routing** (Caps-Chips, Fit, Discover, Routing-View).
API: `health · models · discover · fit · models/register · groups · routing` (Details in `docs/STATUS.md`).
- **Phase 2** — System-Status (CPU/RAM/GPU/Disk), Wartung (restart/self-update, sudo-frei),
**Connect** (saubere IDE-Snippets → Gateway `model:auto`, LAN-IP-Override).
API: `health · models · discover · fit · models/register · groups · routing · system/status ·
system/restart · system/self-update · connect` (Details in `docs/STATUS.md`).
## Entwickeln
+3 -1
View File
@@ -13,7 +13,7 @@ from fastapi.staticfiles import StaticFiles
from starlette.requests import Request
from config import FRONTEND_DIST, VERSION
from routers import health, models, routing
from routers import connect, health, models, routing, system
app = FastAPI(title="Mission Control 2.0", version=VERSION)
@@ -37,6 +37,8 @@ async def no_cache(request: Request, call_next):
app.include_router(health.router)
app.include_router(models.router)
app.include_router(routing.router)
app.include_router(system.router)
app.include_router(connect.router)
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
+1 -1
View File
@@ -44,7 +44,7 @@ PORT = int(os.environ.get("MC_PORT", "9000"))
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
# Version (Phase 0 — Greenfield-Skeleton).
VERSION = "2.0.0-phase1"
VERSION = "2.0.0-phase2"
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
yaml = YAML()
+12
View File
@@ -0,0 +1,12 @@
"""Connect-Endpoint: erzeugt IDE-/Agent-Snippets (auf den Gateway + Memory-MCP)."""
from fastapi import APIRouter
from services.connect import DEFAULT_HOST, build_snippets
router = APIRouter(prefix="/api")
@router.get("/connect")
def connect(host: str = DEFAULT_HOST) -> dict:
return build_snippets(host=host)
+55
View File
@@ -0,0 +1,55 @@
"""System-Endpoints: Live-Status + Wartung (Restart/Self-Update — auf der Box).
Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern).
Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler
zurückgegeben statt zu crashen.
"""
import os
import subprocess
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services.system import system_status
router = APIRouter(prefix="/api")
# Nur diese User-Dienste dürfen neugestartet werden.
ALLOWED_SERVICES = {"mission-control-2", "litellm-gateway", "hermes-webui"}
# Quelle für Self-Update (auf der Box ~/mission-control-v2).
SOURCE_DIR = os.path.expanduser(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2"))
@router.get("/system/status")
def status() -> dict:
return system_status()
def _run(cmd: list[str], cwd: str | None = None) -> dict:
try:
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
return {"ok": p.returncode == 0, "code": p.returncode,
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
except Exception as exc: # noqa: BLE001
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
class RestartReq(BaseModel):
service: str
@router.post("/system/restart")
def restart(req: RestartReq) -> dict:
if req.service not in ALLOWED_SERVICES:
raise HTTPException(400, f"Dienst '{req.service}' nicht erlaubt.")
return _run(["systemctl", "--user", "restart", req.service])
@router.post("/system/self-update")
def self_update() -> dict:
"""git pull (Source) → venv-Deps → Dienst-Restart. Auf der Box; lokal Fehler."""
pull = _run(["git", "fetch", "--all"], cwd=SOURCE_DIR)
reset = _run(["git", "reset", "--hard", "origin/main"], cwd=SOURCE_DIR)
restart_res = _run(["systemctl", "--user", "restart", "mission-control-2"])
return {"pull": pull, "reset": reset, "restart": restart_res}
+105
View File
@@ -0,0 +1,105 @@
"""
Connect: erzeugt saubere, getestete Konfig-Snippets für IDEs/Agenten auf dem
LOKALEN PC (separate Maschine im LAN). Alle zeigen auf den **Gateway** der Box
(`model: auto`, LiteLLM :4000) + den **Shared-Memory-MCP** (MC :9000).
Wichtig: Host ist die LAN-IP der Box (NICHT eine Proxy-Domain) — das war in v1
die häufigste Fehlerquelle. Der Aufrufer übergibt den Host explizit.
"""
import json
DEFAULT_HOST = "192.168.178.151"
GATEWAY_PORT = 4000
MC_PORT = 9000
# Modelle, die der Gateway anbietet (model:auto = Standard).
GATEWAY_MODELS = ["auto", "fast", "heavy", "coder", "vision"]
def _gw(host: str) -> str:
return f"http://{host}:{GATEWAY_PORT}/v1"
def build_snippets(host: str = DEFAULT_HOST,
mcp_script_path: str = r"C:\\Users\\TobisPC\\mission-control-v2\\mcp\\mcp_memory.py",
mcp_python: str = "python") -> dict:
gw = _gw(host)
mc_url = f"http://{host}:{MC_PORT}"
cline = json.dumps({
"apiProvider": "openai",
"openAiBaseUrl": gw,
"openAiApiKey": "local",
"openAiModelId": "auto",
}, indent=2)
opencode = json.dumps({
"providers": {
"litellm": {
"npm": "@ai-sdk/openai-compatible",
"name": "Bosgame Gateway",
"options": {"baseURL": gw, "apiKey": "local"},
"models": {m: {"name": m} for m in GATEWAY_MODELS},
}
}
}, indent=2)
zed = json.dumps({
"language_models": {
"openai_compatible": {
"bosgame": {
"api_url": gw,
"available_models": [
{"name": m, "display_name": m, "max_tokens": 131072,
"capabilities": {"tools": True}} for m in GATEWAY_MODELS
],
}
}
}
}, indent=2)
cont = json.dumps({
"models": [
{"title": f"Bosgame / {m}", "provider": "openai", "model": m,
"apiBase": gw, "apiKey": "local"} for m in ("auto", "coder", "heavy")
]
}, indent=2)
# Claude Code: Anthropic-Format → LiteLLM kann /v1/messages anbieten.
claude_code = (
f'# Claude Code gegen den lokalen Gateway (Anthropic-kompatibel via LiteLLM):\n'
f'export ANTHROPIC_BASE_URL="http://{host}:{GATEWAY_PORT}"\n'
f'export ANTHROPIC_API_KEY="local"\n'
f'export ANTHROPIC_MODEL="auto"\n'
f'# (LiteLLM muss den Anthropic-/v1/messages-Endpunkt aktiviert haben — auf der Box prüfen.)'
)
memory_mcp = json.dumps({
"mcpServers": {
"mission-control-memory": {
"command": mcp_python,
"args": [mcp_script_path],
"env": {"MC_URL": mc_url},
}
}
}, indent=2)
return {
"host": host,
"gateway_url": gw,
"tools": {
"cline": {"label": "Cline (VS Code)", "lang": "json", "snippet": cline,
"note": "OpenAI-Provider → Gateway. Modell 'auto' (schnell, eskaliert bei Bedarf)."},
"opencode": {"label": "OpenCode", "lang": "jsonc", "snippet": opencode,
"note": "Datei opencode.jsonc, Key 'providers'."},
"zed": {"label": "Zed", "lang": "json", "snippet": zed,
"note": "settings.json → language_models.openai_compatible."},
"continue": {"label": "Continue", "lang": "json", "snippet": cont,
"note": "~/.continue/config.json."},
"claude_code": {"label": "Claude Code", "lang": "bash", "snippet": claude_code,
"note": "Anthropic-Format über LiteLLM /v1/messages."},
"memory_mcp": {"label": "Shared Memory (MCP)", "lang": "json", "snippet": memory_mcp,
"note": "Für jedes MCP-fähige Tool. mcp_memory.py muss lokal liegen."},
},
}
+72
View File
@@ -0,0 +1,72 @@
"""
System/OS-Metriken für die Box (Bosgame / Strix Halo).
CPU/RAM/Disk via psutil (plattformübergreifend). GPU-Auslastung/VRAM/Temperatur
via sysfs (amdgpu) — nur Linux; auf anderen Plattformen None (amd-smi fehlt auf
der Box, daher sysfs). Verschachtelte Struktur wie v1 (cpu.percent, ram.used Bytes).
"""
import glob
import os
import psutil
from config import MODELS_DIR
def _read_int(path: str) -> int | None:
try:
with open(path) as f:
return int(f.read().strip())
except Exception:
return None
def _gpu_sysfs() -> dict | None:
"""AMD-GPU-Auslastung + VRAM via sysfs (Linux). None, wenn nicht vorhanden."""
base = "/sys/class/drm/card0/device"
if not os.path.isdir(base):
return None
busy = _read_int(f"{base}/gpu_busy_percent")
vram_used = _read_int(f"{base}/mem_info_vram_used")
vram_total = _read_int(f"{base}/mem_info_vram_total")
if busy is None and vram_used is None:
return None
return {"busy_percent": busy, "vram_used": vram_used, "vram_total": vram_total}
def _temps() -> dict | None:
"""CPU/GPU-Temperatur via hwmon (Linux). None bei Fehlen."""
out: dict = {}
for hw in glob.glob("/sys/class/hwmon/hwmon*"):
name = ""
try:
with open(f"{hw}/name") as f:
name = f.read().strip()
except Exception:
continue
t = _read_int(f"{hw}/temp1_input")
if t is None:
continue
c = round(t / 1000.0, 1)
if name in ("k10temp", "zenpower", "coretemp"):
out["cpu"] = c
elif name in ("amdgpu", "edge"):
out["gpu"] = c
return out or None
def system_status() -> dict:
vm = psutil.virtual_memory()
try:
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
disk = {"total": du.total, "used": du.used, "percent": du.percent}
except Exception:
disk = None
return {
"cpu": {"percent": psutil.cpu_percent(interval=0.1), "cores": psutil.cpu_count()},
"ram": {"total": vm.total, "used": vm.used, "percent": vm.percent},
"gpu": _gpu_sysfs(),
"temp": _temps(),
"disk": disk,
}
+8 -2
View File
@@ -14,7 +14,9 @@
- [x] **Phase 1 — Engine + Routing:** Compute-Module (fit/caps/sources), Discover (live HF), Engine-Write
(register + groups/Ko-Residenz), LiteLLM-Gateway-Config + Service, Frontend Modelle&Routing
(Caps-Chips, Fit, Discover-Tab, Routing-View). Lokal verifiziert (Backend-Smoke + Frontend-Build + Browser).
- [ ] **Phase 2 — System/OS + Connect** (Metriken, Dienste, Update/Self-Update, Connect-Snippets).
- [x] **Phase 2 — System/OS + Connect:** System-Status (psutil CPU/RAM/Disk, sysfs GPU/Temp guarded),
Wartung (restart/self-update als systemd-USER-Dienst, sudo-frei), Connect-Snippets (Cline/OpenCode/
Zed/Continue/Claude Code/Memory-MCP → Gateway `model:auto` + LAN-IP-Override). Lokal verifiziert.
- [ ] **Phase 3 — Memory + MCP** (Governance-UI, mcp_memory.py portiert, mcp_mc.py neu).
- [ ] **Phase 4 — Hermes-Schicht** (hermes-webui Dienst, Brain=auto, Tools/MCP verdrahten). **Braucht Box.**
- [ ] **Phase 5 — Betrieb/Observability/Politur** (Backup, LiteLLM-Traces, Health).
@@ -40,7 +42,11 @@ cd frontend && npm run build
- `POST /api/models/register` — GGUF als llama-swap-Modell eintragen (cmd + Rolle-Alias, optional jinja)
- `GET/PUT /api/groups` — llama-swap-`groups` (Ko-Residenz `swap:false`)
- `GET /api/routing`, `PUT /api/routing/route` — LiteLLM-Gateway-Mapping
- `GET /api/system/status` — CPU/RAM/GPU/Disk/Temp
- `POST /api/system/restart` (Whitelist), `POST /api/system/self-update` — Wartung (Box, sudo-frei)
- `GET /api/connect?host=` — IDE-/Agent-Snippets (Gateway + Memory-MCP)
## Nächster sinnvoller Schritt
Phase 2 (System/OS + Connect) lokal bauen — **oder** Box-Deploy einrichten (systemd-USER-Dienst,
Phase 3 (Memory + MCP) lokal bauen: Memory-Governance-UI, `mcp_memory.py` portieren (Shared SQLite),
`mcp_mc.py` neu (Stack-Management-Tools für Hermes). **Oder** Box-Deploy einrichten (systemd-USER-Dienst,
sudo-frei). Beim Box-Deploy: `deploy/deploy.sh` vorher auf User-Dienst umstellen (kein /opt/sudo).
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="theme-color" content="#0d1117" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-BeAucPZJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-NuZDhqZy.css">
<script type="module" crossorigin src="/assets/index-CzNBzGRb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DSNYfW6y.css">
</head>
<body>
<div id="root"></div>
+5 -1
View File
@@ -4,6 +4,8 @@ import { NAV, type ViewId } from "@/nav"
import { CommandPalette } from "@/components/CommandPalette"
import { ModelsView } from "@/views/ModelsView"
import { RoutingView } from "@/views/RoutingView"
import { SystemView } from "@/views/SystemView"
import { ConnectView } from "@/views/ConnectView"
import { Placeholder } from "@/views/Placeholder"
import { api, type Health } from "@/lib/api"
import { cn } from "@/lib/utils"
@@ -90,7 +92,9 @@ export default function App() {
<main className="flex-1 overflow-y-auto p-6">
{view === "models" && <ModelsView />}
{view === "routing" && <RoutingView />}
{view !== "models" && view !== "routing" && (
{view === "system" && <SystemView />}
{view === "connect" && <ConnectView />}
{!["models", "routing", "system", "connect"].includes(view) && (
<Placeholder title={active.label} hint={active.hint} />
)}
</main>
+20
View File
@@ -80,6 +80,26 @@ export interface RoutingResp {
gateway_reachable: boolean
}
export interface SystemStatus {
cpu: { percent: number; cores: number | null }
ram: { total: number; used: number; percent: number }
gpu: { busy_percent: number | null; vram_used: number | null; vram_total: number | null } | null
temp: { cpu?: number; gpu?: number } | null
disk: { total: number; used: number; percent: number } | null
}
export interface ConnectTool {
label: string
lang: string
snippet: string
note: string
}
export interface ConnectResp {
host: string
gateway_url: string
tools: Record<string, ConnectTool>
}
export interface Health {
status: string
version: string
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, useState } from "react"
import { Check, Copy } from "lucide-react"
import { api, type ConnectResp } from "@/lib/api"
import { cn } from "@/lib/utils"
export function ConnectView() {
const [host, setHost] = useState(localStorage.getItem("mc_host") || "192.168.178.151")
const [data, setData] = useState<ConnectResp | null>(null)
const [active, setActive] = useState("cline")
const [copied, setCopied] = useState(false)
const [error, setError] = useState("")
useEffect(() => {
api<ConnectResp>(`/api/connect?host=${encodeURIComponent(host)}`)
.then(setData)
.catch((e) => setError(String(e)))
}, [host])
function saveHost(v: string) {
setHost(v)
if (v) localStorage.setItem("mc_host", v)
}
const tool = data?.tools[active]
async function copy() {
if (!tool) return
await navigator.clipboard.writeText(tool.snippet)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold">Verbinden</h1>
<p className="text-sm text-muted-foreground">
Fertige Snippets für deine Tools auf dem lokalen PC alle zeigen auf den Gateway der Box
(<code>model: auto</code>) + das geteilte Gedächtnis.
</p>
</div>
<div className="flex items-center gap-2 text-sm">
<label className="text-muted-foreground">Box-LAN-IP:</label>
<input
value={host}
onChange={(e) => saveHost(e.target.value)}
className="rounded-md border border-border bg-card px-2 py-1 font-mono text-xs outline-none focus:ring-2 focus:ring-ring"
/>
{data && <span className="text-xs text-muted-foreground"> {data.gateway_url}</span>}
</div>
{error && (
<div className="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground">
Snippets nicht ladbar ({error}).
</div>
)}
{data && (
<>
<div className="flex flex-wrap gap-1">
{Object.entries(data.tools).map(([key, t]) => (
<button
key={key}
onClick={() => setActive(key)}
className={cn(
"rounded-md px-3 py-1.5 text-sm transition-colors",
active === key ? "bg-primary/15 text-primary" : "text-muted-foreground hover:text-foreground",
)}
>
{t.label}
</button>
))}
</div>
{tool && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{tool.note}</span>
<button
onClick={copy}
className="flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent"
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? "Kopiert" : "Kopieren"}
</button>
</div>
<pre className="overflow-x-auto rounded-xl border border-border bg-card p-4 text-xs leading-relaxed">
<code>{tool.snippet}</code>
</pre>
</div>
)}
</>
)}
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState } from "react"
import { api, type SystemStatus } from "@/lib/api"
function gb(b: number) {
return (b / 1024 ** 3).toFixed(1)
}
function Bar({ label, percent, detail }: { label: string; percent: number; detail?: string }) {
return (
<div className="rounded-lg border border-border bg-card p-4">
<div className="flex items-baseline justify-between">
<span className="text-sm font-medium">{label}</span>
<span className="text-sm text-muted-foreground">{Math.round(percent)}%</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary" style={{ width: `${Math.min(percent, 100)}%` }} />
</div>
{detail && <div className="mt-1 text-xs text-muted-foreground">{detail}</div>}
</div>
)
}
export function SystemView() {
const [s, setS] = useState<SystemStatus | null>(null)
const [error, setError] = useState("")
useEffect(() => {
const load = () => api<SystemStatus>("/api/system/status").then(setS).catch((e) => setError(String(e)))
load()
const t = setInterval(load, 3000)
return () => clearInterval(t)
}, [])
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold">System</h1>
<p className="text-sm text-muted-foreground">Live-Auslastung der Box, Dienste &amp; Updates.</p>
</div>
{error && (
<div className="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground">
System-Status nicht lesbar ({error}).
</div>
)}
{s && (
<div className="grid gap-3 sm:grid-cols-2">
<Bar label="CPU" percent={s.cpu.percent} detail={s.cpu.cores ? `${s.cpu.cores} Kerne` : undefined} />
<Bar
label="RAM"
percent={s.ram.percent}
detail={`${gb(s.ram.used)} / ${gb(s.ram.total)} GB`}
/>
{s.gpu && s.gpu.busy_percent != null && (
<Bar
label="GPU"
percent={s.gpu.busy_percent}
detail={
s.gpu.vram_used != null && s.gpu.vram_total != null
? `${gb(s.gpu.vram_used)} / ${gb(s.gpu.vram_total)} GB VRAM`
: undefined
}
/>
)}
{s.disk && (
<Bar label="Disk (Modelle)" percent={s.disk.percent} detail={`${gb(s.disk.used)} / ${gb(s.disk.total)} GB`} />
)}
</div>
)}
{s?.temp && (s.temp.cpu || s.temp.gpu) && (
<div className="flex gap-3 text-sm text-muted-foreground">
{s.temp.cpu != null && <span>CPU {s.temp.cpu} °C</span>}
{s.temp.gpu != null && <span>GPU {s.temp.gpu} °C</span>}
</div>
)}
</div>
)
}