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:
@@ -7,7 +7,7 @@ siehe Architektur-Plan (`docs/` bzw. der genehmigte Plan).
|
|||||||
Mission Control 2.0 (FastAPI + React/shadcn) · Hermes Agent + hermes-webui ·
|
Mission Control 2.0 (FastAPI + React/shadcn) · Hermes Agent + hermes-webui ·
|
||||||
Shared Memory (SQLite via MCP). Jede Schicht hinter stabilem Vertrag austauschbar.
|
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).
|
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,
|
**Engine-Write** (register + `groups`/Ko-Residenz), **LiteLLM-Gateway** (Config + Service,
|
||||||
`model: auto` + Fallbacks), Frontend **Modelle & Routing** (Caps-Chips, Fit, Discover, Routing-View).
|
`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
|
## Entwickeln
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -13,7 +13,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
from config import FRONTEND_DIST, VERSION
|
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)
|
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(health.router)
|
||||||
app.include_router(models.router)
|
app.include_router(models.router)
|
||||||
app.include_router(routing.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.
|
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
||||||
|
|||||||
+1
-1
@@ -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")))
|
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
|
||||||
|
|
||||||
# Version (Phase 0 — Greenfield-Skeleton).
|
# 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).
|
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
|
||||||
yaml = YAML()
|
yaml = YAML()
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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}
|
||||||
@@ -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."},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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
@@ -14,7 +14,9 @@
|
|||||||
- [x] **Phase 1 — Engine + Routing:** Compute-Module (fit/caps/sources), Discover (live HF), Engine-Write
|
- [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
|
(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).
|
(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 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 4 — Hermes-Schicht** (hermes-webui Dienst, Brain=auto, Tools/MCP verdrahten). **Braucht Box.**
|
||||||
- [ ] **Phase 5 — Betrieb/Observability/Politur** (Backup, LiteLLM-Traces, Health).
|
- [ ] **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)
|
- `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/PUT /api/groups` — llama-swap-`groups` (Ko-Residenz `swap:false`)
|
||||||
- `GET /api/routing`, `PUT /api/routing/route` — LiteLLM-Gateway-Mapping
|
- `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
|
## 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).
|
sudo-frei). Beim Box-Deploy: `deploy/deploy.sh` vorher auf User-Dienst umstellen (kein /opt/sudo).
|
||||||
|
|||||||
-140
File diff suppressed because one or more lines are too long
+150
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
<meta name="theme-color" content="#0d1117" />
|
<meta name="theme-color" content="#0d1117" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<title>Mission Control 2.0</title>
|
<title>Mission Control 2.0</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BeAucPZJ.js"></script>
|
<script type="module" crossorigin src="/assets/index-CzNBzGRb.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-NuZDhqZy.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DSNYfW6y.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { NAV, type ViewId } from "@/nav"
|
|||||||
import { CommandPalette } from "@/components/CommandPalette"
|
import { CommandPalette } from "@/components/CommandPalette"
|
||||||
import { ModelsView } from "@/views/ModelsView"
|
import { ModelsView } from "@/views/ModelsView"
|
||||||
import { RoutingView } from "@/views/RoutingView"
|
import { RoutingView } from "@/views/RoutingView"
|
||||||
|
import { SystemView } from "@/views/SystemView"
|
||||||
|
import { ConnectView } from "@/views/ConnectView"
|
||||||
import { Placeholder } from "@/views/Placeholder"
|
import { Placeholder } from "@/views/Placeholder"
|
||||||
import { api, type Health } from "@/lib/api"
|
import { api, type Health } from "@/lib/api"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -90,7 +92,9 @@ export default function App() {
|
|||||||
<main className="flex-1 overflow-y-auto p-6">
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
{view === "models" && <ModelsView />}
|
{view === "models" && <ModelsView />}
|
||||||
{view === "routing" && <RoutingView />}
|
{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} />
|
<Placeholder title={active.label} hint={active.hint} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -80,6 +80,26 @@ export interface RoutingResp {
|
|||||||
gateway_reachable: boolean
|
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 {
|
export interface Health {
|
||||||
status: string
|
status: string
|
||||||
version: string
|
version: string
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 & 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user