import { useEffect, useState } from "react"
import { Bot, Cpu, ExternalLink, Brain, Layers, Plus, ShieldAlert, X, Power, Shield, Download, RefreshCw, Check } 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 { CustomDialog } from "@/components/CustomDialog"
function gb(b: number) {
return (b / 1024 ** 3).toFixed(1)
}
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 (
{Math.round(value)}%
{label}
{detail &&
{detail}}
)
}
export function DashboardView() {
const [sys, setSys] = useState(null)
const [agent, setAgent] = useState(null)
const [models, setModels] = useState([])
const [running, setRunning] = useState([])
const [memories, setMemories] = useState([])
const [updates, setUpdates] = useState(null)
const [jobs, setJobs] = useState([])
// 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("/api/system/status").then(setSys).catch(() => {})
api("/api/agent/status").then(setAgent).catch(() => {})
api<{ models: ModelInfo[]; running?: string[] }>("/api/models")
.then((d) => {
setModels(d.models || [])
setRunning(d.running || [])
})
.catch(() => {})
api("/api/memory?category=").then((d) => setMemories(d.slice(0, 3))).catch(() => {})
api("/api/maintenance/updates").then(setUpdates).catch(() => {})
api<{ jobs: Job[] }>("/api/jobs").then((d) => setJobs(d.jobs || [])).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("/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 (
{/* Sudo Password Dialog Modal */}
{sudoModal.open && (
Sudo-Passwort erforderlich
Für die Aktion {sudoModal.actionLabel} wird das Administrator-Passwort (Sudo) auf der Box benötigt.
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 && (
{sudoModal.error}
)}
)}
{/* Title */}
Zentrale
Aktueller Status von System, Modellen und Agent.
{/* Top Grid: System stats & Updates (3 Columns) */}
{/* Card 1: System Status (2 columns wide) */}
System-Status
{sys ? (
{sys.gpu && sys.gpu.busy_percent != null && sys.gpu.gtt_used != null && sys.gpu.gtt_total != null && (
)}
{sys.disk && (
)}
) : (
Lade Systemdaten…
)}
{sys?.temp && (sys.temp.cpu || sys.temp.gpu) && (
{sys.temp.cpu != null && CPU Temp: {sys.temp.cpu} °C}
{sys.temp.gpu != null && GPU Temp: {sys.temp.gpu} °C}
)}
{/* Card 2: Updates & Wartung (1 column wide) */}
Updates & Pflege
{updates?.last_check && (
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"
})}
)}
{updates ? (
0
? "border-amber-500/30 bg-amber-500/5 text-amber-400 font-semibold"
: "border-border/30 bg-background/25 text-muted-foreground"
)}>
OS-Pakete
{updates.os > 0 ? `${updates.os} verfügbar` : "aktuell"}
0
? "border-amber-500/30 bg-amber-500/5 text-amber-400 font-semibold"
: "border-border/30 bg-background/25 text-muted-foreground"
)}>
Engine (llama.cpp)
{updates.engine > 0 ? "Update verfügbar" : "aktuell"}
0
? "border-primary/30 bg-primary/5 text-primary font-semibold animate-pulse"
: "border-border/30 bg-background/25 text-muted-foreground"
)}>
Modell-Upgrades
{updates.models > 0 ? `${updates.models} verfügbar` : "aktuell"}
{/* Inline Action Buttons */}
{/* Model Upgrades list */}
{updates.model_list.length > 0 && (
Verfügbare Modell-Upgrades:
{updates.model_list.map((m) => (
{m.role}: {m.repo.split("/").pop()}
))}
)}
) : (
Lade Updates...
)}
{/* Status messages display */}
{msg && (
{msg}
)}
{/* Sudoers explanation text */}
OS-Update & Reboot benötigen NOPASSWD in /etc/sudoers (z.B. hitonabi ALL=(root) NOPASSWD:...) oder ein gültiges Sudo-Passwort per Pop-up.
{/* Bottom Grid: Agent, Roles & Memory (3 Columns) */}
{/* Card 3: Hermes Agent Status */}
{agent ? (
Gateway
{agent.gateway_reachable ? "Online" : "Offline"}
WebUI
{agent.webui_reachable ? "Online" : "Offline"}
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"
>
Aktives Gehirn
{agent.brain_model ? `model: ${agent.brain_model}` : "model: auto"}
) : (
Lade Agenten-Status…
)}
Gedächtnis & Stack-Tools via MCP gekoppelt.
{/* Card 4: Slot-Belegung (Rollen & Modelle) */}
Rollen-Belegung
{["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 (
{role}
{m ? m.name.split("/").pop()?.replace(/\.gguf$/i, "") : "nicht zugewiesen"}
{m ? (
isRunning ? (
warm
) : (
bereit
)
) : (
—
)}
)
})}
Laden erfolgt automatisch per Auto-Swap.
{/* Card 5: Quick Memory Input */}
Gedächtnis
Zuletzt gespeichert:
{memories.length === 0 ? (
Keine Einträge vorhanden.
) : (
memories.map((m) => (
{m.category}
{m.content}
))
)}
Steht allen Clients per MCP zur Verfügung.
{agent && showBrainSelect && (
Hermes-Gehirn konfigurieren
Das „Gehirn" ist das primäre LLM für Hermes. Wähle ein Modell aus deiner Bibliothek oder nutze ein Gateway-Alias (auto / fast / heavy):
{(["auto", "fast", "heavy", ...models.map(m => m.name.split("/").pop()?.replace(".gguf", "") || m.name)]).map((m) => {
const isAlias = ["auto", "fast", "heavy"].includes(m);
return (
);
})}
)}
{dialog && (
dialog.onConfirm()}
onCancel={dialog.onCancel}
/>
)}
)
}