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
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>
)
}