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:
@@ -1,8 +1,11 @@
|
|||||||
import { useEffect, useState, useRef } from "react"
|
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 { api, type Job, type UpdatesResp, type ServicesResp, type UpdateDetails } from "@/lib/api"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { CustomDialog } from "./CustomDialog"
|
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 {
|
interface SystemDrawerProps {
|
||||||
@@ -11,58 +14,6 @@ interface SystemDrawerProps {
|
|||||||
defaultTab?: "maintenance" | "logs" | "settings"
|
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) {
|
export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: SystemDrawerProps) {
|
||||||
const [updates, setUpdates] = useState<UpdatesResp | null>(null)
|
const [updates, setUpdates] = useState<UpdatesResp | null>(null)
|
||||||
@@ -81,7 +32,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
|
|||||||
const [hermesUpdating, setHermesUpdating] = useState(false)
|
const [hermesUpdating, setHermesUpdating] = useState(false)
|
||||||
|
|
||||||
// Update-Detail-Fenster: zeigt VOR dem Anwenden, was genau aktualisiert wird.
|
// 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
|
// Custom Dialog State
|
||||||
const [dialog, setDialog] = useState<{
|
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(() => {
|
useEffect(() => {
|
||||||
if (open && defaultTab) {
|
if (open && defaultTab) {
|
||||||
@@ -702,223 +641,14 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === "settings" && (
|
{activeTab === "settings" && <SettingsTab open={open} showAlert={showAlert} />}
|
||||||
<div className="space-y-6">
|
</div>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Sudo Passwort */}
|
{detail && (
|
||||||
<div className="space-y-2">
|
<UpdateDetailModal detail={detail} maintenanceJob={maintenanceJob}
|
||||||
<label className="text-xs font-semibold flex items-center gap-1.5">
|
onClose={() => setDetail(null)} onApply={applyFromDetail} />
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</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>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{dialog && (
|
{dialog && (
|
||||||
<CustomDialog
|
<CustomDialog
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Eye, EyeOff, Key, Shield } from "lucide-react"
|
||||||
|
|
||||||
|
// Einstellungen-Tab des SystemDrawers (Review P2-14: extrahiert). Hält seinen State komplett
|
||||||
|
// selbst (localStorage-Zugangsdaten) — der Drawer liefert nur `open` (zum Neu-Laden) und
|
||||||
|
// `showAlert` (gemeinsamer Dialog).
|
||||||
|
export function SettingsTab({ open, showAlert }: {
|
||||||
|
open: boolean
|
||||||
|
showAlert: (title: string, message: string) => void
|
||||||
|
}) {
|
||||||
|
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])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { ArrowRight, Bot, ExternalLink, GitCommit, Package, RefreshCw, Server, Shield, Shuffle, X } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { Job, UpdateDetails } from "@/lib/api"
|
||||||
|
|
||||||
|
export type UpdateDetailState = { kind: "os" | "engine" | "swap" | "hermes"; loading: boolean; data: UpdateDetails | null }
|
||||||
|
|
||||||
|
// Update-Detail-Fenster des SystemDrawers (Review P2-14: extrahiert): zeigt VOR dem
|
||||||
|
// Anwenden, was genau aktualisiert wird (apt-Pakete, Engine-Builds, Hermes-Commits).
|
||||||
|
export function UpdateDetailModal({ detail, maintenanceJob, onClose, onApply }: {
|
||||||
|
detail: UpdateDetailState
|
||||||
|
maintenanceJob?: Job
|
||||||
|
onClose: () => void
|
||||||
|
onApply: () => void
|
||||||
|
}) {
|
||||||
|
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={onClose} />
|
||||||
|
<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={onClose} 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={onClose} 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={onApply} 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { RefreshCw, FileText } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Präsentations-Atome des SystemDrawers (Review P2-14: aus dem 934-Z-Monolithen extrahiert).
|
||||||
|
|
||||||
|
// systemd-Units (restart/logs) + reach = Stichwort zum Mappen auf /api/system/services.
|
||||||
|
export 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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user