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 ·
|
||||
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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
(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).
|
||||
|
||||
-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" />
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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