Refactor: DashboardView in Karten zerlegen (Phase 4a)

DashboardView 849 -> ~40 Zeilen Orchestrator. Sechs eigenständige Karten unter
components/dashboard/, jede mit eigenem Daten-Hook (react-query, geteilte Keys):
- RadialGauge, SystemStatusCard (useSystemStatus)
- UpdatesCard (useUpdates+useJobs; OS/Engine-Update, Reboot, Modell-Upgrade,
  Sudo-Modal, invalidiert nach Aktionen)
- AgentStatusCard (useAgentStatus+useModels; Gehirn-Wechsel-Modal)
- RolesCard (useModels), MemoryInputCard (useMemory), TokenStatsCard (useTokenStats)

Kein manuelles useEffect+setInterval mehr; useDialog statt lokalem Dialog-State.
Verifiziert: tsc grün, Build grün, alle 6 Karten rendern, Live-Daten + Pricing-
Footer korrekt, keine Konsolenfehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-26 14:50:54 +02:00
parent 0266dc9e92
commit 7a11fd2846
11 changed files with 1175 additions and 1210 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-CSv49ImW.js"></script>
<script type="module" crossorigin src="/assets/index-CpG8j7ha.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DiSNgbNY.css">
</head>
<body>
@@ -0,0 +1,149 @@
import { useState } from "react"
import { Bot, ExternalLink, Cpu, Layers, X, Check } from "lucide-react"
import { api } from "@/lib/api"
import { useAgentStatus, useModels, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn, resolveExternalUrl } from "@/lib/utils"
export function AgentStatusCard() {
const qc = useQueryClient()
const { data: agent } = useAgentStatus(3_000)
const { data: modelsData } = useModels()
const { showAlert, dialogElement } = useDialog()
const [showBrainSelect, setShowBrainSelect] = useState(false)
const models = modelsData?.models ?? []
async function changeBrainModel(model: string) {
try {
await api("/api/agent/brain", { method: "POST", body: JSON.stringify({ model }) })
showAlert("Erfolgreich", `Hermes-Gehirn wurde auf '${model}' geändert. Der Gateway-Dienst wurde neu gestartet.`)
qc.invalidateQueries({ queryKey: qk.agentStatus })
setShowBrainSelect(false)
} catch (e: any) {
showAlert("Fehler", `Fehler beim Wechseln des Gehirns: ${e.message}`)
}
}
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Bot className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Hermes Agent</h2>
</div>
{agent?.webui_url && (
<a
href={resolveExternalUrl(agent.webui_url)}
target="_blank"
rel="noopener"
className={cn(
"flex items-center gap-1 px-3 py-1 rounded-lg text-xs font-medium transition-all",
agent.webui_reachable
? "bg-primary text-primary-foreground hover:opacity-90 shadow-md shadow-primary/20"
: "border border-border text-muted-foreground"
)}
>
<ExternalLink className="h-3 w-3" /> Hermes öffnen
</a>
)}
</div>
{agent ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
<div className="text-[10px] text-muted-foreground uppercase font-semibold">Gateway</div>
<div className="flex items-center gap-2 mt-1">
<span className={cn("h-2 w-2 rounded-full", agent.gateway_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
<span className="text-xs font-medium">{agent.gateway_reachable ? "Online" : "Offline"}</span>
</div>
</div>
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
<div className="text-[10px] text-muted-foreground uppercase font-semibold">WebUI</div>
<div className="flex items-center gap-2 mt-1">
<span className={cn("h-2 w-2 rounded-full", agent.webui_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
<span className="text-xs font-medium">{agent.webui_reachable ? "Online" : "Offline"}</span>
</div>
</div>
</div>
<div
onClick={() => setShowBrainSelect(true)}
className="p-3 bg-background/20 rounded-xl border border-border/40 hover:border-primary/45 hover:bg-background/30 transition-all duration-300 cursor-pointer group"
>
<div className="flex justify-between items-center">
<span className="text-[10px] text-muted-foreground uppercase font-semibold">Aktives Gehirn</span>
<button className="flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-primary border border-primary/20 bg-primary/10 hover:bg-primary/20 px-1.5 py-0.5 rounded transition-all cursor-pointer font-space">
<Cpu className="h-3 w-3" /> Ändern
</button>
</div>
<div className="text-xs font-medium mt-1 font-mono text-primary flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" />
{agent.brain_model ? `model: ${agent.brain_model}` : "model: auto"}
</div>
</div>
</div>
) : (
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">Lade Agenten-Status</div>
)}
</div>
<div className="mt-3 text-[10px] text-muted-foreground/80 border-t border-border/30 pt-3">
Gedächtnis & Stack-Tools via MCP gekoppelt.
</div>
{agent && showBrainSelect && (
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-primary flex items-center gap-1.5">
<Cpu className="h-4 w-4" />
<span>Hermes-Gehirn konfigurieren</span>
</h3>
<button
onClick={() => setShowBrainSelect(false)}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
Das Gehirn" ist das primäre LLM für Hermes. Wähle ein Modell aus deiner Bibliothek oder nutze ein Gateway-Alias (<code className="text-primary font-semibold">auto</code> / <code className="text-primary font-semibold">fast</code> / <code className="text-primary font-semibold">heavy</code>):
</p>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
{(["auto", "fast", "heavy", ...models.map(m => m.name.split("/").pop()?.replace(".gguf", "") || m.name)]).map((m) => {
const isAlias = ["auto", "fast", "heavy"].includes(m)
return (
<button
key={m}
onClick={() => changeBrainModel(m)}
className={cn(
"w-full text-left px-3 py-2.5 rounded-lg text-xs hover:bg-accent flex items-center justify-between font-mono cursor-pointer border border-border/30",
agent.brain_model === m || (!agent.brain_model && m === "auto")
? "text-primary font-bold bg-primary/10 border-primary/30"
: "text-foreground bg-background/20"
)}
>
<div className="flex flex-col text-left">
<span className="font-semibold truncate max-w-[280px]">{m}</span>
<span className="text-[9px] text-muted-foreground mt-0.5">
{isAlias ? "Gateway Routing Alias" : "Installiertes GGUF Modell"}
</span>
</div>
{(agent.brain_model === m || (!agent.brain_model && m === "auto")) && (
<Check className="h-4 w-4 shrink-0 text-primary" />
)}
</button>
)
})}
</div>
</div>
</div>
)}
{dialogElement}
</div>
)
}
@@ -0,0 +1,92 @@
import { useState } from "react"
import { Brain, Plus } from "lucide-react"
import { api } from "@/lib/api"
import { useMemory, useQueryClient } from "@/lib/queries"
export function MemoryInputCard() {
const qc = useQueryClient()
const { data: memories = [] } = useMemory({ limit: 3 })
const [memContent, setMemContent] = useState("")
const [memCat, setMemCat] = useState("stable")
const [savingMem, setSavingMem] = useState(false)
async function saveQuickMemory() {
if (!memContent.trim() || savingMem) return
setSavingMem(true)
try {
await api("/api/memory", {
method: "POST",
body: JSON.stringify({ content: memContent, category: memCat, source: "dashboard" }),
})
setMemContent("")
qc.invalidateQueries({ queryKey: ["memory"] })
} catch (e) {
console.error(e)
} finally {
setSavingMem(false)
}
}
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
<div>
<div className="flex items-center gap-2 mb-4">
<Brain className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Gedächtnis</h2>
</div>
<div className="space-y-3">
<textarea
value={memContent}
onChange={(e) => setMemContent(e.target.value)}
placeholder="Fakt / Regel im Pool speichern..."
rows={2}
className="w-full resize-none rounded-xl border border-border/50 bg-background/30 p-2 text-[10px] outline-none focus:ring-1 focus:ring-primary transition-all leading-normal"
/>
<div className="flex items-center gap-2 justify-between">
<select
value={memCat}
onChange={(e) => setMemCat(e.target.value)}
className="h-7 rounded-lg border border-border/50 bg-background/50 px-2 text-[10px] outline-none cursor-pointer"
>
<option value="stable">🔵 Fakt</option>
<option value="instruction">📋 Regel</option>
<option value="user">👤 User</option>
<option value="versioned">🟡 Version</option>
</select>
<button
onClick={saveQuickMemory}
disabled={!memContent.trim() || savingMem}
className="flex h-7 items-center gap-1 rounded-lg bg-primary px-3 text-[10px] font-semibold text-primary-foreground hover:opacity-90 disabled:opacity-50 transition-all cursor-pointer"
>
<Plus className="h-3.5 w-3.5" /> Speichern
</button>
</div>
</div>
<div className="mt-3.5 space-y-1.5">
<div className="text-[9px] text-muted-foreground uppercase font-bold tracking-wider">Zuletzt gespeichert:</div>
<div className="max-h-20 overflow-y-auto space-y-1 pr-1 scrollbar-thin">
{memories.length === 0 ? (
<div className="text-[10px] text-muted-foreground/75 py-1">Keine Einträge vorhanden.</div>
) : (
memories.map((m) => (
<div key={m.id} className="text-[10px] bg-background/10 border border-border/30 rounded p-1.5 flex items-start gap-1.5">
<span className="shrink-0 text-[8px] font-mono text-muted-foreground/80 px-1 py-0.5 rounded bg-muted/20">
{m.category}
</span>
<span className="truncate flex-1 text-muted-foreground hover:text-foreground transition-colors" title={m.content}>
{m.content}
</span>
</div>
))
)}
</div>
</div>
</div>
<div className="mt-4 text-[10px] text-muted-foreground/80 border-t border-border/30 pt-3">
Steht allen Clients per MCP zur Verfügung.
</div>
</div>
)
}
@@ -0,0 +1,27 @@
import { cn } from "@/lib/utils"
export function RadialGauge({ value, label, detail }: { value: number; label: string; detail?: string }) {
const radius = 24
const circ = 2 * Math.PI * radius
const offset = circ - (Math.min(value, 100) / 100) * circ
const strokeColor = value > 90
? "stroke-red-500"
: value > 75
? "stroke-amber-500"
: "stroke-primary"
return (
<div className="flex flex-col items-center gap-1.5 p-2 bg-background/20 rounded-xl border border-border/40">
<div className="relative flex h-16 w-16 items-center justify-center">
<svg className="absolute inset-0 h-full w-full -rotate-90">
<circle cx="32" cy="32" r={radius} className="stroke-muted fill-none" strokeWidth="4.5" />
<circle cx="32" cy="32" r={radius} className={cn("fill-none transition-all duration-700 ease-out", strokeColor)} strokeWidth="4.5" strokeDasharray={circ} strokeDashoffset={offset} strokeLinecap="round" />
</svg>
<span className="text-xs font-mono font-bold tracking-tight text-foreground">{Math.round(value)}%</span>
</div>
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">{label}</span>
{detail && <span className="text-[10px] font-mono text-muted-foreground/80">{detail}</span>}
</div>
)
}
@@ -0,0 +1,104 @@
import { Layers } from "lucide-react"
import { useModels } from "@/lib/queries"
import { cn } from "@/lib/utils"
const ROLES = ["fast", "heavy", "coder", "reasoning", "vision", "scout"]
export function RolesCard() {
const { data } = useModels(3_000)
const models = data?.models ?? []
const running = data?.running ?? []
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
<div>
<div className="flex items-center gap-2 mb-4">
<Layers className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Rollen-Belegung</h2>
</div>
<div className="space-y-2 max-h-48 overflow-y-auto pr-1 scrollbar-thin">
{ROLES.map((role) => {
const m = models.find((x) => x.role === role)
const isRunning = m ? running.includes(m.name) : false
return (
<div
key={role}
className={cn(
"flex items-center justify-between p-2 rounded-xl border transition-all duration-300",
isRunning
? "border-emerald-500/30 bg-emerald-500/5 shadow-sm shadow-emerald-500/5"
: m
? "border-primary/20 bg-primary/5"
: "border-border/30 bg-background/10 opacity-60"
)}
>
<div className="min-w-0 flex-1 mr-2">
<div className="flex items-center gap-2 min-w-0">
<span className={cn("text-[9px] font-semibold uppercase px-1.5 py-0.5 rounded font-mono border tracking-wider shrink-0",
role === "fast" ? "bg-cyan-500/15 text-cyan-400 border-cyan-500/25" :
role === "heavy" ? "bg-amber-500/15 text-amber-400 border-amber-500/25" :
role === "coder" ? "bg-violet-500/15 text-violet-400 border-violet-500/25" :
role === "reasoning" ? "bg-emerald-500/15 text-emerald-400 border-emerald-500/25" :
role === "vision" ? "bg-pink-500/15 text-pink-400 border-pink-500/25" :
"bg-teal-500/15 text-teal-400 border-teal-500/25"
)}>
{role}
</span>
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold truncate font-mono text-foreground">
{m ? m.name.split("/").pop()?.replace(/\.gguf$/i, "") : "nicht zugewiesen"}
</span>
{m && (
<div className="flex gap-1 items-center mt-0.5 flex-wrap">
{m.prompt_cache && (
<span className="text-[7px] leading-none font-mono font-bold bg-cyan-500/10 text-cyan-400 border border-cyan-500/20 px-1 py-0.5 rounded" title="Prompt Caching aktiv">
PC
</span>
)}
{m.spec_draft_model && (
<span className="text-[7px] leading-none font-mono font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-1 py-0.5 rounded" title={`Speculative Decoding aktiv (Draft: ${m.spec_draft_model})`}>
SPEC
</span>
)}
{m.parallel_slots > 1 && (
<span className="text-[7px] leading-none font-mono font-bold bg-violet-500/10 text-violet-400 border border-violet-500/20 px-1 py-0.5 rounded" title={`${m.parallel_slots} parallele Slots aktiv`}>
SLOTS: {m.parallel_slots}
</span>
)}
</div>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{m ? (
isRunning ? (
<span className="flex items-center gap-1 text-[8px] font-semibold text-emerald-400 uppercase tracking-wider font-mono">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> warm
</span>
) : (
<span className="text-[8px] font-semibold text-muted-foreground uppercase tracking-wider font-mono">
bereit
</span>
)
) : (
<span className="text-[8px] font-semibold text-muted-foreground/60 uppercase tracking-wider font-mono">
</span>
)}
</div>
</div>
)
})}
</div>
</div>
<div className="mt-4 text-[10px] text-muted-foreground/80 border-t border-border/30 pt-3">
Laden erfolgt automatisch per Auto-Swap.
</div>
</div>
)
}
@@ -0,0 +1,43 @@
import { Cpu } from "lucide-react"
import { useSystemStatus } from "@/lib/queries"
import { gb } from "@/lib/format"
import { RadialGauge } from "./RadialGauge"
export function SystemStatusCard() {
const { data: sys } = useSystemStatus(3_000)
return (
<div className="md:col-span-2 flex flex-col justify-between rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15">
<div>
<div className="flex items-center gap-2 mb-4">
<Cpu className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">System-Status</h2>
</div>
{sys ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<RadialGauge value={sys.cpu.percent} label="CPU" detail={sys.cpu.cores ? `${sys.cpu.cores} Cores` : undefined} />
<RadialGauge value={sys.ram.percent} label="RAM" detail={`${gb(sys.ram.used)} / ${gb(sys.ram.total)} GB`} />
{sys.gpu && sys.gpu.busy_percent != null && sys.gpu.gtt_used != null && sys.gpu.gtt_total != null && (
<RadialGauge
value={sys.gpu.busy_percent}
label="GPU"
detail={`${gb(sys.gpu.gtt_used)} / ${gb(sys.gpu.gtt_total)} GB`}
/>
)}
{sys.disk && (
<RadialGauge value={sys.disk.percent} label="Disk" detail={`${gb(sys.disk.used)} / ${gb(sys.disk.total)} GB`} />
)}
</div>
) : (
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">Lade Systemdaten</div>
)}
</div>
{sys?.temp && (sys.temp.cpu || sys.temp.gpu) && (
<div className="mt-4 flex gap-4 text-xs font-mono text-muted-foreground/80 border-t border-border/30 pt-3">
{sys.temp.cpu != null && <span>CPU Temp: {sys.temp.cpu} °C</span>}
{sys.temp.gpu != null && <span>GPU Temp: {sys.temp.gpu} °C</span>}
</div>
)}
</div>
)
}
@@ -0,0 +1,61 @@
import { Coins } from "lucide-react"
import { useTokenStats } from "@/lib/queries"
export function TokenStatsCard() {
const { data: tokenStats } = useTokenStats(3_000)
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
<div>
<div className="flex items-center gap-2 mb-4">
<Coins className="h-4.5 w-4.5 text-primary animate-pulse" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Effizienz &amp; Ersparnis</h2>
</div>
{tokenStats ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-2.5">
<div className="p-3 bg-background/20 rounded-xl border border-border/40 text-left">
<div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Geld gespart</div>
<div className="text-base font-bold text-emerald-400 mt-0.5 tracking-tight font-space">
{tokenStats.saved_eur.toLocaleString("de-DE", { minimumFractionDigits: 2 })}
</div>
<div className="text-[8px] text-muted-foreground/80 mt-0.5 font-mono">
({tokenStats.saved_usd.toLocaleString("en-US", { minimumFractionDigits: 2 })} $)
</div>
</div>
<div className="p-3 bg-background/20 rounded-xl border border-border/40 text-left">
<div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Gesamt-Tokens</div>
<div className="text-base font-bold text-primary mt-0.5 tracking-tight font-space">
{tokenStats.total_tokens.toLocaleString("de-DE")}
</div>
<div className="text-[8px] text-muted-foreground/80 mt-0.5 font-mono">
(Lokale Inferenz)
</div>
</div>
</div>
<div className="space-y-2 border-t border-border/20 pt-3 text-[10px] text-muted-foreground">
<div className="flex justify-between items-center font-mono">
<span>Input (Prompts):</span>
<span className="font-semibold text-foreground">{tokenStats.prompt_tokens.toLocaleString("de-DE")} tkn</span>
</div>
<div className="flex justify-between items-center font-mono">
<span>Output (Antworten):</span>
<span className="font-semibold text-foreground">{tokenStats.completion_tokens.toLocaleString("de-DE")} tkn</span>
</div>
</div>
</div>
) : (
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">Lade Statistiken</div>
)}
</div>
<div className="mt-4 text-[9px] text-muted-foreground/70 border-t border-border/30 pt-2.5 leading-normal">
Berechnet im Vergleich zu Cloud-APIs von Juni 2026
{tokenStats?.pricing?.heavy
? `${tokenStats.pricing.heavy.in.toFixed(2).replace(".", ",")} $ / ${tokenStats.pricing.heavy.out.toFixed(2).replace(".", ",")} $ pro 1M tkn).`
: "."}
</div>
</div>
)
}
@@ -0,0 +1,304 @@
import { useState } from "react"
import { ShieldAlert, X, Power, Shield, Download, RefreshCw } from "lucide-react"
import { api } from "@/lib/api"
import { useUpdates, useJobs, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
export function UpdatesCard() {
const qc = useQueryClient()
const { data: updates } = useUpdates(3_000)
const { data: jobs = [] } = useJobs(3_000)
const { showConfirm, dialogElement } = useDialog()
const [msg, setMsg] = useState("")
const [loading, setLoading] = useState(false)
const [sudoPassword, setSudoPassword] = useState("")
const [sudoLoading, setSudoLoading] = useState(false)
const [sudoModal, setSudoModal] = useState<{
open: boolean
actionPath: string
actionLabel: string
payload?: any
error?: string
}>({ open: false, actionPath: "", actionLabel: "" })
const refresh = () => {
qc.invalidateQueries({ queryKey: qk.updates })
qc.invalidateQueries({ queryKey: qk.jobs })
qc.invalidateQueries({ queryKey: qk.models })
}
async function postAction(path: string, label: string, payload?: any, password?: string) {
setMsg(`${label} wird ausgeführt...`)
setLoading(true)
try {
const body: any = { ...payload }
if (password) body.sudo_password = password
const r = await api<{ job_id?: string; ok?: boolean; status?: string; err?: string }>(path, {
method: "POST",
body: JSON.stringify(body)
})
if (r.status === "password_required" || r.status === "incorrect_password") {
setSudoModal({
open: true,
actionPath: path,
actionLabel: label,
payload,
error: r.status === "incorrect_password" ? "Falsches Sudo-Passwort. Bitte erneut versuchen." : undefined
})
setMsg("")
return
}
if (r.job_id) setMsg(`${label} gestartet (Job-ID: ${r.job_id})`)
else if (r.ok) setMsg(`${label} erfolgreich ausgeführt.`)
else setMsg(`Fehler: ${r.err || "Unbekannter Fehler"}`)
refresh()
} catch (e: any) {
setMsg(`Fehler bei ${label}: ${e.message}`)
} finally {
setLoading(false)
}
}
async function handleSudoSubmit() {
setSudoLoading(true)
try {
const body: any = { ...sudoModal.payload, sudo_password: sudoPassword }
const r = await api<{ job_id?: string; ok?: boolean; status?: string; err?: string }>(sudoModal.actionPath, {
method: "POST",
body: JSON.stringify(body)
})
if (r.status === "password_required" || r.status === "incorrect_password") {
setSudoModal(prev => ({ ...prev, error: "Falsches Sudo-Passwort. Bitte erneut versuchen." }))
return
}
if (r.job_id) setMsg(`${sudoModal.actionLabel} gestartet (Job-ID: ${r.job_id})`)
else if (r.ok) setMsg(`${sudoModal.actionLabel} erfolgreich ausgeführt.`)
else setMsg(`Fehler: ${r.err || "Unbekannter Fehler"}`)
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
setSudoPassword("")
refresh()
} catch (e: any) {
setMsg(`Fehler: ${e.message}`)
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
setSudoPassword("")
} finally {
setSudoLoading(false)
}
}
async function upgradeModel(repo: string, role: string) {
setMsg(`Upgrade für ${repo} wird gestartet...`)
try {
await api("/api/models/install", {
method: "POST",
body: JSON.stringify({ repo, role, quant: "Q4_K_M", jinja: true })
})
setMsg(`Upgrade-Download gestartet.`)
refresh()
} catch (e: any) {
setMsg(`Upgrade fehlgeschlagen: ${e.message}`)
}
}
const activeOsJob = jobs.find(j => j.label.includes("OS-Update") && (j.state === "running" || j.state === "queued"))
const activeEngineJob = jobs.find(j => j.label.includes("Engine-Update") && (j.state === "running" || j.state === "queued"))
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
{/* Sudo Password Dialog Modal */}
{sudoModal.open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="w-full max-w-sm rounded-2xl border border-border/60 bg-card/90 backdrop-blur-xl p-5 shadow-2xl space-y-4">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<span className="text-xs font-bold uppercase tracking-wider text-primary font-space">Sudo-Passwort erforderlich</span>
<button
onClick={() => { setSudoModal({ open: false, actionPath: "", actionLabel: "" }); setSudoPassword("") }}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="text-[10px] text-muted-foreground leading-normal">
Für die Aktion <strong>{sudoModal.actionLabel}</strong> wird das Administrator-Passwort (Sudo) auf der Box benötigt.
</p>
<div className="space-y-2">
<input
type="password"
value={sudoPassword}
onChange={(e) => setSudoPassword(e.target.value)}
placeholder="Sudo-Passwort eingeben..."
className="w-full h-9 rounded-lg border border-border/60 bg-background/40 px-3 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
onKeyDown={(e) => e.key === "Enter" && handleSudoSubmit()}
autoFocus
/>
{sudoModal.error && (
<div className="text-[10px] font-semibold text-red-400">{sudoModal.error}</div>
)}
</div>
<div className="flex gap-2 justify-end">
<button
onClick={() => { setSudoModal({ open: false, actionPath: "", actionLabel: "" }); setSudoPassword("") }}
className="h-8 px-3 rounded-lg border border-border/60 bg-background/25 text-[10px] font-bold uppercase hover:bg-accent transition-all cursor-pointer"
>
Abbrechen
</button>
<button
onClick={handleSudoSubmit}
disabled={!sudoPassword || sudoLoading}
className="h-8 px-4 rounded-lg bg-primary text-primary-foreground text-[10px] font-bold uppercase hover:opacity-90 transition-all disabled:opacity-50 cursor-pointer flex items-center justify-center gap-1.5"
>
{sudoLoading ? "Prüfe..." : "Ausführen"}
</button>
</div>
</div>
</div>
)}
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2 border-b border-border/20 pb-2">
<div className="flex items-center gap-2">
<ShieldAlert className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Updates &amp; Pflege</h2>
</div>
{updates?.last_check && (
<span className="text-[9px] text-muted-foreground/80 font-mono">
Zuletzt gesucht: {new Date(updates.last_check * 1000).toLocaleString("de-DE", {
day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit"
})}
</span>
)}
</div>
{updates ? (
<div className="space-y-3">
<div className="space-y-1.5">
<div className={cn(
"flex items-center justify-between px-3 py-1.5 rounded-lg border text-xs",
updates.os > 0
? "border-amber-500/30 bg-amber-500/5 text-amber-400 font-semibold"
: "border-border/30 bg-background/25 text-muted-foreground"
)}>
<span>OS-Pakete</span>
<span className="font-mono">{updates.os > 0 ? `${updates.os} verfügbar` : "aktuell"}</span>
</div>
<div className={cn(
"flex items-center justify-between px-3 py-1.5 rounded-lg border text-xs",
updates.engine > 0
? "border-amber-500/30 bg-amber-500/5 text-amber-400 font-semibold"
: "border-border/30 bg-background/25 text-muted-foreground"
)}>
<span>Engine (llama.cpp)</span>
<span className="font-mono">{updates.engine > 0 ? "Update verfügbar" : "aktuell"}</span>
</div>
<div className={cn(
"flex items-center justify-between px-3 py-1.5 rounded-lg border text-xs",
updates.models > 0
? "border-primary/30 bg-primary/5 text-primary font-semibold animate-pulse"
: "border-border/30 bg-background/25 text-muted-foreground"
)}>
<span>Modell-Upgrades</span>
<span className="font-mono">{updates.models > 0 ? `${updates.models} verfügbar` : "aktuell"}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-2 border-t border-border/20 pt-3">
<button
onClick={() => postAction("/api/maintenance/os-update", "OS-Update")}
disabled={loading || !!activeOsJob}
className="h-8 px-2 rounded-lg border border-border/60 bg-background/20 text-[10px] font-bold uppercase hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer disabled:opacity-50 flex items-center justify-center gap-1"
>
{activeOsJob ? (
<>
<RefreshCw className="h-3 w-3 animate-spin text-primary" />
<span>Aktiv ({activeOsJob.progress ?? 0}%)</span>
</>
) : (
<span>OS Update</span>
)}
</button>
<button
onClick={() => postAction("/api/maintenance/engine-update", "Engine-Update")}
disabled={loading || !!activeEngineJob}
className="h-8 px-2 rounded-lg border border-border/60 bg-background/20 text-[10px] font-bold uppercase hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer disabled:opacity-50 flex items-center justify-center gap-1"
>
{activeEngineJob ? (
<>
<RefreshCw className="h-3 w-3 animate-spin text-primary" />
<span>Aktiv ({activeEngineJob.progress ?? 0}%)</span>
</>
) : (
<span>Engine Update</span>
)}
</button>
</div>
<button
onClick={() => { showConfirm("Host-System neu starten?", "Bist du sicher, dass du das Host-System neu starten willst?", () => postAction("/api/maintenance/reboot", "Reboot")) }}
disabled={loading}
className="w-full h-8 flex items-center justify-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/5 text-red-400 text-[10px] font-bold uppercase hover:bg-red-500/10 transition-all cursor-pointer disabled:opacity-50"
>
<Power className="h-3.5 w-3.5" />
<span>Host Reboot</span>
</button>
{updates.model_list.length > 0 && (
<div className="space-y-1.5 border-t border-border/20 pt-3">
<div className="text-[9px] uppercase font-bold text-muted-foreground/60 tracking-wider">Verfügbare Modell-Upgrades:</div>
<div className="max-h-24 overflow-y-auto space-y-1.5 pr-1 scrollbar-thin">
{updates.model_list.map((m) => (
<div key={m.repo} className="flex items-center justify-between p-2 rounded bg-background/25 border border-border/30 text-[10px] font-mono text-muted-foreground">
<span className="truncate flex-1 mr-1.5" title={`${m.role}: ${m.repo}`}>
<span className="text-primary font-bold uppercase">{m.role}</span>: {m.repo.split("/").pop()}
</span>
<button
onClick={() => upgradeModel(m.repo, m.role)}
className="px-2 py-0.5 text-[8px] font-bold uppercase rounded bg-primary text-primary-foreground hover:opacity-90 transition-all cursor-pointer flex items-center gap-0.5"
>
<Download className="h-2.5 w-2.5" /> Laden
</button>
</div>
))}
</div>
</div>
)}
</div>
) : (
<div className="h-24 flex items-center justify-center text-xs text-muted-foreground">Lade Updates...</div>
)}
{msg && (
<div className="text-[10px] font-mono text-primary font-medium bg-primary/5 p-2 rounded-lg border border-primary/20 break-all leading-normal">
{msg}
</div>
)}
<div className="text-[9px] text-muted-foreground/60 leading-normal border-t border-border/10 pt-2.5 flex items-start gap-1">
<Shield className="h-3 w-3 shrink-0 text-muted-foreground/50 mt-0.5" />
<span>OS-Update & Reboot benötigen NOPASSWD in <code>/etc/sudoers</code> (z.B. <code>hitonabi ALL=(root) NOPASSWD:...</code>) oder ein gültiges Sudo-Passwort per Pop-up.</span>
</div>
</div>
<div className="mt-4 border-t border-border/30 pt-3 shrink-0">
<button
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer"))}
className="w-full h-8 flex items-center justify-center gap-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all cursor-pointer shadow-md shadow-primary/10"
>
System-Zentrale öffnen
</button>
</div>
{dialogElement}
</div>
)
}
+14 -829
View File
@@ -1,315 +1,13 @@
import { useEffect, useState } from "react"
import { Bot, Cpu, ExternalLink, Brain, Layers, Plus, ShieldAlert, X, Power, Shield, Download, RefreshCw, Check, Coins } from "lucide-react"
import { api, type SystemStatus, type AgentStatus, type ModelInfo, type Memory, type UpdatesResp, type Job } from "@/lib/api"
import { cn, resolveExternalUrl } from "@/lib/utils"
import { gb } from "@/lib/format"
import { CustomDialog } from "@/components/CustomDialog"
function RadialGauge({ value, label, detail }: { value: number; label: string; detail?: string }) {
const radius = 24
const circ = 2 * Math.PI * radius
const offset = circ - (Math.min(value, 100) / 100) * circ
const strokeColor = value > 90
? "stroke-red-500"
: value > 75
? "stroke-amber-500"
: "stroke-primary"
return (
<div className="flex flex-col items-center gap-1.5 p-2 bg-background/20 rounded-xl border border-border/40">
<div className="relative flex h-16 w-16 items-center justify-center">
<svg className="absolute inset-0 h-full w-full -rotate-90">
<circle cx="32" cy="32" r={radius} className="stroke-muted fill-none" strokeWidth="4.5" />
<circle cx="32" cy="32" r={radius} className={cn("fill-none transition-all duration-700 ease-out", strokeColor)} strokeWidth="4.5" strokeDasharray={circ} strokeDashoffset={offset} strokeLinecap="round" />
</svg>
<span className="text-xs font-mono font-bold tracking-tight text-foreground">{Math.round(value)}%</span>
</div>
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">{label}</span>
{detail && <span className="text-[10px] font-mono text-muted-foreground/80">{detail}</span>}
</div>
)
}
import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard"
import { UpdatesCard } from "@/components/dashboard/UpdatesCard"
import { AgentStatusCard } from "@/components/dashboard/AgentStatusCard"
import { RolesCard } from "@/components/dashboard/RolesCard"
import { MemoryInputCard } from "@/components/dashboard/MemoryInputCard"
import { TokenStatsCard } from "@/components/dashboard/TokenStatsCard"
export function DashboardView() {
const [sys, setSys] = useState<SystemStatus | null>(null)
const [agent, setAgent] = useState<AgentStatus | null>(null)
const [models, setModels] = useState<ModelInfo[]>([])
const [running, setRunning] = useState<string[]>([])
const [memories, setMemories] = useState<Memory[]>([])
const [updates, setUpdates] = useState<UpdatesResp | null>(null)
const [jobs, setJobs] = useState<Job[]>([])
const [tokenStats, setTokenStats] = useState<{
prompt_tokens: number
completion_tokens: number
total_tokens: number
saved_usd: number
saved_eur: number
pricing?: Record<string, { in: number; out: number }>
} | null>(null)
// Sudo & Action states
const [msg, setMsg] = useState("")
const [loading, setLoading] = useState(false)
const [sudoPassword, setSudoPassword] = useState("")
const [sudoLoading, setSudoLoading] = useState(false)
const [sudoModal, setSudoModal] = useState<{
open: boolean
actionPath: string
actionLabel: string
payload?: any
error?: string
}>({ open: false, actionPath: "", actionLabel: "" })
// Custom Dialog State
const [dialog, setDialog] = useState<{
type: "alert" | "confirm"
title: string
message: string
onConfirm: () => void
onCancel?: () => void
} | null>(null)
const [showBrainSelect, setShowBrainSelect] = useState(false)
async function changeBrainModel(model: string) {
try {
await api("/api/agent/brain", {
method: "POST",
body: JSON.stringify({ model })
})
setDialog({
type: "alert",
title: "Erfolgreich",
message: `Hermes-Gehirn wurde auf '${model}' geändert. Der Gateway-Dienst wurde neu gestartet.`,
onConfirm: () => setDialog(null)
})
loadData()
setShowBrainSelect(false)
} catch (e: any) {
setDialog({
type: "alert",
title: "Fehler",
message: `Fehler beim Wechseln des Gehirns: ${e.message}`,
onConfirm: () => setDialog(null)
})
}
}
function showConfirm(title: string, message: string, onConfirm: () => void) {
setDialog({
type: "confirm",
title,
message,
onConfirm: () => {
setDialog(null)
onConfirm()
},
onCancel: () => setDialog(null)
})
}
// Quick Memory Form State
const [memContent, setMemContent] = useState("")
const [memCat, setMemCat] = useState("stable")
const [savingMem, setSavingMem] = useState(false)
function loadData() {
api<SystemStatus>("/api/system/status").then(setSys).catch(() => {})
api<AgentStatus>("/api/agent/status").then(setAgent).catch(() => {})
api<{ models: ModelInfo[]; running?: string[] }>("/api/models")
.then((d) => {
setModels(d.models || [])
setRunning(d.running || [])
})
.catch(() => {})
api<Memory[]>("/api/memory?category=").then((d) => setMemories(d.slice(0, 3))).catch(() => {})
api<UpdatesResp>("/api/maintenance/updates").then(setUpdates).catch(() => {})
api<{ jobs: Job[] }>("/api/jobs").then((d) => setJobs(d.jobs || [])).catch(() => {})
api<any>("/api/system/token-stats").then(setTokenStats).catch(() => {})
}
useEffect(() => {
loadData()
const t = setInterval(loadData, 3000)
return () => clearInterval(t)
}, [])
async function postAction(path: string, label: string, payload?: any, password?: string) {
setMsg(`${label} wird ausgeführt...`)
setLoading(true)
try {
const body: any = { ...payload }
if (password) {
body.sudo_password = password
}
const r = await api<{ job_id?: string; ok?: boolean; status?: string; err?: string }>(path, {
method: "POST",
body: JSON.stringify(body)
})
if (r.status === "password_required" || r.status === "incorrect_password") {
setSudoModal({
open: true,
actionPath: path,
actionLabel: label,
payload,
error: r.status === "incorrect_password" ? "Falsches Sudo-Passwort. Bitte erneut versuchen." : undefined
})
setMsg("")
return
}
if (r.job_id) {
setMsg(`${label} gestartet (Job-ID: ${r.job_id})`)
} else if (r.ok) {
setMsg(`${label} erfolgreich ausgeführt.`)
} else {
setMsg(`Fehler: ${r.err || "Unbekannter Fehler"}`)
}
loadData()
} catch (e: any) {
setMsg(`Fehler bei ${label}: ${e.message}`)
} finally {
setLoading(false)
}
}
async function handleSudoSubmit() {
setSudoLoading(true)
try {
const body: any = { ...sudoModal.payload, sudo_password: sudoPassword }
const r = await api<{ job_id?: string; ok?: boolean; status?: string; err?: string }>(sudoModal.actionPath, {
method: "POST",
body: JSON.stringify(body)
})
if (r.status === "password_required" || r.status === "incorrect_password") {
setSudoModal(prev => ({
...prev,
error: "Falsches Sudo-Passwort. Bitte erneut versuchen."
}))
return
}
if (r.job_id) {
setMsg(`${sudoModal.actionLabel} gestartet (Job-ID: ${r.job_id})`)
} else if (r.ok) {
setMsg(`${sudoModal.actionLabel} erfolgreich ausgeführt.`)
} else {
setMsg(`Fehler: ${r.err || "Unbekannter Fehler"}`)
}
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
setSudoPassword("")
loadData()
} catch (e: any) {
setMsg(`Fehler: ${e.message}`)
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
setSudoPassword("")
} finally {
setSudoLoading(false)
}
}
async function upgradeModel(repo: string, role: string) {
setMsg(`Upgrade für ${repo} wird gestartet...`)
try {
await api("/api/models/install", {
method: "POST",
body: JSON.stringify({ repo, role, quant: "Q4_K_M", jinja: true })
})
setMsg(`Upgrade-Download gestartet.`)
loadData()
} catch (e: any) {
setMsg(`Upgrade fehlgeschlagen: ${e.message}`)
}
}
async function saveQuickMemory() {
if (!memContent.trim() || savingMem) return
setSavingMem(true)
try {
await api("/api/memory", {
method: "POST",
body: JSON.stringify({ content: memContent, category: memCat, source: "dashboard" }),
})
setMemContent("")
api<Memory[]>("/api/memory?category=").then((d) => setMemories(d.slice(0, 3))).catch(() => {})
} catch (e) {
console.error(e)
} finally {
setSavingMem(false)
}
}
// Active updates check
const activeOsJob = jobs.find(j => j.label.includes("OS-Update") && (j.state === "running" || j.state === "queued"))
const activeEngineJob = jobs.find(j => j.label.includes("Engine-Update") && (j.state === "running" || j.state === "queued"))
return (
<div className="space-y-6">
{/* Sudo Password Dialog Modal */}
{sudoModal.open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="w-full max-w-sm rounded-2xl border border-border/60 bg-card/90 backdrop-blur-xl p-5 shadow-2xl space-y-4">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<span className="text-xs font-bold uppercase tracking-wider text-primary font-space">Sudo-Passwort erforderlich</span>
<button
onClick={() => {
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
setSudoPassword("")
}}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="text-[10px] text-muted-foreground leading-normal">
Für die Aktion <strong>{sudoModal.actionLabel}</strong> wird das Administrator-Passwort (Sudo) auf der Box benötigt.
</p>
<div className="space-y-2">
<input
type="password"
value={sudoPassword}
onChange={(e) => setSudoPassword(e.target.value)}
placeholder="Sudo-Passwort eingeben..."
className="w-full h-9 rounded-lg border border-border/60 bg-background/40 px-3 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
onKeyDown={(e) => e.key === "Enter" && handleSudoSubmit()}
autoFocus
/>
{sudoModal.error && (
<div className="text-[10px] font-semibold text-red-400">{sudoModal.error}</div>
)}
</div>
<div className="flex gap-2 justify-end">
<button
onClick={() => {
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
setSudoPassword("")
}}
className="h-8 px-3 rounded-lg border border-border/60 bg-background/25 text-[10px] font-bold uppercase hover:bg-accent transition-all cursor-pointer"
>
Abbrechen
</button>
<button
onClick={handleSudoSubmit}
disabled={!sudoPassword || sudoLoading}
className="h-8 px-4 rounded-lg bg-primary text-primary-foreground text-[10px] font-bold uppercase hover:opacity-90 transition-all disabled:opacity-50 cursor-pointer flex items-center justify-center gap-1.5"
>
{sudoLoading ? "Prüfe..." : "Ausführen"}
</button>
</div>
</div>
</div>
)}
{/* Title */}
<div>
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
@@ -318,532 +16,19 @@ export function DashboardView() {
<p className="text-sm text-muted-foreground">Aktueller Status von System, Modellen und Agent.</p>
</div>
{/* Top Grid: System stats & Updates (3 Columns) */}
{/* Top Grid: System stats & Updates */}
<div className="grid gap-6 md:grid-cols-3">
{/* Card 1: System Status (2 columns wide) */}
<div className="md:col-span-2 flex flex-col justify-between rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15">
<div>
<div className="flex items-center gap-2 mb-4">
<Cpu className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">System-Status</h2>
</div>
{sys ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<RadialGauge value={sys.cpu.percent} label="CPU" detail={sys.cpu.cores ? `${sys.cpu.cores} Cores` : undefined} />
<RadialGauge value={sys.ram.percent} label="RAM" detail={`${gb(sys.ram.used)} / ${gb(sys.ram.total)} GB`} />
{sys.gpu && sys.gpu.busy_percent != null && sys.gpu.gtt_used != null && sys.gpu.gtt_total != null && (
<RadialGauge
value={sys.gpu.busy_percent}
label="GPU"
detail={`${gb(sys.gpu.gtt_used)} / ${gb(sys.gpu.gtt_total)} GB`}
/>
)}
{sys.disk && (
<RadialGauge value={sys.disk.percent} label="Disk" detail={`${gb(sys.disk.used)} / ${gb(sys.disk.total)} GB`} />
)}
</div>
) : (
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">Lade Systemdaten</div>
)}
</div>
{sys?.temp && (sys.temp.cpu || sys.temp.gpu) && (
<div className="mt-4 flex gap-4 text-xs font-mono text-muted-foreground/80 border-t border-border/30 pt-3">
{sys.temp.cpu != null && <span>CPU Temp: {sys.temp.cpu} °C</span>}
{sys.temp.gpu != null && <span>GPU Temp: {sys.temp.gpu} °C</span>}
</div>
)}
</div>
{/* Card 2: Updates & Wartung (1 column wide) */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2 border-b border-border/20 pb-2">
<div className="flex items-center gap-2">
<ShieldAlert className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Updates &amp; Pflege</h2>
</div>
{updates?.last_check && (
<span className="text-[9px] text-muted-foreground/80 font-mono">
Zuletzt gesucht: {new Date(updates.last_check * 1000).toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
})}
</span>
)}
</div>
{updates ? (
<div className="space-y-3">
<div className="space-y-1.5">
<div className={cn(
"flex items-center justify-between px-3 py-1.5 rounded-lg border text-xs",
updates.os > 0
? "border-amber-500/30 bg-amber-500/5 text-amber-400 font-semibold"
: "border-border/30 bg-background/25 text-muted-foreground"
)}>
<span>OS-Pakete</span>
<span className="font-mono">{updates.os > 0 ? `${updates.os} verfügbar` : "aktuell"}</span>
</div>
<div className={cn(
"flex items-center justify-between px-3 py-1.5 rounded-lg border text-xs",
updates.engine > 0
? "border-amber-500/30 bg-amber-500/5 text-amber-400 font-semibold"
: "border-border/30 bg-background/25 text-muted-foreground"
)}>
<span>Engine (llama.cpp)</span>
<span className="font-mono">{updates.engine > 0 ? "Update verfügbar" : "aktuell"}</span>
</div>
<div className={cn(
"flex items-center justify-between px-3 py-1.5 rounded-lg border text-xs",
updates.models > 0
? "border-primary/30 bg-primary/5 text-primary font-semibold animate-pulse"
: "border-border/30 bg-background/25 text-muted-foreground"
)}>
<span>Modell-Upgrades</span>
<span className="font-mono">{updates.models > 0 ? `${updates.models} verfügbar` : "aktuell"}</span>
</div>
</div>
{/* Inline Action Buttons */}
<div className="grid grid-cols-2 gap-2 border-t border-border/20 pt-3">
<button
onClick={() => postAction("/api/maintenance/os-update", "OS-Update")}
disabled={loading || !!activeOsJob}
className="h-8 px-2 rounded-lg border border-border/60 bg-background/20 text-[10px] font-bold uppercase hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer disabled:opacity-50 flex items-center justify-center gap-1"
>
{activeOsJob ? (
<>
<RefreshCw className="h-3 w-3 animate-spin text-primary" />
<span>Aktiv ({activeOsJob.progress ?? 0}%)</span>
</>
) : (
<span>OS Update</span>
)}
</button>
<button
onClick={() => postAction("/api/maintenance/engine-update", "Engine-Update")}
disabled={loading || !!activeEngineJob}
className="h-8 px-2 rounded-lg border border-border/60 bg-background/20 text-[10px] font-bold uppercase hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer disabled:opacity-50 flex items-center justify-center gap-1"
>
{activeEngineJob ? (
<>
<RefreshCw className="h-3 w-3 animate-spin text-primary" />
<span>Aktiv ({activeEngineJob.progress ?? 0}%)</span>
</>
) : (
<span>Engine Update</span>
)}
</button>
</div>
<button
onClick={() => { showConfirm("Host-System neu starten?", "Bist du sicher, dass du das Host-System neu starten willst?", () => postAction("/api/maintenance/reboot", "Reboot")) }}
disabled={loading}
className="w-full h-8 flex items-center justify-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/5 text-red-400 text-[10px] font-bold uppercase hover:bg-red-500/10 transition-all cursor-pointer disabled:opacity-50"
>
<Power className="h-3.5 w-3.5" />
<span>Host Reboot</span>
</button>
{/* Model Upgrades list */}
{updates.model_list.length > 0 && (
<div className="space-y-1.5 border-t border-border/20 pt-3">
<div className="text-[9px] uppercase font-bold text-muted-foreground/60 tracking-wider">Verfügbare Modell-Upgrades:</div>
<div className="max-h-24 overflow-y-auto space-y-1.5 pr-1 scrollbar-thin">
{updates.model_list.map((m) => (
<div key={m.repo} className="flex items-center justify-between p-2 rounded bg-background/25 border border-border/30 text-[10px] font-mono text-muted-foreground">
<span className="truncate flex-1 mr-1.5" title={`${m.role}: ${m.repo}`}>
<span className="text-primary font-bold uppercase">{m.role}</span>: {m.repo.split("/").pop()}
</span>
<button
onClick={() => upgradeModel(m.repo, m.role)}
className="px-2 py-0.5 text-[8px] font-bold uppercase rounded bg-primary text-primary-foreground hover:opacity-90 transition-all cursor-pointer flex items-center gap-0.5"
>
<Download className="h-2.5 w-2.5" /> Laden
</button>
</div>
))}
</div>
</div>
)}
</div>
) : (
<div className="h-24 flex items-center justify-center text-xs text-muted-foreground">Lade Updates...</div>
)}
{/* Status messages display */}
{msg && (
<div className="text-[10px] font-mono text-primary font-medium bg-primary/5 p-2 rounded-lg border border-primary/20 break-all leading-normal">
{msg}
</div>
)}
{/* Sudoers explanation text */}
<div className="text-[9px] text-muted-foreground/60 leading-normal border-t border-border/10 pt-2.5 flex items-start gap-1">
<Shield className="h-3 w-3 shrink-0 text-muted-foreground/50 mt-0.5" />
<span>OS-Update & Reboot benötigen NOPASSWD in <code>/etc/sudoers</code> (z.B. <code>hitonabi ALL=(root) NOPASSWD:...</code>) oder ein gültiges Sudo-Passwort per Pop-up.</span>
</div>
</div>
<div className="mt-4 border-t border-border/30 pt-3 shrink-0">
<button
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer"))}
className="w-full h-8 flex items-center justify-center gap-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all cursor-pointer shadow-md shadow-primary/10"
>
System-Zentrale öffnen
</button>
</div>
</div>
<SystemStatusCard />
<UpdatesCard />
</div>
{/* Bottom Grid: Agent, Roles, Memory & Token Stats (4 Columns on Desktop) */}
{/* Bottom Grid: Agent, Roles, Memory & Token Stats */}
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
{/* Card 3: Hermes Agent Status */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Bot className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Hermes Agent</h2>
</div>
{agent?.webui_url && (
<a
href={resolveExternalUrl(agent.webui_url)}
target="_blank"
rel="noopener"
className={cn(
"flex items-center gap-1 px-3 py-1 rounded-lg text-xs font-medium transition-all",
agent.webui_reachable
? "bg-primary text-primary-foreground hover:opacity-90 shadow-md shadow-primary/20"
: "border border-border text-muted-foreground"
)}
>
<ExternalLink className="h-3 w-3" /> Hermes öffnen
</a>
)}
</div>
{agent ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
<div className="text-[10px] text-muted-foreground uppercase font-semibold">Gateway</div>
<div className="flex items-center gap-2 mt-1">
<span className={cn("h-2 w-2 rounded-full", agent.gateway_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
<span className="text-xs font-medium">{agent.gateway_reachable ? "Online" : "Offline"}</span>
</div>
</div>
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
<div className="text-[10px] text-muted-foreground uppercase font-semibold">WebUI</div>
<div className="flex items-center gap-2 mt-1">
<span className={cn("h-2 w-2 rounded-full", agent.webui_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
<span className="text-xs font-medium">{agent.webui_reachable ? "Online" : "Offline"}</span>
</div>
</div>
</div>
<div
onClick={() => setShowBrainSelect(true)}
className="p-3 bg-background/20 rounded-xl border border-border/40 hover:border-primary/45 hover:bg-background/30 transition-all duration-300 cursor-pointer group"
>
<div className="flex justify-between items-center">
<span className="text-[10px] text-muted-foreground uppercase font-semibold">Aktives Gehirn</span>
<button
className="flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-primary border border-primary/20 bg-primary/10 hover:bg-primary/20 px-1.5 py-0.5 rounded transition-all cursor-pointer font-space"
>
<Cpu className="h-3 w-3" /> Ändern
</button>
</div>
<div className="text-xs font-medium mt-1 font-mono text-primary flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" />
{agent.brain_model ? `model: ${agent.brain_model}` : "model: auto"}
</div>
</div>
</div>
) : (
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">Lade Agenten-Status</div>
)}
</div>
<div className="mt-3 text-[10px] text-muted-foreground/80 border-t border-border/30 pt-3">
Gedächtnis & Stack-Tools via MCP gekoppelt.
</div>
</div>
{/* Card 4: Slot-Belegung (Rollen & Modelle) */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
<div>
<div className="flex items-center gap-2 mb-4">
<Layers className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Rollen-Belegung</h2>
</div>
<div className="space-y-2 max-h-48 overflow-y-auto pr-1 scrollbar-thin">
{["fast", "heavy", "coder", "reasoning", "vision", "scout"].map((role) => {
const m = models.find((x) => x.role === role)
const isRunning = m ? running.includes(m.name) : false
return (
<div
key={role}
className={cn(
"flex items-center justify-between p-2 rounded-xl border transition-all duration-300",
isRunning
? "border-emerald-500/30 bg-emerald-500/5 shadow-sm shadow-emerald-500/5"
: m
? "border-primary/20 bg-primary/5"
: "border-border/30 bg-background/10 opacity-60"
)}
>
<div className="min-w-0 flex-1 mr-2">
<div className="flex items-center gap-2 min-w-0">
<span className={cn("text-[9px] font-semibold uppercase px-1.5 py-0.5 rounded font-mono border tracking-wider shrink-0",
role === "fast" ? "bg-cyan-500/15 text-cyan-400 border-cyan-500/25" :
role === "heavy" ? "bg-amber-500/15 text-amber-400 border-amber-500/25" :
role === "coder" ? "bg-violet-500/15 text-violet-400 border-violet-500/25" :
role === "reasoning" ? "bg-emerald-500/15 text-emerald-400 border-emerald-500/25" :
role === "vision" ? "bg-pink-500/15 text-pink-400 border-pink-500/25" :
"bg-teal-500/15 text-teal-400 border-teal-500/25"
)}>
{role}
</span>
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold truncate font-mono text-foreground">
{m ? m.name.split("/").pop()?.replace(/\.gguf$/i, "") : "nicht zugewiesen"}
</span>
{m && (
<div className="flex gap-1 items-center mt-0.5 flex-wrap">
{m.prompt_cache && (
<span className="text-[7px] leading-none font-mono font-bold bg-cyan-500/10 text-cyan-400 border border-cyan-500/20 px-1 py-0.5 rounded" title="Prompt Caching aktiv">
PC
</span>
)}
{m.spec_draft_model && (
<span className="text-[7px] leading-none font-mono font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-1 py-0.5 rounded" title={`Speculative Decoding aktiv (Draft: ${m.spec_draft_model})`}>
SPEC
</span>
)}
{m.parallel_slots > 1 && (
<span className="text-[7px] leading-none font-mono font-bold bg-violet-500/10 text-violet-400 border border-violet-500/20 px-1 py-0.5 rounded" title={`${m.parallel_slots} parallele Slots aktiv`}>
SLOTS: {m.parallel_slots}
</span>
)}
</div>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{m ? (
isRunning ? (
<span className="flex items-center gap-1 text-[8px] font-semibold text-emerald-400 uppercase tracking-wider font-mono">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> warm
</span>
) : (
<span className="text-[8px] font-semibold text-muted-foreground uppercase tracking-wider font-mono">
bereit
</span>
)
) : (
<span className="text-[8px] font-semibold text-muted-foreground/60 uppercase tracking-wider font-mono">
</span>
)}
</div>
</div>
)
})}
</div>
</div>
<div className="mt-4 text-[10px] text-muted-foreground/80 border-t border-border/30 pt-3">
Laden erfolgt automatisch per Auto-Swap.
</div>
</div>
{/* Card 5: Quick Memory Input */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
<div>
<div className="flex items-center gap-2 mb-4">
<Brain className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Gedächtnis</h2>
</div>
<div className="space-y-3">
<textarea
value={memContent}
onChange={(e) => setMemContent(e.target.value)}
placeholder="Fakt / Regel im Pool speichern..."
rows={2}
className="w-full resize-none rounded-xl border border-border/50 bg-background/30 p-2 text-[10px] outline-none focus:ring-1 focus:ring-primary transition-all leading-normal"
/>
<div className="flex items-center gap-2 justify-between">
<select
value={memCat}
onChange={(e) => setMemCat(e.target.value)}
className="h-7 rounded-lg border border-border/50 bg-background/50 px-2 text-[10px] outline-none cursor-pointer"
>
<option value="stable">🔵 Fakt</option>
<option value="instruction">📋 Regel</option>
<option value="user">👤 User</option>
<option value="versioned">🟡 Version</option>
</select>
<button
onClick={saveQuickMemory}
disabled={!memContent.trim() || savingMem}
className="flex h-7 items-center gap-1 rounded-lg bg-primary px-3 text-[10px] font-semibold text-primary-foreground hover:opacity-90 disabled:opacity-50 transition-all cursor-pointer"
>
<Plus className="h-3.5 w-3.5" /> Speichern
</button>
</div>
</div>
<div className="mt-3.5 space-y-1.5">
<div className="text-[9px] text-muted-foreground uppercase font-bold tracking-wider">Zuletzt gespeichert:</div>
<div className="max-h-20 overflow-y-auto space-y-1 pr-1 scrollbar-thin">
{memories.length === 0 ? (
<div className="text-[10px] text-muted-foreground/75 py-1">Keine Einträge vorhanden.</div>
) : (
memories.map((m) => (
<div key={m.id} className="text-[10px] bg-background/10 border border-border/30 rounded p-1.5 flex items-start gap-1.5">
<span className="shrink-0 text-[8px] font-mono text-muted-foreground/80 px-1 py-0.5 rounded bg-muted/20">
{m.category}
</span>
<span className="truncate flex-1 text-muted-foreground hover:text-foreground transition-colors" title={m.content}>
{m.content}
</span>
</div>
))
)}
</div>
</div>
</div>
<div className="mt-4 text-[10px] text-muted-foreground/80 border-t border-border/30 pt-3">
Steht allen Clients per MCP zur Verfügung.
</div>
</div>
{/* Card 6: Effizienz & Ersparnis (Token-Stats & Ersparnis) */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
<div>
<div className="flex items-center gap-2 mb-4">
<Coins className="h-4.5 w-4.5 text-primary animate-pulse" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Effizienz &amp; Ersparnis</h2>
</div>
{tokenStats ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-2.5">
<div className="p-3 bg-background/20 rounded-xl border border-border/40 text-left">
<div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Geld gespart</div>
<div className="text-base font-bold text-emerald-400 mt-0.5 tracking-tight font-space">
{tokenStats.saved_eur.toLocaleString("de-DE", { minimumFractionDigits: 2 })}
</div>
<div className="text-[8px] text-muted-foreground/80 mt-0.5 font-mono">
({tokenStats.saved_usd.toLocaleString("en-US", { minimumFractionDigits: 2 })} $)
</div>
</div>
<div className="p-3 bg-background/20 rounded-xl border border-border/40 text-left">
<div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Gesamt-Tokens</div>
<div className="text-base font-bold text-primary mt-0.5 tracking-tight font-space">
{tokenStats.total_tokens.toLocaleString("de-DE")}
</div>
<div className="text-[8px] text-muted-foreground/80 mt-0.5 font-mono">
(Lokale Inferenz)
</div>
</div>
</div>
<div className="space-y-2 border-t border-border/20 pt-3 text-[10px] text-muted-foreground">
<div className="flex justify-between items-center font-mono">
<span>Input (Prompts):</span>
<span className="font-semibold text-foreground">{tokenStats.prompt_tokens.toLocaleString("de-DE")} tkn</span>
</div>
<div className="flex justify-between items-center font-mono">
<span>Output (Antworten):</span>
<span className="font-semibold text-foreground">{tokenStats.completion_tokens.toLocaleString("de-DE")} tkn</span>
</div>
</div>
</div>
) : (
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">Lade Statistiken</div>
)}
</div>
<div className="mt-4 text-[9px] text-muted-foreground/70 border-t border-border/30 pt-2.5 leading-normal">
Berechnet im Vergleich zu Cloud-APIs von Juni 2026
{tokenStats?.pricing?.heavy
? `${tokenStats.pricing.heavy.in.toFixed(2).replace(".", ",")} $ / ${tokenStats.pricing.heavy.out.toFixed(2).replace(".", ",")} $ pro 1M tkn).`
: "."}
</div>
</div>
<AgentStatusCard />
<RolesCard />
<MemoryInputCard />
<TokenStatsCard />
</div>
{agent && showBrainSelect && (
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-primary flex items-center gap-1.5">
<Cpu className="h-4 w-4" />
<span>Hermes-Gehirn konfigurieren</span>
</h3>
<button
onClick={() => setShowBrainSelect(false)}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
Das Gehirn" ist das primäre LLM für Hermes. Wähle ein Modell aus deiner Bibliothek oder nutze ein Gateway-Alias (<code className="text-primary font-semibold">auto</code> / <code className="text-primary font-semibold">fast</code> / <code className="text-primary font-semibold">heavy</code>):
</p>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
{(["auto", "fast", "heavy", ...models.map(m => m.name.split("/").pop()?.replace(".gguf", "") || m.name)]).map((m) => {
const isAlias = ["auto", "fast", "heavy"].includes(m);
return (
<button
key={m}
onClick={() => changeBrainModel(m)}
className={cn(
"w-full text-left px-3 py-2.5 rounded-lg text-xs hover:bg-accent flex items-center justify-between font-mono cursor-pointer border border-border/30",
agent.brain_model === m || (!agent.brain_model && m === "auto")
? "text-primary font-bold bg-primary/10 border-primary/30"
: "text-foreground bg-background/20"
)}
>
<div className="flex flex-col text-left">
<span className="font-semibold truncate max-w-[280px]">
{m}
</span>
<span className="text-[9px] text-muted-foreground mt-0.5">
{isAlias ? "Gateway Routing Alias" : "Installiertes GGUF Modell"}
</span>
</div>
{(agent.brain_model === m || (!agent.brain_model && m === "auto")) && (
<Check className="h-4 w-4 shrink-0 text-primary" />
)}
</button>
);
})}
</div>
</div>
</div>
)}
{dialog && (
<CustomDialog
type={dialog.type}
title={dialog.title}
message={dialog.message}
onConfirm={() => dialog.onConfirm()}
onCancel={dialog.onCancel}
/>
)}
</div>
)
}