Frontend: SystemDrawer entflochten (Review P2-14, Teil 1) — 934 -> 665 Zeilen

- components/system/rows.tsx: SERVICES + UpdateRow + ServiceRow + formatBytes
- components/system/SettingsTab.tsx: Einstellungen-Tab inkl. eigenem State (localStorage)
- components/system/UpdateDetailModal.tsx: Update-Detail-Fenster (os/engine/swap/hermes)
- Preview-verifiziert gegen die Live-Box: alle 3 Tabs rendern, Speichern-Flow
  (Dialog + localStorage) und Logs-Tab (Service-Select) funktionieren

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-02 14:42:52 +02:00
parent a16287e19c
commit e84386b03e
4 changed files with 317 additions and 280 deletions
+10 -280
View File
@@ -1,8 +1,11 @@
import { useEffect, useState, useRef } from "react"
import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power, Eye, EyeOff, Key, AlertTriangle, Camera, Bot, Box, FileText, Package, GitCommit, ExternalLink, ArrowRight, Shuffle } from "lucide-react"
import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power, AlertTriangle, Camera, Bot, Box, Shuffle } from "lucide-react"
import { api, type Job, type UpdatesResp, type ServicesResp, type UpdateDetails } from "@/lib/api"
import { cn } from "@/lib/utils"
import { CustomDialog } from "./CustomDialog"
import { SERVICES, ServiceRow, UpdateRow, formatBytes } from "./system/rows"
import { SettingsTab } from "./system/SettingsTab"
import { UpdateDetailModal, type UpdateDetailState } from "./system/UpdateDetailModal"
interface SystemDrawerProps {
@@ -11,58 +14,6 @@ interface SystemDrawerProps {
defaultTab?: "maintenance" | "logs" | "settings"
}
// systemd-Units (restart/logs) + reach = Stichwort zum Mappen auf /api/system/services.
const SERVICES = [
{ id: "mission-control-2", label: "Mission Control", type: "user", reach: "gateway (integr" },
{ id: "hermes-gateway", label: "Hermes Gateway", type: "user", reach: "hermes-gateway" },
{ id: "mem0-service", label: "Mem0 (Gedächtnis)", type: "user", reach: "mem0" },
{ id: "hermes-terminal", label: "Hermes Terminal", type: "user", reach: "hermes-terminal" },
{ id: "llama-swap", label: "Llama Swap", type: "system", reach: "llama-swap" },
]
function UpdateRow({ icon: Icon, iconClass, name, status, available, busy, actionLabel, onAction }: {
icon: any; iconClass: string; name: string; status: string; available: boolean; busy?: boolean; actionLabel: string; onAction: () => void
}) {
return (
<div className={cn("flex items-center gap-3 rounded-lg border px-3 py-2",
available ? "border-amber-500/30 bg-amber-500/5" : "border-border/50 bg-background/20")}>
<Icon className={cn("h-4 w-4 shrink-0", iconClass)} />
<div className="flex-1 min-w-0">
<div className="text-xs text-foreground truncate">{name}</div>
<div className={cn("text-[10px] truncate", available ? "text-amber-400/90" : "text-muted-foreground")}>{status}</div>
</div>
<button onClick={onAction} disabled={!available || busy}
className={cn("text-[11px] font-semibold rounded-lg px-2.5 py-1 transition-colors shrink-0",
available ? "bg-primary text-primary-foreground hover:opacity-90 cursor-pointer" : "border border-border/50 text-muted-foreground/40 cursor-default")}>
{busy ? "…" : actionLabel}
</button>
</div>
)
}
function ServiceRow({ label, ok, system, busy, onRestart, onLogs }: {
label: string; ok?: boolean; system?: boolean; busy?: boolean; onRestart: () => void; onLogs: () => void
}) {
return (
<div className="flex items-center gap-2.5 rounded-lg border border-border/50 bg-background/20 px-3 py-2">
<span className={cn("w-1.5 h-1.5 rounded-full shrink-0",
ok === true ? "bg-emerald-500" : ok === false ? "bg-red-500" : "bg-muted-foreground/40")} />
<span className="flex-1 text-xs text-foreground truncate">{label}{system && <span className="text-[9px] text-muted-foreground"> (root)</span>}</span>
<button onClick={onRestart} disabled={busy} title="Neu starten" className="text-muted-foreground hover:text-primary transition-colors disabled:opacity-50">
<RefreshCw className={cn("h-3.5 w-3.5", busy && "animate-spin")} />
</button>
<button onClick={onLogs} title="Logs ansehen" className="text-muted-foreground hover:text-primary transition-colors">
<FileText className="h-3.5 w-3.5" />
</button>
</div>
)
}
function formatBytes(bytes?: number) {
if (bytes == null) return ""
if (bytes > 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`
return `${(bytes / 1024 ** 2).toFixed(1)} MB`
}
export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: SystemDrawerProps) {
const [updates, setUpdates] = useState<UpdatesResp | null>(null)
@@ -81,7 +32,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
const [hermesUpdating, setHermesUpdating] = useState(false)
// Update-Detail-Fenster: zeigt VOR dem Anwenden, was genau aktualisiert wird.
const [detail, setDetail] = useState<{ kind: "os" | "engine" | "swap" | "hermes"; loading: boolean; data: UpdateDetails | null } | null>(null)
const [detail, setDetail] = useState<UpdateDetailState | null>(null)
// Custom Dialog State
const [dialog, setDialog] = useState<{
@@ -128,18 +79,6 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
})
}
// Credentials State
const [sudoPasswordInput, setSudoPasswordInput] = useState("")
const [hfTokenInput, setHfTokenInput] = useState("")
const [showSudo, setShowSudo] = useState(false)
const [showHf, setShowHf] = useState(false)
useEffect(() => {
if (open) {
setSudoPasswordInput(localStorage.getItem("mc_sudo_password") || "")
setHfTokenInput(localStorage.getItem("mc_hf_token") || "")
}
}, [open])
useEffect(() => {
if (open && defaultTab) {
@@ -702,223 +641,14 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
</div>
)}
{activeTab === "settings" && (
<div className="space-y-6">
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Zugangsdaten & Schlüssel</h3>
<p className="text-[10px] text-muted-foreground leading-normal">
Diese Daten werden ausschließlich lokal in deinem Browser (localStorage) gespeichert und bei Bedarf an das Backend übertragen.
</p>
</div>
{/* Sudo Passwort */}
<div className="space-y-2">
<label className="text-xs font-semibold flex items-center gap-1.5">
<Shield className="h-4 w-4 text-amber-400" />
Host Sudo-Passwort
</label>
<div className="relative">
<input
type={showSudo ? "text" : "password"}
value={sudoPasswordInput}
onChange={(e) => setSudoPasswordInput(e.target.value)}
aria-label="Host Sudo-Passwort"
name="sudo-password"
autoComplete="off"
spellCheck={false}
placeholder="Sudo-Passwort für System-Operationen"
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
/>
<button
type="button"
onClick={() => setShowSudo(!showSudo)}
aria-label={showSudo ? "Passwort verbergen" : "Passwort anzeigen"}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
>
{showSudo ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
</button>
</div>
<p className="text-[9px] text-muted-foreground leading-normal">
Wird benötigt für systemd-Dienste (Llama Swap logs/restart), OS-Update (apt) und Reboot.
</p>
</div>
{/* HuggingFace Token */}
<div className="space-y-2">
<label className="text-xs font-semibold flex items-center gap-1.5">
<Key className="h-4 w-4 text-violet-400" />
HuggingFace API Token
</label>
<div className="relative">
<input
type={showHf ? "text" : "password"}
value={hfTokenInput}
onChange={(e) => setHfTokenInput(e.target.value)}
aria-label="HuggingFace API Token"
name="hf-token"
autoComplete="off"
spellCheck={false}
placeholder="hf_…"
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
/>
<button
type="button"
onClick={() => setShowHf(!showHf)}
aria-label={showHf ? "Token verbergen" : "Token anzeigen"}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
>
{showHf ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
</button>
</div>
<p className="text-[9px] text-muted-foreground leading-normal">
Erlaubt das Herunterladen von geschützten oder Gate-restricted Modellen direkt über Mission Control.
</p>
</div>
{/* Buttons */}
<div className="flex gap-3 pt-2">
<button
onClick={() => {
localStorage.setItem("mc_sudo_password", sudoPasswordInput);
localStorage.setItem("mc_hf_token", hfTokenInput);
showAlert("Erfolgreich", "Einstellungen erfolgreich lokal gespeichert.");
}}
className="flex-1 h-9 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg transition-colors shadow shadow-primary/10 cursor-pointer"
>
Speichern
</button>
<button
onClick={() => {
setSudoPasswordInput("");
setHfTokenInput("");
localStorage.removeItem("mc_sudo_password");
localStorage.removeItem("mc_hf_token");
showAlert("Gelöscht", "Zugangsdaten gelöscht.");
}}
className="h-9 px-4 flex items-center justify-center text-xs font-semibold text-muted-foreground border border-border/60 bg-background/20 hover:bg-accent rounded-lg transition-colors cursor-pointer"
>
Zurücksetzen
</button>
</div>
</div>
)}
{activeTab === "settings" && <SettingsTab open={open} showAlert={showAlert} />}
</div>
</div>
{detail && (() => {
const d = detail.data
const meta = {
os: { icon: Shield, cls: "text-cyan-400", title: "OS-Pakete (apt)" },
engine: { icon: Server, cls: "text-violet-400", title: "Inferenz-Engine (llama.cpp)" },
swap: { icon: Shuffle, cls: "text-fuchsia-400", title: "Router (llama-swap)" },
hermes: { icon: Bot, cls: "text-amber-400", title: "Hermes-Agent" },
}[detail.kind]
const Icon = meta.icon
const nothing = !d ? true
: detail.kind === "os" ? (d.count ?? 0) === 0
: detail.kind === "hermes" ? (d.behind ?? 0) === 0
: (d.installed_build != null && d.latest_build != null && d.latest_build <= d.installed_build)
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={() => setDetail(null)} />
<div className="relative w-full max-w-lg max-h-[80vh] flex flex-col rounded-2xl border border-border/60 bg-card shadow-2xl font-sans text-foreground">
{/* Header */}
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-5">
<div className="flex items-center gap-2">
<Icon className={cn("h-4.5 w-4.5", meta.cls)} />
<h3 className="text-sm font-semibold">{meta.title}</h3>
</div>
<button onClick={() => setDetail(null)} className="flex h-7 w-7 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors">
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-5 text-xs space-y-3 scrollbar-thin">
{detail.loading ? (
<div className="flex h-24 items-center justify-center gap-2 text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin" /> Details werden geladen
</div>
) : d?.error ? (
<div className="rounded-lg border border-red-500/30 bg-red-500/5 p-3 text-red-400">{d.error}</div>
) : detail.kind === "os" ? (
(d?.count ?? 0) === 0 ? (
<div className="text-muted-foreground">Keine Pakete zu aktualisieren System ist aktuell.</div>
) : (
<>
<div className="text-muted-foreground">{d!.count} Paket(e) werden aktualisiert:</div>
<div className="space-y-1">
{d!.packages!.map((p) => (
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
<span className="flex items-center gap-1.5 font-mono text-[11px] truncate">
<Package className="h-3 w-3 text-cyan-400 shrink-0" />{p.name}
</span>
<span className="flex items-center gap-1 font-mono text-[10px] text-muted-foreground shrink-0">
<span>{p.current}</span><ArrowRight className="h-3 w-3" /><span className="text-emerald-400">{p.candidate}</span>
</span>
</div>
))}
</div>
</>
)
) : detail.kind === "engine" || detail.kind === "swap" ? (
<>
<div className="flex items-center gap-2 font-mono text-[11px]">
<span className="rounded-md border border-border/40 bg-background/30 px-2 py-1">Build {d?.installed_build ?? "?"}</span>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
<span className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-2 py-1 text-emerald-400">Build {d?.latest_build ?? "?"}</span>
</div>
{(d?.name || d?.latest_tag) && (
<div className="text-muted-foreground">Release: <span className="text-foreground">{d?.name}</span>{d?.latest_tag ? ` (${d.latest_tag})` : ""}</div>
)}
{d?.url && (
<a href={d.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-primary hover:underline">
Release-Notes auf GitHub <ExternalLink className="h-3 w-3" />
</a>
)}
{d?.body && (
<pre className="whitespace-pre-wrap rounded-lg border border-border/40 bg-background/30 p-3 text-[10px] leading-relaxed text-muted-foreground max-h-60 overflow-y-auto scrollbar-thin">{d.body}</pre>
)}
</>
) : (
// hermes
(d?.commits?.length ?? 0) === 0 ? (
<div className="text-muted-foreground">Keine neuen Commits Hermes-Agent ist bereits aktuell.</div>
) : (
<>
<div className="text-muted-foreground">{d!.behind} neue Commit(s) auf <span className="font-mono text-foreground">origin/{d!.branch}</span>:</div>
<div className="space-y-1">
{d!.commits!.map((c) => (
<div key={c.hash} className="flex items-start gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
<GitCommit className="h-3.5 w-3.5 text-primary shrink-0 mt-0.5" />
<div className="min-w-0">
<div className="text-[11px] truncate">{c.subject}</div>
<div className="font-mono text-[9px] text-muted-foreground">{c.hash} · {c.when}</div>
</div>
</div>
))}
</div>
<p className="text-[10px] text-muted-foreground leading-normal">Vor dem Update wird automatisch ein Backup erstellt; danach startet der Hermes-Gateway neu.</p>
</>
)
)}
</div>
{/* Footer */}
<div className="flex gap-3 border-t border-border/40 p-4 shrink-0">
<button onClick={() => setDetail(null)} className="h-9 flex-1 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold text-muted-foreground hover:bg-accent transition-colors cursor-pointer">
Schließen
</button>
<button onClick={applyFromDetail} disabled={detail.loading || nothing || !!maintenanceJob}
title={maintenanceJob ? `Update läuft bereits: ${maintenanceJob.label}` : undefined}
className="h-9 flex-1 rounded-lg bg-primary text-xs font-semibold text-primary-foreground hover:opacity-90 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-default">
{maintenanceJob ? "Update läuft…" : "Jetzt aktualisieren"}
</button>
</div>
</div>
</div>
)
})()}
{detail && (
<UpdateDetailModal detail={detail} maintenanceJob={maintenanceJob}
onClose={() => setDetail(null)} onApply={applyFromDetail} />
)}
{dialog && (
<CustomDialog