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:
@@ -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