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