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:
@@ -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 & 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 & 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user