feat: complete UI/UX Rework into Sleek Glassmorphic AI OS (June 2026)

This commit is contained in:
Hitonabi
2026-06-25 22:18:51 +02:00
parent e1da5c797d
commit 6a8e55cc43
18 changed files with 2362 additions and 787 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ def _ram_gb() -> float:
@router.get("/models") @router.get("/models")
def models() -> dict: def models() -> dict:
items = llamaswap.list_models() items = llamaswap.list_models()
return {"models": items, "count": len(items)} return {"models": items, "count": len(items), "running": llamaswap.get_running_models()}
@router.get("/discover") @router.get("/discover")
+12
View File
@@ -227,3 +227,15 @@ def delete_model(model_id: str) -> bool:
g["members"] = [m for m in g["members"] if m != model_id] g["members"] = [m for m in g["members"] if m != model_id]
write_config(cfg) write_config(cfg)
return True return True
def get_running_models() -> list[str]:
"""Fragt den /running Endpunkt von llama-swap ab. Gibt geladene Modelle zurück."""
try:
with httpx.Client(timeout=2.0) as c:
r = c.get(f"{LLAMA_SWAP_URL}/running")
if r.status_code == 200:
return r.json().get("running") or []
except Exception:
pass
return []
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="theme-color" content="#0d1117" /> <meta name="theme-color" content="#0d1117" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-BcRd449w.js"></script> <script type="module" crossorigin src="/assets/index-DpycPcE0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7pMz4id.css"> <link rel="stylesheet" crossorigin href="/assets/index-GX6Y3U0B.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+127 -60
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Command as CommandIcon, Moon, Sun } from "lucide-react" import { Command as CommandIcon, Moon, Sun, ChevronLeft, ChevronRight, Cpu } from "lucide-react"
import { NAV, type ViewId } from "@/nav" import { NAV, type ViewId } from "@/nav"
import { CommandPalette } from "@/components/CommandPalette" import { CommandPalette } from "@/components/CommandPalette"
import { DashboardView } from "@/views/DashboardView"
import { ModelsView } from "@/views/ModelsView" import { ModelsView } from "@/views/ModelsView"
import { RoutingView } from "@/views/RoutingView" import { RoutingView } from "@/views/RoutingView"
import { SystemView } from "@/views/SystemView" import { SystemView } from "@/views/SystemView"
@@ -9,13 +10,17 @@ import { ConnectView } from "@/views/ConnectView"
import { MemoryView } from "@/views/MemoryView" import { MemoryView } from "@/views/MemoryView"
import { AgentView } from "@/views/AgentView" import { AgentView } from "@/views/AgentView"
import { Placeholder } from "@/views/Placeholder" import { Placeholder } from "@/views/Placeholder"
import { api, type Health } from "@/lib/api" import { SystemDrawer } from "@/components/SystemDrawer"
import { api, type Health, type UpdatesResp } from "@/lib/api"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export default function App() { export default function App() {
const [view, setView] = useState<ViewId>("models") const [view, setView] = useState<ViewId>("dashboard")
const [health, setHealth] = useState<Health | null>(null) const [health, setHealth] = useState<Health | null>(null)
const [dark, setDark] = useState(() => localStorage.getItem("mc_theme") !== "light") const [dark, setDark] = useState(() => localStorage.getItem("mc_theme") !== "light")
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true")
const [updates, setUpdates] = useState<UpdatesResp | null>(null)
const [drawerOpen, setDrawerOpen] = useState(false)
useEffect(() => { useEffect(() => {
const load = () => api<Health>("/api/health").then(setHealth).catch(() => setHealth(null)) const load = () => api<Health>("/api/health").then(setHealth).catch(() => setHealth(null))
@@ -24,109 +29,171 @@ export default function App() {
return () => clearInterval(t) return () => clearInterval(t)
}, []) }, [])
useEffect(() => {
const loadUpdates = () => api<UpdatesResp>("/api/maintenance/updates").then(setUpdates).catch(() => {})
loadUpdates()
const t = setInterval(loadUpdates, 20000)
return () => clearInterval(t)
}, [])
useEffect(() => { useEffect(() => {
document.documentElement.classList.toggle("dark", dark) document.documentElement.classList.toggle("dark", dark)
localStorage.setItem("mc_theme", dark ? "dark" : "light") localStorage.setItem("mc_theme", dark ? "dark" : "light")
}, [dark]) }, [dark])
const active = NAV.find((n) => n.id === view)! const active = NAV.find((n) => n.id === view)!
const totalUpdates = updates ? (updates.os + updates.engine + updates.models) : 0
return ( return (
<div className="flex h-full"> <div className="flex h-full relative">
{/* Background Aurora Ambient Effects */}
<div className="fixed inset-0 -z-50 pointer-events-none overflow-hidden bg-[hsl(222,24%,7%)]">
<div className="absolute inset-0 opacity-25 animate-aurora bg-gradient-to-tr from-teal-500/15 via-indigo-500/10 to-purple-500/15 blur-[120px]" />
</div>
<CommandPalette onNavigate={setView} /> <CommandPalette onNavigate={setView} />
<SystemDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
{/* Sidebar */} {/* Sidebar */}
<aside className="flex w-60 shrink-0 flex-col border-r border-border bg-card/40"> <aside className={cn(
<div className="flex items-center gap-2 px-5 py-4"> "flex shrink-0 flex-col border-r border-border/40 bg-card/45 backdrop-blur-md transition-all duration-300 ease-in-out",
<div className="h-7 w-7 rounded-lg bg-primary" /> sidebarCollapsed ? "w-16" : "w-60"
<div className="leading-tight"> )}>
<div className="text-sm font-semibold">Mission Control</div> <div className={cn("flex items-center py-4 border-b border-border/40 shrink-0", sidebarCollapsed ? "flex-col gap-3 px-2" : "justify-between px-5")}>
<div className="text-xs text-muted-foreground">2.0</div> <div className="flex items-center gap-2 overflow-hidden">
<div className="h-7 w-7 rounded-lg bg-primary shadow-md shadow-primary/20 shrink-0" />
{!sidebarCollapsed && (
<div className="leading-tight">
<div className="text-sm font-semibold tracking-wide font-space">Mission Control</div>
<div className="text-xs text-muted-foreground">2.0</div>
</div>
)}
</div> </div>
<button
onClick={() => {
setSidebarCollapsed(c => {
const next = !c
localStorage.setItem("mc_sidebar_collapsed", next.toString())
return next
})
}}
className="p-1 rounded-md hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
title={sidebarCollapsed ? "Maximieren" : "Minimieren"}
>
{sidebarCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</button>
</div> </div>
<nav className="flex-1 space-y-1 px-3 py-2">
<nav className="flex-1 space-y-1 px-3 py-4 overflow-y-auto">
{NAV.map((item) => ( {NAV.map((item) => (
<button <button
key={item.id} key={item.id}
onClick={() => setView(item.id)} onClick={() => setView(item.id)}
className={cn( className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors", "flex w-full items-center rounded-md text-sm transition-all cursor-pointer",
sidebarCollapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2",
view === item.id view === item.id
? "bg-primary/15 text-primary" ? "bg-primary/15 text-primary shadow-sm shadow-primary/5"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground", : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)} )}
title={sidebarCollapsed ? item.label : undefined}
> >
<item.icon className="h-4 w-4" /> <item.icon className="h-4.5 w-4.5 shrink-0" />
{item.label} {!sidebarCollapsed && <span className="truncate">{item.label}</span>}
</button> </button>
))} ))}
</nav> </nav>
<div className="px-4 py-3 text-xs text-muted-foreground">
{health ? ( <div className={cn("py-3 text-xs text-muted-foreground border-t border-border/40 shrink-0", sidebarCollapsed ? "px-2 text-center" : "px-5")}>
<span className="flex items-center gap-2"> {sidebarCollapsed ? (
<span <div className="flex justify-center">
className={cn( <span className={cn(
"h-2 w-2 rounded-full", "h-2.5 w-2.5 rounded-full ring-2 ring-black/40",
health.engine_reachable ? "bg-emerald-500" : "bg-amber-500", health ? (health.engine_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500") : "bg-red-500"
)} )} title={health ? `Engine ${health.engine_reachable ? "online" : "offline"}` : "Backend offline"} />
/> </div>
Engine {health.engine_reachable ? "online" : "offline"} · v{health.version}
</span>
) : ( ) : (
<span className="flex items-center gap-2"> health ? (
<span className="h-2 w-2 rounded-full bg-red-500" /> Backend offline <span className="flex items-center gap-2">
</span> <span className={cn("h-2 w-2 rounded-full", health.engine_reachable ? "bg-emerald-500" : "bg-amber-500")} />
<span className="truncate">Engine {health.engine_reachable ? "online" : "offline"} · v{health.version}</span>
</span>
) : (
<span className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-red-500" /> <span className="truncate">Backend offline</span>
</span>
)
)} )}
</div> </div>
</aside> </aside>
{/* Main */} {/* Main */}
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border px-6"> <header className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-6 bg-card/20 backdrop-blur-sm">
<div className="text-sm text-muted-foreground">{active.hint}</div> <div className="text-xs font-medium text-muted-foreground tracking-wide uppercase font-sans">{active.hint}</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<a <button
href="https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2/src/branch/main/docs/BEDIENUNG.md" onClick={() => setDrawerOpen(true)}
target="_blank" className="relative flex h-8 items-center rounded-md border border-border/40 bg-background/40 px-3 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground gap-1.5 transition-all cursor-pointer font-semibold"
rel="noopener" title="System-Pflege öffnen"
className="flex h-8 items-center rounded-md border border-border px-2 text-xs text-muted-foreground hover:bg-accent" >
title="Bedien-Anleitung" <Cpu className="h-3.5 w-3.5" />
> <span>OS & Updates</span>
Hilfe {totalUpdates > 0 && (
</a> <span className="flex h-1.5 w-1.5 relative">
<button <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
onClick={() => setDark((d) => !d)} <span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-amber-500"></span>
className="flex h-8 w-8 items-center justify-center rounded-md border border-border text-muted-foreground hover:bg-accent" </span>
title="Hell/Dunkel" )}
> </button>
{dark ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
</button> <a
<button href="https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2/src/branch/main/docs/BEDIENUNG.md"
onClick={() => { target="_blank"
const ev = new KeyboardEvent("keydown", { key: "k", metaKey: true }) rel="noopener"
document.dispatchEvent(ev) className="flex h-8 items-center rounded-md border border-border/40 bg-background/40 px-2.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-all cursor-pointer font-semibold"
}} title="Bedien-Anleitung"
className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent" >
> Hilfe
<CommandIcon className="h-3.5 w-3.5" /> </a>
<span>Springen</span>
<kbd className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">K</kbd> <button
</button> onClick={() => setDark((d) => !d)}
className="flex h-8 w-8 items-center justify-center rounded-md border border-border/40 bg-background/40 text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-all cursor-pointer"
title="Hell/Dunkel"
>
{dark ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
</button>
<button
onClick={() => {
const ev = new KeyboardEvent("keydown", { key: "k", metaKey: true })
document.dispatchEvent(ev)
}}
className="flex items-center gap-2 rounded-md border border-border/40 bg-background/40 px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-all cursor-pointer"
>
<CommandIcon className="h-3.5 w-3.5" />
<span>Suchen</span>
<kbd className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">K</kbd>
</button>
</div> </div>
</header> </header>
<main className="flex-1 overflow-y-auto p-6"> <main className="flex-1 overflow-y-auto p-6 scrollbar-thin">
{view === "dashboard" && <DashboardView />}
{view === "models" && <ModelsView />} {view === "models" && <ModelsView />}
{view === "routing" && <RoutingView />} {view === "routing" && <RoutingView />}
{view === "system" && <SystemView />} {view === "system" && <SystemView />}
{view === "connect" && <ConnectView />} {view === "connect" && <ConnectView />}
{view === "memory" && <MemoryView />} {view === "memory" && <MemoryView />}
{view === "agent" && <AgentView />} {view === "agent" && <AgentView />}
{!["models", "routing", "system", "connect", "memory", "agent"].includes(view) && ( {!["dashboard", "models", "routing", "system", "connect", "memory", "agent"].includes(view) && (
<Placeholder title={active.label} hint={active.hint} /> <Placeholder title={active.label} hint={active.hint} />
)} )}
</main> </main>
</div> </div>
</div> </div>
) )
} }
+406
View File
@@ -0,0 +1,406 @@
import { useEffect, useState, useRef } from "react"
import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power } from "lucide-react"
import { api, type Job, type UpdatesResp } from "@/lib/api"
import { cn } from "@/lib/utils"
interface SystemDrawerProps {
open: boolean
onClose: () => void
}
const SERVICES = [
{ id: "llama-swap", label: "Llama Swap", type: "system" },
{ id: "mission-control-2", label: "Mission Control 2", type: "user" },
{ id: "hermes-gateway", label: "Hermes Gateway", type: "user" },
{ id: "hermes-dashboard", label: "Hermes Dashboard", type: "user" },
{ id: "hermes-webui", label: "Hermes WebUI", type: "user" }
]
function formatBytes(bytes?: number) {
if (bytes == null) return ""
if (bytes > 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`
return `${(bytes / 1024 ** 2).toFixed(1)} MB`
}
export function SystemDrawer({ open, onClose }: SystemDrawerProps) {
const [updates, setUpdates] = useState<UpdatesResp | null>(null)
const [jobs, setJobs] = useState<Job[]>([])
const [selectedService, setSelectedService] = useState("llama-swap")
const [logs, setLogs] = useState("")
const [loadingLogs, setLoadingLogs] = useState(false)
const [restartingServices, setRestartingServices] = useState<Record<string, boolean>>({})
const [activeTab, setActiveTab] = useState<"maintenance" | "logs">("maintenance")
const logContainerRef = useRef<HTMLPreElement | null>(null)
function loadUpdates() {
api<UpdatesResp>("/api/maintenance/updates")
.then(setUpdates)
.catch((e) => console.error("Error loading updates", e))
}
function loadJobs() {
api<{ jobs: Job[] }>("/api/jobs")
.then((data) => setJobs(data.jobs || []))
.catch((e) => console.error("Error loading jobs", e))
}
function loadServiceLogs(service: string) {
setLoadingLogs(true)
api<{ ok: boolean; text: string; err?: string }>(`/api/maintenance/logs?service=${service}&lines=150`)
.then((res) => {
if (res.ok) {
setLogs(res.text)
} else {
setLogs(`Fehler beim Laden der Logs: ${res.err || "Unbekannter Fehler"}`)
}
})
.catch((e) => setLogs(`Fehler: ${e.message}`))
.finally(() => {
setLoadingLogs(false)
// Scroll to bottom
setTimeout(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
}
}, 50)
})
}
// Poll jobs & updates when open
useEffect(() => {
if (!open) return
loadUpdates()
loadJobs()
const timer = setInterval(() => {
loadJobs()
loadUpdates()
}, 3000)
return () => clearInterval(timer)
}, [open])
// Load logs when tab is active or service changes
useEffect(() => {
if (!open || activeTab !== "logs") return
loadServiceLogs(selectedService)
}, [open, activeTab, selectedService])
async function triggerOsUpdate() {
try {
await api<{ job_id: string }>("/api/maintenance/os-update", { method: "POST" })
loadJobs()
setActiveTab("maintenance")
} catch (e: any) {
alert(`Fehler beim Starten des OS-Updates: ${e.message}`)
}
}
async function triggerEngineUpdate() {
try {
await api<{ job_id: string }>("/api/maintenance/engine-update", { method: "POST" })
loadJobs()
setActiveTab("maintenance")
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
async function triggerReboot() {
if (!confirm("Bist du sicher, dass du das gesamte Host-System neu starten willst?")) return
try {
await api("/api/maintenance/reboot", { method: "POST" })
alert("Reboot ausgelöst. System startet neu...")
onClose()
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
async function restartService(serviceId: string) {
setRestartingServices(prev => ({ ...prev, [serviceId]: true }))
try {
const res = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
method: "POST",
body: JSON.stringify({ service: serviceId })
})
if (res.ok) {
alert(`Dienst ${serviceId} wurde erfolgreich neu gestartet.`)
if (activeTab === "logs" && selectedService === serviceId) {
loadServiceLogs(serviceId)
}
} else {
alert(`Fehler beim Neustart: ${res.err || "Unbekannter Fehler"}`)
}
} catch (e: any) {
alert(`Fehler: ${e.message}`)
} finally {
setRestartingServices(prev => ({ ...prev, [serviceId]: false }))
}
}
async function cancelJob(jobId: string) {
try {
await api(`/api/jobs/${jobId}/cancel`, { method: "POST" })
loadJobs()
} catch (e: any) {
alert(`Fehler beim Abbrechen: ${e.message}`)
}
}
return (
<>
{/* Backdrop */}
<div
className={cn(
"fixed inset-0 bg-black/60 backdrop-blur-sm z-50 transition-opacity duration-300",
open ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
)}
onClick={onClose}
/>
{/* Drawer */}
<div
className={cn(
"fixed inset-y-0 right-0 w-full sm:w-[500px] bg-card/85 backdrop-blur-xl border-l border-border/60 shadow-2xl z-50 flex flex-col transform transition-transform duration-300 ease-in-out font-sans text-foreground",
open ? "translate-x-0" : "translate-x-full"
)}
>
{/* Header */}
<div className="flex h-16 shrink-0 items-center justify-between border-b border-border/40 px-6">
<div className="flex items-center gap-2">
<Cpu className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase font-space">OS-Zentrale & Pflege</h2>
</div>
<button
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Navigation Tabs */}
<div className="flex border-b border-border/40 px-6">
<button
onClick={() => setActiveTab("maintenance")}
className={cn(
"flex-1 py-3 text-xs font-semibold uppercase tracking-wider border-b-2 text-center transition-all",
activeTab === "maintenance"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
System-Wartung
</button>
<button
onClick={() => setActiveTab("logs")}
className={cn(
"flex-1 py-3 text-xs font-semibold uppercase tracking-wider border-b-2 text-center transition-all",
activeTab === "logs"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
System-Logs
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{activeTab === "maintenance" ? (
<>
{/* Quick Actions */}
<div className="space-y-3">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Wartungsaktionen</h3>
<div className="grid grid-cols-2 gap-3">
<button
onClick={triggerOsUpdate}
className="flex flex-col items-start gap-1 p-4 rounded-xl border border-border/60 bg-background/20 hover:border-primary/50 transition-all text-left group"
>
<Shield className="h-5 w-5 text-cyan-400 mb-1 group-hover:scale-105 transition-transform" />
<span className="text-xs font-semibold">OS Update (apt)</span>
<span className="text-[10px] text-muted-foreground">
{updates?.os ? `${updates.os} Updates verfügbar` : "Auf neuestem Stand"}
</span>
</button>
<button
onClick={triggerEngineUpdate}
className="flex flex-col items-start gap-1 p-4 rounded-xl border border-border/60 bg-background/20 hover:border-primary/50 transition-all text-left group"
>
<Server className="h-5 w-5 text-violet-400 mb-1 group-hover:scale-105 transition-transform" />
<span className="text-xs font-semibold">Engine Update</span>
<span className="text-[10px] text-muted-foreground">
{updates?.engine ? "Update verfügbar" : "Auf neuestem Stand"}
</span>
</button>
</div>
<button
onClick={triggerReboot}
className="flex w-full items-center gap-3 p-3 rounded-xl border border-red-500/20 bg-red-500/5 hover:bg-red-500/10 text-red-400 transition-all text-left text-xs font-semibold"
>
<Power className="h-4.5 w-4.5" />
<div>
<div>Host-System neu starten (Reboot)</div>
<div className="text-[10px] text-red-400/80 font-normal">Startet das gesamte Betriebssystem des Homelabs neu</div>
</div>
</button>
</div>
{/* Active Jobs */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Hintergrund-Aufgaben</h3>
<span className="text-[10px] font-mono bg-primary/10 text-primary px-1.5 py-0.5 rounded-full">
{jobs.filter(j => j.state === "running" || j.state === "queued").length} Aktiv
</span>
</div>
<div className="space-y-3">
{jobs.length === 0 ? (
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-xl p-6 text-center">
Aktuell keine aktiven Hintergrund-Jobs.
</div>
) : (
jobs.map((job) => {
const isActive = job.state === "running" || job.state === "queued"
return (
<div
key={job.id}
className={cn(
"p-3 rounded-xl border transition-all duration-300",
isActive
? "border-primary/40 bg-primary/5 shadow-md shadow-primary/5"
: "border-border/40 bg-background/20 opacity-80"
)}
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="text-xs font-semibold flex items-center gap-1.5">
{isActive && (
<span className="flex h-2 w-2 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
</span>
)}
{job.label}
</div>
<div className="text-[10px] font-mono text-muted-foreground uppercase flex items-center gap-2">
<span>ID: {job.id}</span>
<span></span>
<span className={cn(
job.state === "done" && "text-emerald-400",
job.state === "failed" && "text-red-400",
job.state === "running" && "text-primary",
job.state === "queued" && "text-amber-400",
job.state === "canceled" && "text-muted-foreground"
)}>
{job.state}
</span>
</div>
</div>
{isActive && (
<button
onClick={() => cancelJob(job.id)}
className="text-[10px] font-semibold text-red-400 hover:text-red-300 border border-red-500/20 bg-red-500/5 hover:bg-red-500/10 px-2 py-0.5 rounded transition-colors"
>
Abbrechen
</button>
)}
</div>
{/* Progress */}
{job.state === "running" && (
<div className="mt-3 space-y-1">
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-500"
style={{ width: `${job.progress ?? 0}%` }}
/>
</div>
<div className="flex justify-between items-center text-[9px] font-mono text-muted-foreground">
<span>{job.progress ?? 0}%</span>
{job.done_bytes != null && job.total_bytes != null && (
<span>
{formatBytes(job.done_bytes)} / {formatBytes(job.total_bytes)}
{job.rate_bps != null && ` (${formatBytes(job.rate_bps)}/s)`}
</span>
)}
{job.eta_s != null && <span>ETA: {job.eta_s}s</span>}
</div>
</div>
)}
</div>
)
})
)}
</div>
</div>
</>
) : (
// Logs View
<div className="flex flex-col h-full space-y-4">
{/* Service Select & Restart */}
<div className="flex items-center gap-2">
<select
value={selectedService}
onChange={(e) => setSelectedService(e.target.value)}
className="flex-1 h-9 px-3 text-xs bg-background/40 border border-border/60 rounded-lg outline-none text-foreground font-semibold"
>
{SERVICES.map((s) => (
<option key={s.id} value={s.id}>
{s.label} ({s.type === "system" ? "systemd-root" : "user"})
</option>
))}
</select>
<button
onClick={() => restartService(selectedService)}
disabled={restartingServices[selectedService]}
className="flex h-9 px-3 items-center gap-1.5 text-xs font-semibold rounded-lg border border-border/60 bg-background/20 hover:border-primary/50 transition-colors disabled:opacity-50"
title="Dienst neu starten"
>
<RefreshCw className={cn("h-3.5 w-3.5", restartingServices[selectedService] && "animate-spin")} />
Restart
</button>
</div>
{/* Log Console Container */}
<div className="flex-1 flex flex-col min-h-[300px] border border-border/60 bg-black/50 rounded-xl overflow-hidden shadow-inner">
{/* Console Header */}
<div className="flex h-9 items-center justify-between px-4 border-b border-border/40 bg-black/30 shrink-0">
<div className="flex items-center gap-1.5 text-[10px] font-mono text-muted-foreground">
<Terminal className="h-3 w-3 text-primary" />
<span>stdout/stderr - {selectedService}</span>
</div>
<button
onClick={() => loadServiceLogs(selectedService)}
disabled={loadingLogs}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className={cn("h-3 w-3", loadingLogs && "animate-spin")} />
</button>
</div>
{/* Console Log Area */}
<pre
ref={logContainerRef}
className="flex-1 p-4 overflow-y-auto text-[10px] font-mono text-cyan-300/90 whitespace-pre-wrap select-text leading-relaxed scrollbar-thin"
>
{loadingLogs && !logs ? (
<span className="text-muted-foreground">Lade Logs...</span>
) : (
logs || <span className="text-muted-foreground">Keine Logeinträge vorhanden.</span>
)}
</pre>
</div>
</div>
)}
</div>
</div>
</>
)
}
+55 -24
View File
@@ -1,28 +1,10 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
@import "tailwindcss"; @import "tailwindcss";
/* Design-System (von v3 übernommen): EINE Akzentfarbe Teal #2dd4bf. /* Design-System: EINE Akzentfarbe Teal. Dark als Default.
Dark als Default; Light als Fallback. Tokens als CSS-Variablen shadcn-Style. */ Glassmorphismus & Aurora Keyframes für den Vibe 2026. */
:root { :root {
--background: 0 0% 100%;
--foreground: 222 22% 12%;
--card: 0 0% 100%;
--card-foreground: 222 22% 12%;
--popover: 0 0% 100%;
--popover-foreground: 222 22% 12%;
--primary: 172 70% 38%;
--primary-foreground: 0 0% 100%;
--muted: 220 14% 95%;
--muted-foreground: 220 9% 42%;
--accent: 220 14% 94%;
--accent-foreground: 222 22% 12%;
--border: 220 13% 88%;
--input: 220 13% 88%;
--ring: 172 66% 50%;
--radius: 0.65rem;
}
.dark {
--background: 222 24% 7%; --background: 222 24% 7%;
--foreground: 210 20% 92%; --foreground: 210 20% 92%;
--card: 222 22% 10%; --card: 222 22% 10%;
@@ -38,10 +20,27 @@
--border: 220 14% 19%; --border: 220 14% 19%;
--input: 220 14% 19%; --input: 220 14% 19%;
--ring: 172 66% 50%; --ring: 172 66% 50%;
--radius: 0.75rem;
}
.light {
--background: 0 0% 100%;
--foreground: 222 22% 12%;
--card: 0 0% 100%;
--card-foreground: 222 22% 12%;
--popover: 0 0% 100%;
--popover-foreground: 222 22% 12%;
--primary: 172 70% 38%;
--primary-foreground: 0 0% 100%;
--muted: 220 14% 95%;
--muted-foreground: 220 9% 42%;
--accent: 220 14% 94%;
--accent-foreground: 222 22% 12%;
--border: 220 13% 88%;
--input: 220 13% 88%;
--ring: 172 66% 50%;
} }
/* Tailwind-v4-Mapping: macht bg-background, text-foreground, border-border nutzbar
und an die obigen (themable) Variablen gebunden. */
@theme inline { @theme inline {
--color-background: hsl(var(--background)); --color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground)); --color-foreground: hsl(var(--foreground));
@@ -61,22 +60,54 @@
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
--font-space: "Space Grotesk", sans-serif;
}
@keyframes aurora {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.animate-aurora {
background-size: 200% 200%;
animation: aurora 25s ease infinite;
}
/* Custom Scrollbars im Glassmorphismus-Design */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--border));
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--primary) / 0.4);
} }
* { * {
border-color: hsl(var(--border)); border-color: hsl(var(--border));
outline-color: hsl(var(--primary) / 0.5);
} }
html, html,
body, body,
#root { #root {
height: 100%; height: 100%;
overflow: hidden;
} }
body { body {
margin: 0; margin: 0;
background-color: hsl(var(--background)); background-color: hsl(var(--background));
color: hsl(var(--foreground)); color: hsl(var(--foreground));
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
+4 -2
View File
@@ -1,4 +1,5 @@
import { import {
LayoutDashboard,
Boxes, Boxes,
Route, Route,
Cpu, Cpu,
@@ -8,7 +9,7 @@ import {
type LucideIcon, type LucideIcon,
} from "lucide-react" } from "lucide-react"
export type ViewId = "models" | "routing" | "system" | "memory" | "connect" | "agent" export type ViewId = "dashboard" | "models" | "routing" | "system" | "memory" | "connect" | "agent"
export interface NavItem { export interface NavItem {
id: ViewId id: ViewId
@@ -19,9 +20,10 @@ export interface NavItem {
// Eine Quelle der Wahrheit für Sidebar UND Command-Palette. // Eine Quelle der Wahrheit für Sidebar UND Command-Palette.
export const NAV: NavItem[] = [ export const NAV: NavItem[] = [
{ id: "dashboard", label: "Zentrale", hint: "System- & Stack-Status", icon: LayoutDashboard },
{ id: "models", label: "Modelle & Routing", hint: "Modelle kuratieren, Gruppen & Auto-Routing", icon: Boxes }, { id: "models", label: "Modelle & Routing", hint: "Modelle kuratieren, Gruppen & Auto-Routing", icon: Boxes },
{ id: "routing", label: "Routing", hint: "Gateway-Regeln: schnell ↔ schwer", icon: Route }, { id: "routing", label: "Routing", hint: "Gateway-Regeln: schnell ↔ schwer", icon: Route },
{ id: "system", label: "System", hint: "Metriken, Dienste, Updates", icon: Cpu }, { id: "system", label: "Diagnose", hint: "Metriken, Dienste, Logs", icon: Cpu },
{ id: "memory", label: "Gedächtnis", hint: "Geteiltes Memory verwalten", icon: Brain }, { id: "memory", label: "Gedächtnis", hint: "Geteiltes Memory verwalten", icon: Brain },
{ id: "connect", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug }, { id: "connect", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug },
{ id: "agent", label: "Hermes", hint: "Agent-Status & WebUI öffnen", icon: Bot }, { id: "agent", label: "Hermes", hint: "Agent-Status & WebUI öffnen", icon: Bot },
+95 -37
View File
@@ -1,20 +1,31 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { ExternalLink } from "lucide-react" import { Bot, ExternalLink, Activity, Cpu, Wrench, Shield } from "lucide-react"
import { api, type AgentStatus } from "@/lib/api" import { api, type AgentStatus } from "@/lib/api"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Dot({ ok }: { ok: boolean }) { function Tile({ label, ok, detail, icon: Icon }: { label: string; ok: boolean; detail?: string; icon: any }) {
return <span className={cn("h-2 w-2 rounded-full", ok ? "bg-emerald-500" : "bg-amber-500")} />
}
function Tile({ label, ok, detail }: { label: string; ok: boolean; detail?: string }) {
return ( return (
<div className="rounded-lg border border-border bg-card p-4"> <div className={cn(
<div className="flex items-center gap-2"> "rounded-2xl border bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-3 hover:border-primary/40 transition-all duration-300",
<Dot ok={ok} /> ok ? "border-border/60" : "border-amber-500/30"
<span className="text-sm font-medium">{label}</span> )}>
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
<Icon className={cn("h-4.5 w-4.5", ok ? "text-primary" : "text-amber-500")} />
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<span className={cn(
"h-2 w-2 rounded-full ring-2 ring-black/40",
ok ? "bg-emerald-500 animate-pulse" : "bg-amber-500"
)} />
<span className="text-xs font-semibold text-foreground">
{ok ? "Bereit / Online" : "Offline / Inaktiv"}
</span>
</div>
{detail && <div className="text-[10px] font-mono text-muted-foreground truncate max-w-[200px]" title={detail}>{detail}</div>}
</div> </div>
{detail && <div className="mt-1 text-xs text-muted-foreground">{detail}</div>}
</div> </div>
) )
} }
@@ -26,60 +37,107 @@ export function AgentView() {
useEffect(() => { useEffect(() => {
const load = () => api<AgentStatus>("/api/agent/status").then(setS).catch((e) => setError(String(e))) const load = () => api<AgentStatus>("/api/agent/status").then(setS).catch((e) => setError(String(e)))
load() load()
const t = setInterval(load, 8000) const t = setInterval(load, 5000)
return () => clearInterval(t) return () => clearInterval(t)
}, []) }, [])
return ( return (
<div className="space-y-4"> <div className="space-y-6">
<div className="flex items-start justify-between"> {/* Header */}
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<div> <div>
<h1 className="text-xl font-semibold">Hermes</h1> <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">
Hermes Agenten-Cockpit
</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Der autonome Agent läuft eigenständig (Gateway :8642) und hat seine eigene Oberfläche Hermes ist ein autonomer Agent, der im Hintergrund der Box läuft (Port <code>:8642</code>) und seine eigene UI besitzt.
(hermes-webui :8787). Mission Control verlinkt nur Chat &amp; Steuerung leben dort.
</p> </p>
</div> </div>
{s?.webui_url && ( {s?.webui_url && (
<a <a
href={s.webui_url} href={s.webui_url}
target="_blank" target="_blank"
rel="noopener" rel="noopener"
className={cn( className={cn(
"flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium", "flex h-9 px-4 items-center gap-1.5 text-xs font-semibold rounded-lg transition-all cursor-pointer shadow-md shrink-0 self-start",
s.webui_reachable s.webui_reachable
? "bg-primary text-primary-foreground hover:opacity-90" ? "bg-primary text-primary-foreground hover:opacity-90 shadow-primary/10"
: "border border-border text-muted-foreground", : "border border-border/60 text-muted-foreground bg-background/20"
)} )}
> >
<ExternalLink className="h-4 w-4" /> Hermes öffnen <ExternalLink className="h-4 w-4" />
<span>Hermes WebUI öffnen</span>
</a> </a>
)} )}
</div> </div>
{error && <div className="text-sm text-muted-foreground">Status nicht lesbar ({error}).</div>} {error && (
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400 font-mono">
Status nicht lesbar ({error}).
</div>
)}
{s && ( {s && (
<> <div className="space-y-6">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> {/* Tile Grid */}
<Tile label="Gateway (:8642)" ok={s.gateway_reachable} detail={s.gateway_reachable ? "erreichbar" : "offline"} /> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Tile label="WebUI (:8787)" ok={s.webui_reachable} detail={s.webui_reachable ? "erreichbar" : "offline"} /> <Tile
<Tile label="Brain" ok={s.gateway_reachable} detail={`model: ${s.brain_model || "auto"}`} /> label="Agent Gateway"
<Tile label="Verdrahtung" ok={s.has_config} detail={`config ${s.has_config ? "✓" : "—"} · skills ${s.has_skills ? "✓" : "—"} · memories ${s.has_memories ? "✓" : "—"}`} /> ok={s.gateway_reachable}
detail="Port :8642 (REST API)"
icon={Bot}
/>
<Tile
label="Agent WebUI"
ok={s.webui_reachable}
detail="Port :8787 (Chat UI)"
icon={Activity}
/>
<Tile
label="Aktives Gehirn"
ok={s.gateway_reachable}
detail={s.brain_model ? `Model: ${s.brain_model}` : "Model: auto"}
icon={Cpu}
/>
<Tile
label="Verdrahtung"
ok={s.has_config}
detail={`Config: ${s.has_config ? "✓" : "—"} · Skills: ${s.has_skills ? "✓" : "—"} · Memory: ${s.has_memories ? "✓" : "—"}`}
icon={Wrench}
/>
</div> </div>
{/* Diagnostic Details if Offline */}
{!s.gateway_reachable && ( {!s.gateway_reachable && (
<div className="rounded-xl border border-dashed border-border bg-card/50 p-5 text-sm"> <div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-6 space-y-4 shadow-lg shadow-black/10">
<div className="mb-2 font-medium">Hermes ist (hier) offline</div> <div className="flex items-center gap-2">
<p className="text-muted-foreground"> <Shield className="h-5 w-5 text-amber-500" />
Der Agent + hermes-webui laufen auf der Box. Einrichtung & volle Verdrahtung <h3 className="text-sm font-bold uppercase tracking-wider text-foreground">Hermes-Agent Diagnostics</h3>
(Brain = <code>model: auto</code>, Tools/MCP inkl. <code>mcp_mc</code> + <code>mcp_memory</code>, </div>
SSHWindows, lokale Browser-/Such-MCP) sind im Runbook beschrieben:
<code className="ml-1">docs/HERMES_SETUP.md</code>. <div className="text-xs text-muted-foreground space-y-3 leading-relaxed">
</p> <p>
Der Hermes-Dienst ist auf der Box derzeit offline oder kann sich nicht mit dem lokalen llama-swap verbinden.
</p>
<p>
Stelle sicher, dass die systemd-Dienste auf der Box laufen. Du kannst den Status im <strong>OS &amp; Updates Drawer</strong> prüfen und die Dienste bei Bedarf neu starten.
</p>
<div className="p-3.5 bg-background/25 rounded-xl border border-border/30 font-mono text-[10px] space-y-1">
<div className="text-muted-foreground/60"># Dienste manuell auf der Box prüfen:</div>
<div className="text-primary">systemctl --user status hermes-gateway</div>
<div className="text-primary">systemctl --user status hermes-webui</div>
</div>
<p>
Weitere Einrichtungsdetails (Brain-Konfiguration, MCP-Kopplungen, SSH-Tunneling und Such-Engines) findest du in der Dokumentationsdatei:
<code className="ml-1 px-1.5 py-0.5 rounded bg-muted/40 text-foreground border border-border/30">docs/HERMES_SETUP.md</code>.
</p>
</div>
</div> </div>
)} )}
</> </div>
)} )}
</div> </div>
) )
+71 -32
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Check, Copy } from "lucide-react" import { Check, Copy, Terminal, Info, Globe, FolderOpen } from "lucide-react"
import { api, type ConnectResp } from "@/lib/api" import { api, type ConnectResp } from "@/lib/api"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -40,51 +40,63 @@ export function ConnectView() {
} }
return ( return (
<div className="space-y-4"> <div className="space-y-6">
{/* Header */}
<div> <div>
<h1 className="text-xl font-semibold">Verbinden</h1> <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">
Verbindung &amp; Integration
</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Fertige Snippets für deine Tools auf dem lokalen PC alle zeigen auf den Gateway der Box Kopiere vorgefertigte Konfigurationsdateien für deinen lokalen PC (IDEs, Cline, Roo Code, Cursor), um direkt auf das geteilte Gedächtnis und den Auto-Swap-Gateway der Box zuzugreifen.
(<code>model: auto</code>) + das geteilte Gedächtnis.
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-4 text-sm bg-card/40 p-3 rounded-lg border border-border"> {/* Connection Variables Panel */}
<div className="flex items-center gap-2"> <div className="grid gap-4 md:grid-cols-2 p-5 rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md shadow-lg shadow-black/10">
<label className="text-muted-foreground">Box-LAN-IP:</label> <div className="space-y-1.5">
<label className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
<Globe className="h-3.5 w-3.5 text-primary" /> Box LAN IP-Adresse
</label>
<input <input
value={host} value={host}
onChange={(e) => saveHost(e.target.value)} onChange={(e) => saveHost(e.target.value)}
className="w-36 rounded-md border border-border bg-background px-2 py-1 font-mono text-xs outline-none focus:ring-2 focus:ring-ring" placeholder="z.B. 192.168.178.151"
className="w-full h-9 rounded-lg border border-border/60 bg-background/40 px-3 font-mono text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
/> />
</div> </div>
<div className="flex items-center gap-2 flex-1 min-w-[280px]">
<label className="text-muted-foreground whitespace-nowrap">Lokaler MCP-Pfad:</label> <div className="space-y-1.5">
<label className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
<FolderOpen className="h-3.5 w-3.5 text-primary" /> Lokaler MCP-Scriptpfad
</label>
<input <input
value={mcpPath} value={mcpPath}
onChange={(e) => saveMcpPath(e.target.value)} onChange={(e) => saveMcpPath(e.target.value)}
placeholder="z.B. F:\Coding Stuff\mission-control-2\mcp\mcp_memory.py" placeholder="z.B. F:\Coding Stuff\mission-control-2\mcp\mcp_memory.py"
className="flex-1 rounded-md border border-border bg-background px-2 py-1 font-mono text-xs outline-none focus:ring-2 focus:ring-ring" className="w-full h-9 rounded-lg border border-border/60 bg-background/40 px-3 font-mono text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
/> />
</div> </div>
</div> </div>
{error && ( {error && (
<div className="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground"> <div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">
Snippets nicht ladbar ({error}). Fehler beim Generieren der Snippets: {error}
</div> </div>
)} )}
{data && ( {data && (
<> <div className="space-y-4">
<div className="flex flex-wrap gap-1"> {/* Tool Tab Bar */}
<div className="flex flex-wrap gap-1 bg-card/20 p-1 border border-border/40 rounded-xl max-w-fit">
{Object.entries(data.tools).map(([key, t]) => ( {Object.entries(data.tools).map(([key, t]) => (
<button <button
key={key} key={key}
onClick={() => setActive(key)} onClick={() => setActive(key)}
className={cn( className={cn(
"rounded-md px-3 py-1.5 text-sm transition-colors", "rounded-lg px-4 py-1.5 text-xs font-semibold tracking-wide uppercase transition-all cursor-pointer",
active === key ? "bg-primary/15 text-primary" : "text-muted-foreground hover:text-foreground", active === key
? "bg-primary text-primary-foreground shadow-md shadow-primary/10"
: "text-muted-foreground hover:text-foreground",
)} )}
> >
{t.label} {t.label}
@@ -93,23 +105,50 @@ export function ConnectView() {
</div> </div>
{tool && ( {tool && (
<div className="space-y-2"> <div className="space-y-3">
<div className="flex items-center justify-between"> {/* Note / Info */}
<span className="text-xs text-muted-foreground">{tool.note}</span> {tool.note && (
<button <div className="flex items-start gap-2.5 p-3 rounded-xl bg-primary/5 border border-primary/20 text-xs text-muted-foreground leading-relaxed">
onClick={copy} <Info className="h-4.5 w-4.5 text-primary shrink-0 mt-0.5" />
className="flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent" <span>{tool.note}</span>
> </div>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />} )}
{copied ? "Kopiert" : "Kopieren"}
</button> {/* Editor Mockup Window */}
<div className="flex flex-col border border-border/60 bg-black/60 rounded-2xl overflow-hidden shadow-2xl">
{/* Editor Header Bar */}
<div className="flex h-11 items-center justify-between px-4 border-b border-border/40 bg-black/40 shrink-0">
{/* Left: Window Control Dots */}
<div className="flex items-center gap-1.5">
<span className="h-3 w-3 rounded-full bg-red-500/80 shadow-md shadow-red-500/10" />
<span className="h-3 w-3 rounded-full bg-amber-500/80 shadow-md shadow-amber-500/10" />
<span className="h-3 w-3 rounded-full bg-emerald-500/80 shadow-md shadow-emerald-500/10" />
</div>
{/* Center: File Title */}
<div className="flex items-center gap-1.5 text-[10px] font-mono text-muted-foreground/75 bg-background/30 px-3 py-1 rounded-md border border-border/20">
<Terminal className="h-3.5 w-3.5 text-primary" />
<span>{active === "cline" || active === "cursor" ? "config.json" : "settings.json"}</span>
</div>
{/* Right: Copy Action */}
<button
onClick={copy}
className="flex h-7 px-2.5 items-center gap-1.5 rounded-lg border border-border/40 bg-background/30 text-[10px] font-semibold text-muted-foreground hover:text-foreground hover:bg-background/60 transition-all cursor-pointer"
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-400" /> : <Copy className="h-3.5 w-3.5" />}
<span>{copied ? "Kopiert" : "Kopieren"}</span>
</button>
</div>
{/* Editor Code Area */}
<pre className="p-5 overflow-x-auto text-xs font-mono text-cyan-200/90 whitespace-pre leading-relaxed scrollbar-thin select-text bg-black/10">
<code>{tool.snippet}</code>
</pre>
</div> </div>
<pre className="overflow-x-auto rounded-xl border border-border bg-card p-4 text-xs leading-relaxed">
<code>{tool.snippet}</code>
</pre>
</div> </div>
)} )}
</> </div>
)} )}
</div> </div>
) )
+317
View File
@@ -0,0 +1,317 @@
import { useEffect, useState } from "react"
import { Bot, Cpu, ExternalLink, Brain, Layers, Plus } from "lucide-react"
import { api, type SystemStatus, type AgentStatus, type ModelInfo, type Memory } from "@/lib/api"
import { cn } from "@/lib/utils"
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
// Verfärbung bei hoher Last
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>
)
}
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[]>([])
// 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(() => {})
}
useEffect(() => {
loadData()
const t = setInterval(loadData, 3000)
return () => clearInterval(t)
}, [])
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("")
// Liste sofort aktualisieren
api<Memory[]>("/api/memory?category=").then((d) => setMemories(d.slice(0, 3))).catch(() => {})
} catch (e) {
console.error(e)
} finally {
setSavingMem(false)
}
}
const activeModels = models.filter((m) => m.role)
return (
<div className="space-y-6">
<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">
Zentrale
</h1>
<p className="text-sm text-muted-foreground">Aktueller Status von System, Modellen und Agent.</p>
</div>
<div className="grid gap-6 sm:grid-cols-2">
{/* Card 1: System Status */}
<div className="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} Cores`} />
<RadialGauge value={sys.ram.percent} label="RAM" detail={`${gb(sys.ram.used)} / ${gb(sys.ram.total)} GB`} />
{sys.gpu && sys.gpu.busy_percent != null && (
<RadialGauge
value={sys.gpu.busy_percent}
label="GPU"
detail={sys.gpu.gtt_used != null && sys.gpu.gtt_total != null ? `${gb(sys.gpu.gtt_used)} / ${gb(sys.gpu.gtt_total)} GB` : undefined}
/>
)}
{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: 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={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 className="p-3 bg-background/20 rounded-xl border border-border/40">
<div className="text-[10px] text-muted-foreground uppercase font-semibold">Aktives Gehirn</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>
</div>
<div className="grid gap-6 sm:grid-cols-2">
{/* Card 3: Active Model Roles */}
<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">Aktive Rollen</h2>
</div>
<div className="space-y-2">
{activeModels.length === 0 ? (
<div className="text-xs text-muted-foreground py-6 text-center">Keine Modelle als Rollen zugewiesen.</div>
) : (
activeModels.map((m) => {
const isRunning = running.includes(m.name)
return (
<div
key={m.name}
className={cn(
"flex items-center justify-between p-3 rounded-xl border transition-all duration-300",
isRunning
? "border-primary/50 bg-primary/5 shadow-md shadow-primary/5"
: "border-border/40 bg-background/20"
)}
>
<div>
<div className="flex items-center gap-2">
<span className={cn("text-xs font-semibold uppercase px-1.5 py-0.5 rounded",
m.role === "fast" ? "bg-cyan-500/15 text-cyan-400" :
m.role === "heavy" ? "bg-amber-500/15 text-amber-400" :
m.role === "coder" ? "bg-violet-500/15 text-violet-400" :
m.role === "vision" ? "bg-pink-500/15 text-pink-400" : "bg-muted text-muted-foreground"
)}>
{m.role}
</span>
{isRunning && (
<span className="flex h-2 w-2 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
</span>
)}
</div>
<div className="text-xs font-medium mt-1.5 truncate max-w-[200px] sm:max-w-[280px]" title={m.name}>
{m.name}
</div>
</div>
<div className="text-[10px] font-mono text-muted-foreground">
{isRunning ? "Warm / Aktiv" : "Bereit"}
</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 4: 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-Schnellform</h2>
</div>
<div className="space-y-3">
<div className="flex items-start gap-2">
<textarea
value={memContent}
onChange={(e) => setMemContent(e.target.value)}
placeholder="Fakt / Regel auf der Box speichern..."
rows={2}
className="flex-1 resize-none rounded-xl border border-border/50 bg-background/30 px-3 py-2 text-xs outline-none focus:ring-1.5 focus:ring-primary transition-all"
/>
</div>
<div className="flex items-center gap-2 justify-end">
<select
value={memCat}
onChange={(e) => setMemCat(e.target.value)}
className="rounded-lg border border-border/50 bg-background/50 px-2 py-1 text-xs outline-none"
>
<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 items-center gap-1 rounded-lg bg-primary px-3 py-1 text-xs 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-4 space-y-2">
<div className="text-[10px] text-muted-foreground uppercase font-semibold tracking-wider">Zuletzt gespeichert:</div>
{memories.length === 0 ? (
<div className="text-xs text-muted-foreground/75 py-2">Keine Einträge vorhanden.</div>
) : (
memories.map((m) => (
<div key={m.id} className="text-xs bg-background/10 border border-border/30 rounded-lg p-2 flex items-start gap-2">
<span className="shrink-0 text-[10px] 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 className="mt-4 text-[10px] text-muted-foreground/80 border-t border-border/30 pt-3">
Steht allen Clients (IDEs, Hermes) per MCP zur Verfügung.
</div>
</div>
</div>
</div>
)
}
+178 -73
View File
@@ -1,12 +1,26 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Trash2, Sparkles } from "lucide-react" import { Trash2, Sparkles, User, Scroll, Shield, Tag, Clock, Search, Plus, BookOpen } from "lucide-react"
import { api, type DedupeResult, type Memory } from "@/lib/api" import { api, type DedupeResult, type Memory } from "@/lib/api"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"] const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"]
const CAT_LABEL: Record<string, string> = {
user: "👤 User", instruction: "📋 Regel", stable: "🔵 Fakt", const CAT_CONFIG: Record<string, { label: string; icon: any; color: string; border: string; bg: string; text: string }> = {
versioned: "🟡 Version", ephemeral: "⏱ Temporär", user: { label: "User", icon: User, color: "text-cyan-400", border: "border-cyan-500/30", bg: "bg-cyan-500/10", text: "text-cyan-400" },
instruction: { label: "Regel", icon: Scroll, color: "text-violet-400", border: "border-violet-500/30", bg: "bg-violet-500/10", text: "text-violet-400" },
stable: { label: "Fakt", icon: Shield, color: "text-indigo-400", border: "border-indigo-500/30", bg: "bg-indigo-500/10", text: "text-indigo-400" },
versioned: { label: "Version", icon: Tag, color: "text-amber-400", border: "border-amber-500/30", bg: "bg-amber-500/10", text: "text-amber-400" },
ephemeral: { label: "Temporär", icon: Clock, color: "text-pink-400", border: "border-pink-500/30", bg: "bg-pink-500/10", text: "text-pink-400" },
}
const DEFAULT_CAT = { label: "Gedächtnis", icon: BookOpen, color: "text-muted-foreground", border: "border-border/40", bg: "bg-muted/10", text: "text-muted-foreground" }
const BORDER_CLASSES: Record<string, string> = {
user: "border-l-cyan-500/80",
instruction: "border-l-violet-500/80",
stable: "border-l-indigo-500/80",
versioned: "border-l-amber-500/80",
ephemeral: "border-l-pink-500/80",
} }
export function MemoryView() { export function MemoryView() {
@@ -16,123 +30,214 @@ export function MemoryView() {
const [content, setContent] = useState("") const [content, setContent] = useState("")
const [category, setCategory] = useState("stable") const [category, setCategory] = useState("stable")
const [error, setError] = useState("") const [error, setError] = useState("")
const [deduping, setDeduping] = useState(false)
function load() { function load() {
const params = new URLSearchParams() const params = new URLSearchParams()
if (q) params.set("q", q) if (q) params.set("q", q)
if (filter) params.set("category", filter) if (filter) params.set("category", filter)
api<Memory[]>(`/api/memory?${params}`).then(setItems).catch((e) => setError(String(e))) api<Memory[]>(`/api/memory?${params}`)
.then(setItems)
.catch((e) => setError(String(e)))
} }
useEffect(load, [q, filter]) useEffect(load, [q, filter])
async function add() { async function add() {
if (!content.trim()) return if (!content.trim()) return
await api("/api/memory", { method: "POST", body: JSON.stringify({ content, category, source: "ui" }) }) await api("/api/memory", {
method: "POST",
body: JSON.stringify({ content, category, source: "ui" })
})
setContent("") setContent("")
load() load()
} }
async function del(id: string) { async function del(id: string) {
await api(`/api/memory/${id}`, { method: "DELETE" }) await api(`/api/memory/${id}`, { method: "DELETE" })
load() load()
} }
async function cleanup() { async function cleanup() {
const dry = await api<DedupeResult>("/api/memory/dedupe", { setDeduping(true)
method: "POST", body: JSON.stringify({ apply: false }), try {
}) const dry = await api<DedupeResult>("/api/memory/dedupe", {
if (dry.duplicate_count === 0) { method: "POST",
alert("Keine Dubletten gefunden — alles sauber.") body: JSON.stringify({ apply: false }),
return })
} if (dry.duplicate_count === 0) {
if (confirm(`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden. Entfernen?`)) { alert("Keine Dubletten gefunden — alles sauber.")
await api("/api/memory/dedupe", { method: "POST", body: JSON.stringify({ apply: true }) }) return
load() }
if (confirm(`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden. Entfernen?`)) {
await api("/api/memory/dedupe", {
method: "POST",
body: JSON.stringify({ apply: true })
})
load()
}
} catch (e: any) {
alert(`Fehler: ${e.message}`)
} finally {
setDeduping(false)
} }
} }
return ( return (
<div className="space-y-4"> <div className="space-y-6">
<div className="flex items-start justify-between"> {/* Header */}
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<div> <div>
<h1 className="text-xl font-semibold">Gedächtnis</h1> <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">
Gedächtnis-Pool (Memory)
</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Die geteilte Verfassung" alle Tools (Hermes, IDEs) lesen/schreiben hier via MCP. Die geteilte Konstitution des Systems. Alle Instanzen (Hermes, IDEs, Gateway) lesen und schreiben hierauf per MCP-Protokoll.
</p> </p>
</div> </div>
<button <button
onClick={cleanup} onClick={cleanup}
className="flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent" disabled={deduping}
className="flex h-9 px-4 items-center gap-1.5 text-xs font-semibold rounded-lg border border-border/60 bg-card hover:border-primary/50 transition-all cursor-pointer shadow-md disabled:opacity-50 shrink-0 self-start"
> >
<Sparkles className="h-3.5 w-3.5 text-primary" /> Aufräumen <Sparkles className="h-4 w-4 text-primary animate-pulse" />
<span>Deduplizieren</span>
</button> </button>
</div> </div>
{/* Add */} {/* Add New Fact Box */}
<div className="rounded-xl border border-border bg-card p-3"> <div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-4">
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Neuen Eintrag anlegen</div>
<textarea <textarea
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
placeholder="Neuen Fakt / Regel hinzufügen…" placeholder="Füge eine neue Regel, eine Vorliebe oder einen stabilen Fakt über das Projekt oder dich hinzu..."
rows={2} rows={3}
className="w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring" className="w-full resize-none rounded-xl border border-border/60 bg-background/30 p-3.5 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground transition-all leading-relaxed"
/> />
<div className="mt-2 flex items-center gap-2">
<select <div className="flex flex-wrap items-center justify-between gap-3">
value={category} <div className="flex items-center gap-2">
onChange={(e) => setCategory(e.target.value)} <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Kategorie</span>
className="rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none" <select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="h-8 rounded-lg border border-border/60 bg-background/50 px-2 py-1 text-xs outline-none font-semibold text-foreground cursor-pointer"
>
{CATEGORIES.map((c) => (
<option key={c} value={c} className="bg-popover text-foreground">
{CAT_CONFIG[c]?.label || c}
</option>
))}
</select>
</div>
<button
onClick={add}
className="h-9 px-4 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all flex items-center gap-1.5 cursor-pointer shadow-md shadow-primary/10"
> >
{CATEGORIES.map((c) => ( <Plus className="h-4 w-4" /> Speichern
<option key={c} value={c}>{CAT_LABEL[c]}</option>
))}
</select>
<button onClick={add} className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90">
Speichern
</button> </button>
</div> </div>
</div> </div>
{/* Filter */} {/* Filter / Search HUD */}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-col md:flex-row items-stretch md:items-center gap-3">
<input <div className="relative flex-1">
value={q} <input
onChange={(e) => setQ(e.target.value)} value={q}
placeholder="Suchen…" onChange={(e) => setQ(e.target.value)}
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-ring" placeholder="Gedächtnis durchsuchen..."
/> className="w-full h-9 pl-9 pr-3 rounded-lg border border-border/60 bg-card/45 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
<button />
onClick={() => setFilter("")} <Search className="absolute left-3 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
className={cn("rounded-md px-2.5 py-1.5 text-xs", !filter ? "bg-primary/15 text-primary" : "text-muted-foreground hover:text-foreground")} </div>
>
Alle <div className="flex flex-wrap items-center gap-1.5 p-1 bg-card/30 border border-border/40 rounded-xl overflow-x-auto max-w-full">
</button>
{CATEGORIES.map((c) => (
<button <button
key={c} onClick={() => setFilter("")}
onClick={() => setFilter(c)} className={cn(
className={cn("rounded-md px-2.5 py-1.5 text-xs", filter === c ? "bg-primary/15 text-primary" : "text-muted-foreground hover:text-foreground")} "h-7 px-3 rounded-lg text-[10px] font-semibold uppercase tracking-wider transition-all cursor-pointer whitespace-nowrap",
!filter ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
> >
{CAT_LABEL[c]} Alle
</button> </button>
))} {CATEGORIES.map((c) => {
const conf = CAT_CONFIG[c] || DEFAULT_CAT
const Icon = conf.icon
return (
<button
key={c}
onClick={() => setFilter(c)}
className={cn(
"h-7 px-2.5 rounded-lg text-[10px] font-semibold uppercase tracking-wider transition-all cursor-pointer flex items-center gap-1 whitespace-nowrap",
filter === c
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="h-3 w-3" />
<span>{conf.label}</span>
</button>
)
})}
</div>
</div> </div>
{error && <div className="text-sm text-muted-foreground">Fehler: {error}</div>} {error && (
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">
Fehler beim Laden des Gedächtnisses: {error}
</div>
)}
{/* List */} {/* Facts List */}
<div className="space-y-2"> <div className="space-y-3">
{items.length === 0 && <div className="text-sm text-muted-foreground">Keine Einträge.</div>} {items.length === 0 ? (
{items.map((m) => ( <div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center">
<div key={m.id} className="flex items-start gap-3 rounded-lg border border-border bg-card p-3"> Keine Einträge für die aktuellen Filterkriterien gefunden.
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
{CAT_LABEL[m.category] || m.category}
</span>
<span className="flex-1 text-sm">{m.content}</span>
<span className="shrink-0 text-[11px] text-muted-foreground">{m.source}</span>
<button onClick={() => del(m.id)} className="shrink-0 text-muted-foreground hover:text-red-500">
<Trash2 className="h-4 w-4" />
</button>
</div> </div>
))} ) : (
items.map((m) => {
const conf = CAT_CONFIG[m.category] || DEFAULT_CAT
const Icon = conf.icon
return (
<div
key={m.id}
className={cn(
"flex items-start justify-between gap-4 p-4 rounded-xl border border-l-4 bg-card/45 backdrop-blur-md border-border/60 shadow-md shadow-black/5 hover:border-primary/20 transition-all group",
BORDER_CLASSES[m.category] || "border-l-muted"
)}
>
<div className="flex items-start gap-3 flex-1 min-w-0">
<span className={cn(
"flex items-center gap-1 px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider font-mono shrink-0",
conf.bg, conf.text
)}>
<Icon className="h-3 w-3" />
<span className="hidden sm:inline">{conf.label}</span>
</span>
<span className="text-xs text-foreground leading-relaxed break-words whitespace-pre-wrap flex-1">{m.content}</span>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="text-[9px] font-mono text-muted-foreground/60 bg-background/20 px-1.5 py-0.5 rounded uppercase tracking-wider">
{m.source}
</span>
<button
onClick={() => del(m.id)}
className="h-7 w-7 rounded-lg flex items-center justify-center border border-border/40 text-muted-foreground hover:text-red-400 hover:bg-red-500/5 transition-all opacity-0 group-hover:opacity-100"
title="Eintrag löschen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
})
)}
</div> </div>
</div> </div>
) )
+330 -174
View File
@@ -1,13 +1,15 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Download } from "lucide-react" import { Download, Search, Trash2, Edit3, Star, Layers, Activity, HardDrive } from "lucide-react"
import { api, type DiscoverResp, type Fit, type Job, type ModelInfo } from "@/lib/api" import { api, type DiscoverResp, type Fit, type Job, type ModelInfo } from "@/lib/api"
import { CapsChips } from "@/components/CapsChips" import { CapsChips } from "@/components/CapsChips"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function fmtBytes(b?: number) { function fmtBytes(b?: number) {
if (!b) return "" if (!b) return ""
return `${(b / 1024 ** 3).toFixed(1)} GB` if (b > 1024 ** 3) return `${(b / 1024 ** 3).toFixed(1)} GB`
return `${(b / 1024 ** 2).toFixed(0)} MB`
} }
function fmtEta(s?: number) { function fmtEta(s?: number) {
if (!s) return "" if (!s) return ""
const m = Math.floor(s / 60) const m = Math.floor(s / 60)
@@ -16,36 +18,69 @@ function fmtEta(s?: number) {
function JobsBar() { function JobsBar() {
const [jobs, setJobs] = useState<Job[]>([]) const [jobs, setJobs] = useState<Job[]>([])
function load() {
api<{ jobs: Job[] }>("/api/jobs")
.then((d) => setJobs(d.jobs || []))
.catch(() => {})
}
useEffect(() => { useEffect(() => {
const load = () => api<{ jobs: Job[] }>("/api/jobs").then((d) => setJobs(d.jobs)).catch(() => {})
load() load()
const t = setInterval(load, 2000) const t = setInterval(load, 2000)
return () => clearInterval(t) return () => clearInterval(t)
}, []) }, [])
async function cancelJob(jobId: string) {
try {
await api(`/api/jobs/${jobId}/cancel`, { method: "POST" })
load()
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
}
const active = jobs.filter((j) => j.state === "running" || j.state === "queued") const active = jobs.filter((j) => j.state === "running" || j.state === "queued")
const recent = jobs.filter((j) => j.state !== "running" && j.state !== "queued").slice(-3) const recent = jobs.filter((j) => j.state !== "running" && j.state !== "queued").slice(-3)
if (active.length === 0 && recent.length === 0) return null if (active.length === 0 && recent.length === 0) return null
return ( return (
<div className="space-y-2 rounded-xl border border-border bg-card p-3"> <div className="space-y-3 rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 shadow-lg shadow-black/10">
<div className="text-xs font-medium text-muted-foreground">Downloads</div> <div className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">Aktive Downloads</div>
{active.map((j) => ( {active.map((j) => (
<div key={j.id} className="space-y-1"> <div key={j.id} className="space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40">
<div className="flex justify-between text-xs"> <div className="flex justify-between items-center text-xs">
<span className="truncate">{j.label}</span> <span className="font-semibold truncate max-w-[250px]">{j.label}</span>
<span className="text-muted-foreground"> <div className="flex items-center gap-3">
{j.progress ?? 0}% · {fmtBytes(j.done_bytes)}/{fmtBytes(j.total_bytes)} <span className="text-muted-foreground font-mono">
{j.eta_s ? ` · ETA ${fmtEta(j.eta_s)}` : ""} {j.progress ?? 0}% {fmtBytes(j.done_bytes)}/{fmtBytes(j.total_bytes)}
</span> {j.eta_s ? ` • ETA ${fmtEta(j.eta_s)}` : ""}
</span>
<button
onClick={() => cancelJob(j.id)}
className="text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all"
>
Abbrechen
</button>
</div>
</div> </div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted"> <div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${j.progress ?? 0}%` }} /> <div className="h-full rounded-full bg-primary transition-all duration-500" style={{ width: `${j.progress ?? 0}%` }} />
</div> </div>
</div> </div>
))} ))}
{recent.map((j) => ( {recent.map((j) => (
<div key={j.id} className="flex justify-between text-xs text-muted-foreground"> <div key={j.id} className="flex justify-between items-center text-xs text-muted-foreground px-1">
<span className="truncate">{j.label}</span> <span className="truncate">{j.label}</span>
<span className={j.state === "done" ? "text-emerald-500" : "text-amber-500"}>{j.state}</span> <span className={cn(
"font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono",
j.state === "done" ? "bg-emerald-500/10 text-emerald-400" : "bg-amber-500/10 text-amber-400"
)}>
{j.state}
</span>
</div> </div>
))} ))}
</div> </div>
@@ -57,19 +92,20 @@ function fmtSize(b: number | null) {
const gb = b / 1024 ** 3 const gb = b / 1024 ** 3
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(b / 1024 ** 2).toFixed(0)} MB` return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(b / 1024 ** 2).toFixed(0)} MB`
} }
function fmtCtx(c: number | null) { function fmtCtx(c: number | null) {
return c ? `${Math.round(c / 1024)}k` : "—" return c ? `${Math.round(c / 1024)}k` : "—"
} }
function FitBadge({ fit }: { fit: Fit }) { function FitBadge({ fit }: { fit: Fit }) {
const tone = { const tone = {
perfect: "bg-emerald-500/15 text-emerald-500", perfect: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/20",
marginal: "bg-amber-500/15 text-amber-500", marginal: "bg-amber-500/15 text-amber-400 border border-amber-500/20",
too_tight: "bg-red-500/15 text-red-500", too_tight: "bg-red-500/15 text-red-400 border border-red-500/20",
}[fit.level] }[fit.level]
return ( return (
<span className={cn("rounded px-1.5 py-0.5 text-[11px] font-medium", tone)}> <span className={cn("rounded px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider font-mono", tone)}>
{fit.text} · ~{fit.req_gb} GB {fit.text} {fit.req_gb} GB RAM
</span> </span>
) )
} }
@@ -78,99 +114,146 @@ const ROLES = ["", "fast", "heavy", "coder", "reasoning", "agent", "vision", "sc
function Installed() { function Installed() {
const [models, setModels] = useState<ModelInfo[]>([]) const [models, setModels] = useState<ModelInfo[]>([])
const [running, setRunning] = useState<string[]>([])
const [error, setError] = useState("") const [error, setError] = useState("")
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
function load() { function load() {
api<{ models: ModelInfo[] }>("/api/models") api<{ models: ModelInfo[]; running?: string[] }>("/api/models")
.then((d) => setModels(d.models)) .then((d) => {
setModels(d.models || [])
setRunning(d.running || [])
})
.catch((e) => setError(String(e))) .catch((e) => setError(String(e)))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
} }
useEffect(load, [])
useEffect(() => {
load()
const t = setInterval(load, 3000)
return () => clearInterval(t)
}, [])
async function setRole(name: string, role: string) { async function setRole(name: string, role: string) {
await api(`/api/models/${encodeURIComponent(name)}/role`, { await api(`/api/models/${encodeURIComponent(name)}/role`, {
method: "POST", body: JSON.stringify({ role: role || null }), method: "POST",
body: JSON.stringify({ role: role || null }),
}) })
load() load()
} }
async function setCtx(name: string, cur: number | null) { async function setCtx(name: string, cur: number | null) {
const v = prompt("Kontextlänge (Tokens):", String(cur || 32768)) const v = prompt("Kontextlänge (Tokens):", String(cur || 32768))
if (!v) return if (!v) return
await api(`/api/models/${encodeURIComponent(name)}/ctx`, { await api(`/api/models/${encodeURIComponent(name)}/ctx`, {
method: "POST", body: JSON.stringify({ ctx: parseInt(v, 10) }), method: "POST",
body: JSON.stringify({ ctx: parseInt(v, 10) }),
}) })
load() load()
} }
async function del(name: string) { async function del(name: string) {
if (!confirm(`Modell '${name}' aus der Config entfernen? (GGUF-Datei bleibt)`)) return if (!confirm(`Modell '${name}' aus der Config entfernen? (GGUF-Datei bleibt)`)) return
await api(`/api/models/${encodeURIComponent(name)}`, { method: "DELETE" }) await api(`/api/models/${encodeURIComponent(name)}`, { method: "DELETE" })
load() load()
} }
if (loading) return <div className="text-sm text-muted-foreground">Lade</div> if (loading) return <div className="text-xs text-muted-foreground py-6 text-center">Lade installierte Modelle</div>
if (error) if (error)
return ( return (
<div className="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground"> <div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">
Engine nicht erreichbar oder keine Config gefunden ({error}). Engine nicht erreichbar oder keine Config gefunden ({error}).
</div> </div>
) )
return ( return (
<div className="overflow-hidden rounded-xl border border-border bg-card"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<table className="w-full text-sm"> {models.length === 0 ? (
<thead> <div className="col-span-full text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center">
<tr className="border-b border-border text-left text-xs text-muted-foreground"> Keine Modelle konfiguriert. Verwende den Tab "Modelle finden" zum Herunterladen.
<th className="px-4 py-2 font-medium">Modell</th> </div>
<th className="px-4 py-2 font-medium">Rolle</th> ) : (
<th className="px-4 py-2 font-medium">Fähigkeiten</th> models.map((m) => {
<th className="px-4 py-2 font-medium">Kontext</th> const isRunning = running.includes(m.name)
<th className="px-4 py-2 font-medium">Größe</th> return (
<th className="px-4 py-2 font-medium">Aktionen</th> <div
</tr> key={m.name}
</thead> className={cn(
<tbody> "rounded-2xl border bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-4 transition-all duration-300 hover:border-primary/40 group",
{models.length === 0 && ( isRunning ? "border-primary/45 shadow-primary/5" : "border-border/60"
<tr> )}
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground"> >
Keine Modelle konfiguriert. <div className="space-y-3.5">
</td> <div className="flex items-start justify-between gap-3">
</tr> <div className="space-y-1">
)} <h3 className="text-xs font-bold tracking-tight text-foreground line-clamp-2 break-all" title={m.name}>
{models.map((m) => ( {m.name}
<tr key={m.name} className="border-b border-border/50 last:border-0"> </h3>
<td className="px-4 py-2.5 font-medium">{m.name}</td> <div className="flex items-center gap-2">
<td className="px-4 py-2.5"> <span className="text-[10px] font-mono text-muted-foreground bg-background/40 px-1.5 py-0.5 rounded border border-border/30">
<select {m.quant || "GGUF"}
value={m.role || ""} </span>
onChange={(e) => setRole(m.name, e.target.value)} {isRunning && (
className="rounded-md border border-border bg-background px-1.5 py-1 text-xs outline-none" <span className="flex items-center gap-1 text-[9px] font-semibold text-emerald-400 uppercase tracking-wider">
title="Rolle/Alias setzen (so tauschst du z.B. das fast-Hirn)" <Activity className="h-3 w-3 animate-pulse" /> Warm
> </span>
{ROLES.map((r) => ( )}
<option key={r} value={r}>{r || "—"}</option> </div>
))} </div>
</select> <button
</td> onClick={() => del(m.name)}
<td className="px-4 py-2.5"> className="h-7 w-7 rounded-lg flex items-center justify-center border border-border/40 text-muted-foreground hover:text-red-400 hover:bg-red-500/5 transition-all opacity-0 group-hover:opacity-100 shrink-0"
<CapsChips caps={m.capabilities} /> title="Modell aus Config entfernen"
</td> >
<td className="px-4 py-2.5"> <Trash2 className="h-3.5 w-3.5" />
<button onClick={() => setCtx(m.name, m.ctx)} className="text-muted-foreground hover:text-foreground" title="Kontext ändern"> </button>
{fmtCtx(m.ctx)} </div>
</button>
</td> <div className="border-t border-border/30 pt-3 flex flex-wrap gap-1">
<td className="px-4 py-2.5 text-muted-foreground">{fmtSize(m.size_bytes)}</td> <CapsChips caps={m.capabilities} />
<td className="px-4 py-2.5"> </div>
<button onClick={() => del(m.name)} className="text-muted-foreground hover:text-red-500" title="Aus Config entfernen"> </div>
🗑
</button> <div className="space-y-3 pt-2">
</td> <div className="grid grid-cols-2 gap-2 text-[10px] font-mono text-muted-foreground">
</tr> <div className="flex items-center gap-1.5 p-2 bg-background/20 rounded-lg border border-border/20">
))} <HardDrive className="h-3.5 w-3.5 text-primary/80" />
</tbody> <div>
</table> <div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Größe</div>
<div className="text-foreground font-semibold">{fmtSize(m.size_bytes)}</div>
</div>
</div>
<button
onClick={() => setCtx(m.name, m.ctx)}
className="flex items-center gap-1.5 p-2 bg-background/20 rounded-lg border border-border/20 hover:border-primary/40 text-left transition-colors"
>
<Edit3 className="h-3.5 w-3.5 text-primary/80" />
<div>
<div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Kontext</div>
<div className="text-foreground font-semibold">{fmtCtx(m.ctx)}</div>
</div>
</button>
</div>
<div className="flex items-center justify-between gap-2 border-t border-border/30 pt-3">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rolle</span>
<select
value={m.role || ""}
onChange={(e) => setRole(m.name, e.target.value)}
className="h-8 rounded-lg border border-border/60 bg-background/60 px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground cursor-pointer font-semibold min-w-[120px]"
title="Weise diesem Modell eine Systemrolle zu"
>
{ROLES.map((r) => (
<option key={r} value={r} className="bg-popover text-foreground">{r || "Keine Rolle"}</option>
))}
</select>
</div>
</div>
</div>
)
})
)}
</div> </div>
) )
} }
@@ -186,85 +269,119 @@ function AddModel() {
async function loadQuants(r?: string) { async function loadQuants(r?: string) {
const rr = r ?? repo const rr = r ?? repo
if (!rr.trim()) return if (!rr.trim()) return
setMsg("Lade Quants…") setMsg("Analysiere HuggingFace Repository...")
try { try {
const d = await api<{ repo: string; quants: string[] }>(`/api/hf/quants?repo=${encodeURIComponent(rr)}`) const d = await api<{ repo: string; quants: string[] }>(`/api/hf/quants?repo=${encodeURIComponent(rr)}`)
setRepo(d.repo) setRepo(d.repo)
setQuants(d.quants) setQuants(d.quants)
if (d.quants.length) setQuant(d.quants.includes("Q4_K_M") ? "Q4_K_M" : d.quants[0]) if (d.quants.length) setQuant(d.quants.includes("Q4_K_M") ? "Q4_K_M" : d.quants[0])
setMsg(d.quants.length ? "" : "Keine GGUF-Quants gefunden") setMsg(d.quants.length ? "" : "Keine GGUF-Dateien in diesem Repository gefunden.")
} catch (e) { } catch (e) {
setMsg(`Fehler: ${e}`) setMsg(`Fehler: ${e}`)
} }
} }
async function search() { async function search() {
if (!q.trim()) return if (!q.trim()) return
const d = await api<{ results: { repo: string; downloads: number }[] }>(`/api/hf/search?q=${encodeURIComponent(q)}`) setMsg("Durchsuche HuggingFace...")
setResults(d.results) try {
const d = await api<{ results: { repo: string; downloads: number }[] }>(`/api/hf/search?q=${encodeURIComponent(q)}`)
setResults(d.results)
setMsg(d.results.length ? "" : "Keine Ergebnisse gefunden.")
} catch (e) {
setMsg(`Suche fehlgeschlagen: ${e}`)
}
} }
async function install() { async function install() {
if (!repo.trim()) return if (!repo.trim()) return
setMsg("Installiere…") setMsg("Download-Job wird initiiert...")
try { try {
await api("/api/models/install", { await api("/api/models/install", {
method: "POST", body: JSON.stringify({ repo, quant, jinja: true }), method: "POST",
body: JSON.stringify({ repo, quant, jinja: true }),
}) })
setMsg(`Download gestartet: ${repo} (${quant}) Fortschritt oben.`) setMsg(`Download gestartet: ${repo} (${quant}). Fortschritt wird oben angezeigt.`)
} catch (e) { } catch (e) {
setMsg(`Fehler: ${e}`) setMsg(`Download-Fehler: ${e}`)
} }
} }
return ( return (
<div className="space-y-3 rounded-xl border border-border bg-card p-4"> <div className="space-y-4 rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10">
<div className="text-sm font-medium">Eigenes Modell laden (HuggingFace)</div> <div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">HF Download &amp; Suche</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-col sm:flex-row gap-2">
<input <input
value={repo} value={repo}
onChange={(e) => setRepo(e.target.value)} onChange={(e) => setRepo(e.target.value)}
placeholder="HF-URL oder org/repo (z.B. unsloth/Qwen3.6-35B-A3B-GGUF)" placeholder="HF-URL oder org/repo (z.B. unsloth/Qwen2.5-Coder-7B-Instruct-GGUF)"
className="min-w-[280px] flex-1 rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-ring" className="flex-1 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"
/> />
<button onClick={() => loadQuants()} className="rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent"> <div className="flex gap-2">
Quants laden <button
</button> onClick={() => loadQuants()}
{quants.length > 0 && ( className="h-9 px-4 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer whitespace-nowrap"
<> >
<select value={quant} onChange={(e) => setQuant(e.target.value)} className="rounded-md border border-border bg-background px-2 py-1.5 text-sm"> Quants laden
{quants.map((qq) => <option key={qq} value={qq}>{qq}</option>)} </button>
</select> {quants.length > 0 && (
<button onClick={install} className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90"> <>
Installieren <select
</button> value={quant}
</> onChange={(e) => setQuant(e.target.value)}
)} className="h-9 rounded-lg border border-border/60 bg-background/40 px-3 text-xs outline-none text-foreground font-semibold"
>
{quants.map((qq) => <option key={qq} value={qq} className="bg-popover text-foreground">{qq}</option>)}
</select>
<button
onClick={install}
className="h-9 px-4 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all cursor-pointer flex items-center gap-1.5"
>
<Download className="h-3.5 w-3.5" /> Herunterladen
</button>
</>
)}
</div>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex gap-2 border-t border-border/20 pt-4">
<input <div className="relative flex-1">
value={q} <input
onChange={(e) => setQ(e.target.value)} value={q}
onKeyDown={(e) => e.key === "Enter" && search()} onChange={(e) => setQ(e.target.value)}
placeholder="HuggingFace durchsuchen (GGUF)…" onKeyDown={(e) => e.key === "Enter" && search()}
className="min-w-[240px] flex-1 rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-ring" placeholder="HuggingFace durchsuchen (z.B. Llama-3.1)..."
/> className="w-full h-9 pl-9 pr-3 rounded-lg border border-border/60 bg-background/40 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
<button onClick={search} className="rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent">Suchen</button> />
<Search className="absolute left-3 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
</div>
<button
onClick={search}
className="h-9 px-4 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer"
>
Suchen
</button>
</div> </div>
{results.length > 0 && ( {results.length > 0 && (
<div className="max-h-48 space-y-1 overflow-y-auto"> <div className="max-h-48 space-y-1.5 overflow-y-auto border border-border/40 bg-background/20 p-2 rounded-xl scrollbar-thin">
{results.map((r) => ( {results.map((r) => (
<button <button
key={r.repo} key={r.repo}
onClick={() => { setRepo(r.repo); setResults([]); setQ(""); loadQuants(r.repo) }} onClick={() => { setRepo(r.repo); setResults([]); setQ(""); loadQuants(r.repo) }}
className="flex w-full items-center justify-between rounded-md px-2 py-1 text-left text-xs hover:bg-accent" className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-xs bg-background/10 border border-border/10 hover:border-primary/30 hover:bg-background/30 transition-all"
> >
<span className="truncate">{r.repo}</span> <span className="font-semibold truncate">{r.repo}</span>
<span className="text-muted-foreground">{r.downloads.toLocaleString()}</span> <span className="text-[10px] font-mono text-muted-foreground flex items-center gap-1 shrink-0">
<Download className="h-3 w-3" /> {r.downloads.toLocaleString()}
</span>
</button> </button>
))} ))}
</div> </div>
)} )}
{msg && <div className="text-xs text-muted-foreground">{msg}</div>}
{msg && <div className="text-[10px] font-medium text-primary font-mono">{msg}</div>}
</div> </div>
) )
} }
@@ -283,59 +400,90 @@ function Discover() {
}, []) }, [])
async function install(repo: string, role: string, quant: string, toolCapable: boolean) { async function install(repo: string, role: string, quant: string, toolCapable: boolean) {
setInstalling((s) => ({ ...s, [repo]: "" })) setInstalling((s) => ({ ...s, [repo]: "Starte..." }))
try { try {
await api("/api/models/install", { await api("/api/models/install", {
method: "POST", method: "POST",
body: JSON.stringify({ repo, role, quant, jinja: toolCapable }), body: JSON.stringify({ repo, role, quant, jinja: toolCapable }),
}) })
setInstalling((s) => ({ ...s, [repo]: "geladen" })) setInstalling((s) => ({ ...s, [repo]: "Download läuft" }))
} catch (e) { } catch (e) {
setInstalling((s) => ({ ...s, [repo]: `Fehler` })) setInstalling((s) => ({ ...s, [repo]: "Fehler" }))
} }
} }
if (loading) return <div className="text-sm text-muted-foreground">Suche aktuelle Modelle</div> if (loading) return <div className="text-xs text-muted-foreground py-12 text-center">Analysiere Hardware und suche passende GGUF-Empfehlungen</div>
if (error || !data) if (error || !data)
return ( return (
<div className="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground"> <div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">
Modell-Quellen gerade nicht erreichbar ({error}). Empfehlungsdienst temporär nicht erreichbar ({error}).
</div> </div>
) )
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<AddModel /> <AddModel />
<div className="text-xs text-muted-foreground">
Live von HuggingFace · Hardware-Fit für ~{data.sys_ram_gb} GB · = beste Wahl je Kategorie <div className="text-[10px] font-mono text-muted-foreground bg-background/25 border border-border/40 p-3 rounded-xl">
Modell-Registry geladen für {data.sys_ram_gb} GB System-RAM markiert die empfohlene Standard-Rolle.
</div> </div>
{data.categories.map((cat) => ( {data.categories.map((cat) => (
<div key={cat.role} className="space-y-2"> <div key={cat.role} className="space-y-3">
<h3 className="text-sm font-semibold">{cat.title}</h3> <div className="flex items-center gap-2 px-1">
<div className="grid gap-2 sm:grid-cols-2"> <Layers className="h-4 w-4 text-primary" />
{cat.models.map((m) => ( <h3 className="text-xs font-bold uppercase tracking-wider text-foreground">{cat.title}</h3>
<div key={m.repo} className="rounded-lg border border-border bg-card p-3"> </div>
<div className="flex items-center gap-2">
{cat.recommended === m.repo && <span title="beste Wahl"></span>} <div className="grid gap-4 sm:grid-cols-2">
<span className="truncate text-sm font-medium">{m.name}</span> {cat.models.map((m) => {
const isRec = cat.recommended === m.repo
return (
<div
key={m.repo}
className={cn(
"rounded-2xl border bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-4 transition-all duration-300 hover:border-primary/40",
isRec ? "border-primary/30" : "border-border/60"
)}
>
<div className="space-y-3.5">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-1.5">
{isRec && <span title="Empfohlen für diese Rolle"><Star className="h-3.5 w-3.5 text-amber-400 fill-amber-400" /></span>}
<h4 className="text-xs font-bold text-foreground truncate max-w-[200px]" title={m.name}>
{m.name}
</h4>
</div>
<span className="text-[10px] font-mono text-muted-foreground">{m.author}</span>
</div>
<div className="text-[10px] font-mono bg-background/40 px-1.5 py-0.5 rounded border border-border/30 text-muted-foreground">
{m.quant}
</div>
</div>
<div className="flex flex-wrap items-center gap-1.5 border-t border-border/30 pt-3">
<CapsChips caps={m.caps} />
<FitBadge fit={m.fit} />
</div>
</div>
{m.fit.level !== "too_tight" && (
<button
onClick={() => install(m.repo, m.role, m.quant, m.caps.tools !== "no")}
disabled={!!installing[m.repo]}
className={cn(
"mt-2 h-8 w-full flex items-center justify-center gap-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer border border-border/60 bg-background/20 hover:border-primary/50 disabled:opacity-50",
installing[m.repo] && "border-primary/40 bg-primary/5 text-primary"
)}
>
<Download className={cn("h-3.5 w-3.5", !installing[m.repo] && "text-primary")} />
{installing[m.repo] || "Modell laden"}
</button>
)}
</div> </div>
<div className="mt-1 text-xs text-muted-foreground">{m.author}</div> )
<div className="mt-2 flex flex-wrap items-center gap-1"> })}
<CapsChips caps={m.caps} />
<FitBadge fit={m.fit} />
</div>
{m.fit.level !== "too_tight" && (
<button
onClick={() => install(m.repo, m.role, m.quant, m.caps.tools !== "no")}
disabled={!!installing[m.repo]}
className="mt-2 flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-accent disabled:opacity-60"
>
<Download className="h-3.5 w-3.5 text-primary" />
{installing[m.repo] || "Installieren"}
</button>
)}
</div>
))}
</div> </div>
</div> </div>
))} ))}
@@ -346,32 +494,40 @@ function Discover() {
export function ModelsView() { export function ModelsView() {
const [tab, setTab] = useState<"installed" | "discover">("installed") const [tab, setTab] = useState<"installed" | "discover">("installed")
return ( return (
<div className="space-y-4"> <div className="space-y-6">
<div> <div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<h1 className="text-xl font-semibold">Modelle &amp; Routing</h1> <div>
<p className="text-sm text-muted-foreground"> <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">
Installierte Modelle (mit Fähigkeiten) und aktuell beste Modelle für deine Hardware. Modell-Zentrale
</p> </h1>
<p className="text-sm text-muted-foreground">
Verwalte installierte GGUFs, weise Systemrollen zu und lade neue Modelle von HuggingFace.
</p>
</div>
<div className="flex rounded-xl border border-border/60 bg-card/45 backdrop-blur-md p-1 self-start">
{(["installed", "discover"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
"rounded-lg px-4 py-1.5 text-xs font-semibold tracking-wide uppercase transition-all cursor-pointer",
tab === t
? "bg-primary text-primary-foreground shadow-md shadow-primary/10"
: "text-muted-foreground hover:text-foreground",
)}
>
{t === "installed" ? "Installiert" : "Suchen & Entdecken"}
</button>
))}
</div>
</div> </div>
<JobsBar /> <JobsBar />
<div className="inline-flex rounded-lg border border-border bg-card p-0.5 text-sm"> <div className="transition-all duration-300">
{(["installed", "discover"] as const).map((t) => ( {tab === "installed" ? <Installed /> : <Discover />}
<button
key={t}
onClick={() => setTab(t)}
className={cn(
"rounded-md px-3 py-1.5 transition-colors",
tab === t ? "bg-primary/15 text-primary" : "text-muted-foreground hover:text-foreground",
)}
>
{t === "installed" ? "Installiert" : "Modelle finden"}
</button>
))}
</div> </div>
{tab === "installed" ? <Installed /> : <Discover />}
</div> </div>
) )
} }
+157 -51
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Route, GitBranch, ArrowRight, Settings, AlertCircle } from "lucide-react"
import { api, type RoutingResp } from "@/lib/api" import { api, type RoutingResp } from "@/lib/api"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -7,79 +8,184 @@ export function RoutingView() {
const [error, setError] = useState("") const [error, setError] = useState("")
useEffect(() => { useEffect(() => {
api<RoutingResp>("/api/routing").then(setData).catch((e) => setError(String(e))) api<RoutingResp>("/api/routing")
.then(setData)
.catch((e) => setError(String(e)))
}, []) }, [])
return ( return (
<div className="space-y-4"> <div className="space-y-6">
{/* Header */}
<div> <div>
<h1 className="text-xl font-semibold">Routing</h1> <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">
Gateway &amp; Routing
</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Eingebauter OpenAI-Gateway: ein Endpunkt für alle Tools. <code>model: auto</code> = schnell im Eingebauter OpenAI-Gateway für Vibe Coding &amp; Hermes. Verwende den Endpunkt <code>model: auto</code>, um je nach Komplexität automatisch zwischen <code>fast</code> und <code>heavy</code> zu routen.
Alltag (<code>fast</code>), wechselt bei komplexen/langen Anfragen auf <code>heavy</code>.
Gilt für Hermes <em>und</em> Vibe Coding.
</p> </p>
</div> </div>
{error && ( {error && (
<div className="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground"> <div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">
Gateway-Config nicht lesbar ({error}). Gateway-Konfiguration nicht lesbar ({error}).
</div> </div>
)} )}
{data && ( {data && (
<> <div className="space-y-6">
<div className="flex items-center gap-2 text-sm"> {/* Status HUD Card */}
<span <div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col sm:flex-row justify-between sm:items-center gap-4">
className={cn( <div className="flex items-center gap-3">
"h-2 w-2 rounded-full", <span className={cn(
data.gateway_reachable ? "bg-emerald-500" : "bg-amber-500", "h-2.5 w-2.5 rounded-full ring-2 ring-black/40",
)} data.gateway_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500"
/> )} />
Gateway {data.gateway_reachable ? "online" : "offline"} <div>
{data.endpoint && <span className="text-muted-foreground">· {data.endpoint}</span>} <div className="text-xs font-semibold uppercase tracking-wider text-foreground">Gateway-Status</div>
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">{data.endpoint || "Lokaler Proxy"}</div>
</div>
</div>
{data.heavy_threshold_chars && ( {data.heavy_threshold_chars && (
<span className="ml-auto text-xs text-muted-foreground"> <div className="p-3 bg-background/25 rounded-xl border border-border/30 text-right">
autoheavy ab {data.heavy_threshold_chars} Zeichen <div className="text-[9px] text-muted-foreground uppercase font-bold tracking-wider">Auto-Routing-Schwelle</div>
</span> <div className="text-xs font-semibold text-primary mt-0.5 font-mono">
&gt; {data.heavy_threshold_chars.toLocaleString()} Zeichen heavy
</div>
</div>
)} )}
</div> </div>
<div className="overflow-hidden rounded-xl border border-border bg-card"> {/* Visual Routing flow */}
<table className="w-full text-sm"> <div className="grid gap-6 md:grid-cols-3">
<thead> {/* Box 1: Ingress */}
<tr className="border-b border-border text-left text-xs text-muted-foreground"> <div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 flex flex-col justify-between gap-4">
<th className="px-4 py-2 font-medium">Gateway-Modell</th> <div>
<th className="px-4 py-2 font-medium"> Backend (llama-swap)</th> <div className="flex items-center gap-2 mb-3">
</tr> <Settings className="h-4.5 w-4.5 text-primary" />
</thead> <h3 className="text-xs font-bold uppercase tracking-wider text-foreground">1. API Ingress</h3>
<tbody> </div>
{data.routes.map((r) => ( <p className="text-xs text-muted-foreground leading-relaxed">
<tr key={r.name} className="border-b border-border/50 last:border-0"> Deine IDE oder dein Agent sendet Anfragen mit <code>model: auto</code> an den lokalen Gateway-Port.
<td className="px-4 py-2.5 font-medium">{r.name}</td> </p>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{r.target}</td> </div>
</tr> <div className="p-3 bg-background/20 rounded-xl border border-border/20 font-mono text-[10px]">
))} <div className="text-muted-foreground/60">HEADER</div>
</tbody> <div className="text-primary truncate">Authorization: Bearer key</div>
</table> <div className="text-muted-foreground/60 mt-1">MODEL</div>
<div className="text-foreground">"auto"</div>
</div>
</div>
{/* Box 2: Routing Logic */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 flex flex-col justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-3">
<Route className="h-4.5 w-4.5 text-primary" />
<h3 className="text-xs font-bold uppercase tracking-wider text-foreground">2. Analysator</h3>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
Der Gateway misst die Länge des Prompts. Kurze Tasks landen bei <code>fast</code>, anspruchsvolle Tasks werden an <code>heavy</code> weitergeleitet.
</p>
</div>
<div className="space-y-1.5 font-mono text-[9px] p-2 bg-background/10 rounded-xl border border-border/10">
<div className="flex items-center justify-between text-cyan-400">
<span>Prompt &lt; {data.heavy_threshold_chars}</span>
<span className="flex items-center gap-1">Fast-Hirn <ArrowRight className="h-3 w-3" /></span>
</div>
<div className="flex items-center justify-between text-violet-400">
<span>Prompt &gt;= {data.heavy_threshold_chars}</span>
<span className="flex items-center gap-1">Heavy-Hirn <ArrowRight className="h-3 w-3" /></span>
</div>
</div>
</div>
{/* Box 3: Execution */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 flex flex-col justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-3">
<GitBranch className="h-4.5 w-4.5 text-primary" />
<h3 className="text-xs font-bold uppercase tracking-wider text-foreground">3. Llama Swap</h3>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
Llama Swap tauscht das Modell bei Bedarf vollautomatisch im VRAM aus (Auto-Swap). Keine manuelle Zuweisung nötig.
</p>
</div>
<div className="p-3 bg-primary/5 rounded-xl border border-primary/20 text-center">
<span className="text-[10px] font-bold text-primary animate-pulse">Auto-Swap aktiv</span>
</div>
</div>
</div> </div>
{/* Active Routes Cards */}
<div className="space-y-3">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground px-1">Gateway-Routen</h3>
<div className="grid gap-3 sm:grid-cols-2">
{data.routes.map((r) => (
<div
key={r.name}
className="flex items-center justify-between p-4 rounded-xl border border-border/60 bg-card/45 backdrop-blur-md hover:border-primary/30 transition-colors"
>
<div className="space-y-1">
<div className="text-xs font-bold text-foreground font-mono">{r.name}</div>
<div className="text-[9px] text-muted-foreground uppercase">Gateway-Alias</div>
</div>
<div className="flex items-center gap-2">
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/60" />
<div className="px-2.5 py-1 rounded-lg bg-background/40 border border-border/40 text-[10px] font-mono text-primary font-semibold truncate max-w-[160px] sm:max-w-[200px]" title={r.target}>
{r.target}
</div>
</div>
</div>
))}
</div>
</div>
{/* Fallback Rules */}
{data.fallbacks.length > 0 && ( {data.fallbacks.length > 0 && (
<div className="rounded-xl border border-border bg-card p-4 text-sm"> <div className="space-y-3">
<div className="mb-2 font-medium">Eskalation (Fallbacks)</div> <div className="flex items-center gap-1.5 px-1">
<ul className="space-y-1 text-muted-foreground"> <AlertCircle className="h-4 w-4 text-amber-500" />
{data.fallbacks.map((f, i) => { <h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Eskalationspfad (Fallbacks)</h3>
const [k, v] = Object.entries(f)[0] </div>
return (
<li key={i}> <div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 space-y-3">
<code>{k}</code> <code>{v.join(", ")}</code> <div className="text-[10px] text-muted-foreground">
</li> Sollte ein angefordertes Modell offline oder überlastet sein, eskaliert das Routing sequenziell entlang dieser vordefinierten Kette:
) </div>
})}
</ul> <div className="space-y-2">
{data.fallbacks.map((f, i) => {
const [k, v] = Object.entries(f)[0]
return (
<div
key={i}
className="flex flex-wrap items-center gap-2 p-2.5 bg-background/25 rounded-xl border border-border/20 font-mono text-xs"
>
<span className="font-semibold text-amber-400">{k}</span>
<ArrowRight className="h-3 w-3 text-muted-foreground/60" />
<div className="flex flex-wrap gap-1.5">
{v.map((item, idx) => (
<span
key={idx}
className={cn(
"px-1.5 py-0.5 rounded text-[10px]",
idx === 0 ? "bg-primary/10 text-primary border border-primary/20" : "bg-muted text-muted-foreground"
)}
>
{item}
</span>
))}
</div>
</div>
)
})}
</div>
</div>
</div> </div>
)} )}
</> </div>
)} )}
</div> </div>
) )
+271 -140
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { ExternalLink, RefreshCw, Save } from "lucide-react" import { ExternalLink, RefreshCw, Save, Cpu, HardDrive, Cpu as GpuIcon, Activity, ShieldAlert } from "lucide-react"
import { api, type ServicesResp, type SystemStatus, type UpdatesResp } from "@/lib/api" import { api, type ServicesResp, type SystemStatus, type UpdatesResp } from "@/lib/api"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -7,95 +7,140 @@ function gb(b: number) {
return (b / 1024 ** 3).toFixed(1) return (b / 1024 ** 3).toFixed(1)
} }
function Maintenance() { function DiagnosticBar({ label, percent, detail, icon: Icon }: { label: string; percent: number; detail?: string; icon: any }) {
const [u, setU] = useState<UpdatesResp | null>(null) const barColor = percent > 90
const [msg, setMsg] = useState("") ? "bg-red-500 shadow-md shadow-red-500/20"
: percent > 75
function load() { ? "bg-amber-500 shadow-md shadow-amber-500/20"
api<UpdatesResp>("/api/maintenance/updates").then(setU).catch(() => {}) : "bg-primary shadow-md shadow-primary/20"
}
useEffect(load, [])
async function post(path: string, label: string) {
setMsg(`${label}`)
try {
const r = await api<{ job_id?: string; ok?: boolean; err?: string }>(path, { method: "POST" })
setMsg(r.job_id ? `${label} gestartet (Job ${r.job_id})` : r.ok ? `${label}` : `${label}: ${r.err || "?"}`)
} catch (e) {
setMsg(`${label}: ${e}`)
}
}
async function restart(service: string) {
setMsg(`Restart ${service}`)
try {
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
method: "POST", body: JSON.stringify({ service }),
})
setMsg(r.ok ? `Restart ${service}` : `Restart ${service}: ${r.err || "?"}`)
} catch (e) {
setMsg(`Restart ${service}: ${e}`)
}
}
async function upgrade(repo: string, role: string) {
setMsg(`Lade ${repo}`)
try {
await api("/api/models/install", { method: "POST", body: JSON.stringify({ repo, role, quant: "Q4_K_M", jinja: true }) })
setMsg(`Upgrade ${repo} gestartet`)
} catch (e) {
setMsg(`Fehler: ${e}`)
}
}
return ( return (
<div className="rounded-xl border border-border bg-card p-4"> <div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-3 hover:border-primary/30 transition-all duration-300">
<div className="mb-3 flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium">Wartung &amp; Updates</span> <div className="flex items-center gap-2">
{u && ( <Icon className="h-4.5 w-4.5 text-primary" />
<span className="flex gap-2 text-xs"> <span className="text-xs font-semibold uppercase tracking-wider text-foreground">{label}</span>
<span className={cn("rounded px-1.5 py-0.5", u.os ? "bg-amber-500/15 text-amber-500" : "bg-muted text-muted-foreground")}>OS: {u.os}</span>
<span className={cn("rounded px-1.5 py-0.5", u.engine ? "bg-amber-500/15 text-amber-500" : "bg-muted text-muted-foreground")}>Engine: {u.engine ? "neu" : "aktuell"}</span>
<span className={cn("rounded px-1.5 py-0.5", u.models ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground")}>Modelle: {u.models}</span>
</span>
)}
</div>
<div className="flex flex-wrap gap-2 text-sm">
<button onClick={() => post("/api/maintenance/os-update", "OS-Update")} className="rounded-md border border-border px-2.5 py-1.5 hover:bg-accent">OS aktualisieren</button>
<button onClick={() => post("/api/maintenance/engine-update", "Engine-Update")} className="rounded-md border border-border px-2.5 py-1.5 hover:bg-accent">Engine aktualisieren</button>
<button onClick={() => restart("llama-swap")} className="flex items-center gap-1 rounded-md border border-border px-2.5 py-1.5 hover:bg-accent"><RefreshCw className="h-3.5 w-3.5" /> Engine neu starten</button>
<button onClick={() => { if (confirm("Box wirklich neu starten?")) post("/api/maintenance/reboot", "Reboot") }} className="rounded-md border border-border px-2.5 py-1.5 text-amber-500 hover:bg-accent">Reboot</button>
</div>
{u && u.model_list.length > 0 && (
<div className="mt-3 space-y-1">
<div className="text-xs text-muted-foreground">Modell-Upgrades verfügbar:</div>
{u.model_list.map((m) => (
<div key={m.repo} className="flex items-center justify-between text-xs">
<span className="truncate"><span className="text-primary">{m.role}</span> · {m.repo}</span>
<button onClick={() => upgrade(m.repo, m.role)} className="rounded-md border border-border px-2 py-0.5 hover:bg-accent">Upgrade</button>
</div>
))}
</div> </div>
)} <span className="text-xs font-mono font-bold text-foreground">{Math.round(percent)}%</span>
{msg && <div className="mt-2 text-xs text-muted-foreground">{msg}</div>}
<div className="mt-2 text-[11px] text-muted-foreground">
OS-Update/Reboot brauchen einmalig erweiterte NOPASSWD-sudoers (siehe BEDIENUNG/CUTOVER).
</div> </div>
<div className="w-full h-2 bg-background/40 rounded-full overflow-hidden border border-border/20">
<div
className={cn("h-full transition-all duration-700 ease-out", barColor)}
style={{ width: `${Math.min(percent, 100)}%` }}
/>
</div>
{detail && <div className="text-[10px] font-mono text-muted-foreground/80">{detail}</div>}
</div> </div>
) )
} }
function Bar({ label, percent, detail }: { label: string; percent: number; detail?: string }) { function MaintenanceSection() {
const [u, setU] = useState<UpdatesResp | null>(null)
const [msg, setMsg] = useState("")
const [loading, setLoading] = useState(false)
function load() {
api<UpdatesResp>("/api/maintenance/updates").then(setU).catch(() => {})
}
useEffect(load, [])
async function postAction(path: string, label: string) {
setMsg(`${label} wird ausgeführt...`)
setLoading(true)
try {
const r = await api<{ job_id?: string; ok?: boolean; err?: string }>(path, { method: "POST" })
setMsg(r.job_id ? `${label} gestartet (Job-ID: ${r.job_id})` : r.ok ? `${label} erfolgreich ausgeführt.` : `Fehler: ${r.err || "Unbekannt"}`)
} catch (e: any) {
setMsg(`Fehler bei ${label}: ${e.message}`)
} finally {
setLoading(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.`)
} catch (e: any) {
setMsg(`Upgrade fehlgeschlagen: ${e.message}`)
}
}
return ( return (
<div className="rounded-lg border border-border bg-card p-4"> <div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-4">
<div className="flex items-baseline justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium">{label}</span> <div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Wartung &amp; Updates</div>
<span className="text-sm text-muted-foreground">{Math.round(percent)}%</span> {u && (
<div className="flex gap-1.5 text-[9px] font-mono font-bold uppercase tracking-wider">
<span className={cn("px-1.5 py-0.5 rounded", u.os ? "bg-amber-500/10 text-amber-400 border border-amber-500/20" : "bg-background/40 text-muted-foreground border border-border/20")}>
OS: {u.os}
</span>
<span className={cn("px-1.5 py-0.5 rounded", u.engine ? "bg-amber-500/10 text-amber-400 border border-amber-500/20" : "bg-background/40 text-muted-foreground border border-border/20")}>
Engine: {u.engine ? "neu" : "aktuell"}
</span>
<span className={cn("px-1.5 py-0.5 rounded", u.models ? "bg-primary/10 text-primary border border-primary/20" : "bg-background/40 text-muted-foreground border border-border/20")}>
Modelle: {u.models}
</span>
</div>
)}
</div> </div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary" style={{ width: `${Math.min(percent, 100)}%` }} /> <div className="flex flex-wrap gap-2">
<button
onClick={() => postAction("/api/maintenance/os-update", "OS-Update")}
disabled={loading}
className="h-8 px-3 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer disabled:opacity-50"
>
OS (Apt) aktualisieren
</button>
<button
onClick={() => postAction("/api/maintenance/engine-update", "Engine-Update")}
disabled={loading}
className="h-8 px-3 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer disabled:opacity-50"
>
Engine aktualisieren
</button>
<button
onClick={() => { if (confirm("Bist du sicher, dass du den Host neu starten willst?")) postAction("/api/maintenance/reboot", "Reboot") }}
disabled={loading}
className="h-8 px-3 rounded-lg border border-red-500/30 bg-red-500/5 text-red-400 text-xs font-semibold hover:bg-red-500/10 transition-all cursor-pointer disabled:opacity-50"
>
Host Reboot
</button>
</div>
{u && u.model_list.length > 0 && (
<div className="mt-3 space-y-2 border-t border-border/20 pt-3">
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Verfügbare Modell-Updates:</div>
<div className="space-y-1.5">
{u.model_list.map((m) => (
<div key={m.repo} className="flex items-center justify-between p-2.5 rounded-xl bg-background/20 border border-border/30 text-xs font-semibold">
<span className="truncate"><span className="text-primary uppercase font-mono text-[10px] mr-1.5">{m.role}</span> {m.repo}</span>
<button
onClick={() => upgradeModel(m.repo, m.role)}
className="px-2.5 py-1 text-[10px] font-bold rounded-md bg-primary text-primary-foreground hover:opacity-90 transition-all cursor-pointer"
>
Laden
</button>
</div>
))}
</div>
</div>
)}
{msg && <div className="text-[10px] font-mono text-primary font-medium">{msg}</div>}
<div className="text-[9px] text-muted-foreground/60 leading-normal border-t border-border/10 pt-2 flex items-center gap-1">
<ShieldAlert className="h-3.5 w-3.5 shrink-0" />
<span>OS-Update & Reboot benötigen NOPASSWD Berechtigungen in der sudoers Datei des Hosts.</span>
</div> </div>
{detail && <div className="mt-1 text-xs text-muted-foreground">{detail}</div>}
</div> </div>
) )
} }
@@ -105,107 +150,193 @@ export function SystemView() {
const [svc, setSvc] = useState<ServicesResp | null>(null) const [svc, setSvc] = useState<ServicesResp | null>(null)
const [error, setError] = useState("") const [error, setError] = useState("")
const [backupMsg, setBackupMsg] = useState("") const [backupMsg, setBackupMsg] = useState("")
const [restartingServices, setRestartingServices] = useState<Record<string, boolean>>({})
function load() {
api<SystemStatus>("/api/system/status").then(setS).catch((e) => setError(String(e)))
api<ServicesResp>("/api/system/services").then(setSvc).catch(() => {})
}
useEffect(() => { useEffect(() => {
const load = () => {
api<SystemStatus>("/api/system/status").then(setS).catch((e) => setError(String(e)))
api<ServicesResp>("/api/system/services").then(setSvc).catch(() => {})
}
load() load()
const t = setInterval(load, 3000) const t = setInterval(load, 3000)
return () => clearInterval(t) return () => clearInterval(t)
}, []) }, [])
async function doBackup() { async function doBackup() {
setBackupMsg("") setBackupMsg("Backup snapshotted...")
try { try {
const r = await api<{ ok: boolean; snapshot: string; files: string[] }>("/api/system/backup", { method: "POST" }) const r = await api<{ ok: boolean; snapshot: string; files: string[] }>("/api/system/backup", { method: "POST" })
setBackupMsg(r.ok ? `Snapshot ${r.snapshot} (${r.files.length} Dateien)` : "Nichts zu sichern") setBackupMsg(r.ok ? `Snapshot erzeugt: ${r.snapshot} (${r.files.length} Dateien)` : "Keine Änderungen zu sichern.")
} catch (e) { } catch (e: any) {
setBackupMsg(`Fehler: ${e}`) setBackupMsg(`Fehler: ${e.message}`)
}
}
async function restartService(serviceId: string) {
setRestartingServices(prev => ({ ...prev, [serviceId]: true }))
try {
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
method: "POST",
body: JSON.stringify({ service: serviceId })
})
if (r.ok) {
alert(`Dienst ${serviceId} wurde erfolgreich neu gestartet.`)
} else {
alert(`Fehler beim Neustart: ${r.err || "Unbekannter Fehler"}`)
}
} catch (e: any) {
alert(`Fehler: ${e.message}`)
} finally {
setRestartingServices(prev => ({ ...prev, [serviceId]: false }))
} }
} }
return ( return (
<div className="space-y-4"> <div className="space-y-6">
{/* Header */}
<div> <div>
<h1 className="text-xl font-semibold">System</h1> <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">
<p className="text-sm text-muted-foreground">Live-Auslastung der Box, Dienste &amp; Updates.</p> System-Diagnose &amp; Status
</h1>
<p className="text-sm text-muted-foreground flex items-center gap-1">
Echtzeit-Ressourcen der Box, Dienst-Überwachung und Systempflege.
</p>
</div> </div>
{error && ( {error && (
<div className="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground"> <div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400 font-mono">
System-Status nicht lesbar ({error}). System-Status nicht lesbar ({error}).
</div> </div>
)} )}
{/* Metrics Section */}
{s && ( {s && (
<div className="grid gap-3 sm:grid-cols-2"> <div className="space-y-4">
<Bar label="CPU" percent={s.cpu.percent} detail={s.cpu.cores ? `${s.cpu.cores} Kerne` : undefined} /> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Bar <DiagnosticBar label="CPU" percent={s.cpu.percent} detail={s.cpu.cores ? `${s.cpu.cores} Cores` : undefined} icon={Cpu} />
label="RAM" <DiagnosticBar label="RAM" percent={s.ram.percent} detail={`${gb(s.ram.used)} / ${gb(s.ram.total)} GB`} icon={Activity} />
percent={s.ram.percent}
detail={`${gb(s.ram.used)} / ${gb(s.ram.total)} GB`} {s.gpu && s.gpu.busy_percent != null && (
/> <DiagnosticBar
{s.gpu && s.gpu.busy_percent != null && ( label="GPU"
<Bar percent={s.gpu.busy_percent}
label="GPU" detail={
percent={s.gpu.busy_percent} s.gpu.gtt_used != null && s.gpu.gtt_total
detail={ ? `${gb(s.gpu.gtt_used)} / ${gb(s.gpu.gtt_total)} GB (GTT/unified)`
s.gpu.gtt_used != null && s.gpu.gtt_total : s.gpu.vram_used != null && s.gpu.vram_total
? `${gb(s.gpu.gtt_used)} / ${gb(s.gpu.gtt_total)} GB (GTT/unified)` ? `${gb(s.gpu.vram_used)} / ${gb(s.gpu.vram_total)} GB VRAM`
: s.gpu.vram_used != null && s.gpu.vram_total : undefined
? `${gb(s.gpu.vram_used)} / ${gb(s.gpu.vram_total)} GB VRAM` }
: undefined icon={GpuIcon}
} />
/> )}
)}
{s.disk && ( {s.disk && (
<Bar label="Disk (Modelle)" percent={s.disk.percent} detail={`${gb(s.disk.used)} / ${gb(s.disk.total)} GB`} /> <DiagnosticBar label="Disk" percent={s.disk.percent} detail={`${gb(s.disk.used)} / ${gb(s.disk.total)} GB`} icon={HardDrive} />
)}
</div>
{/* Temperatures display */}
{s.temp && (s.temp.cpu || s.temp.gpu) && (
<div className="flex gap-3 text-[10px] font-mono text-muted-foreground bg-background/25 border border-border/40 p-2.5 rounded-xl self-start w-fit">
{s.temp.cpu != null && (
<span className="flex items-center gap-1">
CPU-Temperatur: <span className="text-foreground font-bold">{s.temp.cpu} °C</span>
</span>
)}
{s.temp.cpu != null && s.temp.gpu != null && <span>|</span>}
{s.temp.gpu != null && (
<span className="flex items-center gap-1">
GPU-Temperatur: <span className="text-foreground font-bold">{s.temp.gpu} °C</span>
</span>
)}
</div>
)} )}
</div> </div>
)} )}
{s?.temp && (s.temp.cpu || s.temp.gpu) && ( {/* Services Health matrix */}
<div className="flex gap-3 text-sm text-muted-foreground">
{s.temp.cpu != null && <span>CPU {s.temp.cpu} °C</span>}
{s.temp.gpu != null && <span>GPU {s.temp.gpu} °C</span>}
</div>
)}
{/* Dienste-Health + Observability-Links */}
{svc && ( {svc && (
<div className="rounded-xl border border-border bg-card p-4"> <div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-4">
<div className="mb-3 text-sm font-medium">Dienste</div> <div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Homelab-Dienste</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="grid gap-3 sm:grid-cols-2">
{svc.services.map((x) => ( {svc.services.map((x) => (
<div key={x.name} className="flex items-center gap-2 text-sm"> <div
<span className={cn("h-2 w-2 rounded-full", x.ok ? "bg-emerald-500" : "bg-amber-500")} /> key={x.name}
<span>{x.name}</span> className="flex items-center justify-between p-3.5 rounded-xl bg-background/20 border border-border/30 hover:border-primary/20 transition-all group"
<span className="ml-auto font-mono text-xs text-muted-foreground">{x.url}</span> >
<div className="flex items-center gap-2.5 min-w-0">
<span className={cn(
"h-2.5 w-2.5 rounded-full ring-2 ring-black/40",
x.ok ? "bg-emerald-500" : "bg-amber-500"
)} />
<div className="truncate">
<div className="text-xs font-bold text-foreground truncate">{x.name}</div>
<div className="text-[9px] text-muted-foreground/60 font-mono mt-0.5 truncate">{x.url}</div>
</div>
</div>
<button
onClick={() => restartService(x.name)}
disabled={restartingServices[x.name]}
className="h-7 w-7 rounded-lg border border-border/40 text-muted-foreground hover:text-primary hover:bg-primary/5 flex items-center justify-center transition-all opacity-0 group-hover:opacity-100"
title="Dienst neu starten"
>
<RefreshCw className={cn("h-3.5 w-3.5", restartingServices[x.name] && "animate-spin")} />
</button>
</div> </div>
))} ))}
</div> </div>
<div className="mt-3 flex flex-wrap gap-3 text-xs">
<a href={svc.links.engine_ui} target="_blank" rel="noopener" className="flex items-center gap-1 text-primary hover:underline"> {/* Service Links */}
<ExternalLink className="h-3 w-3" /> Engine-Logs (llama-swap /ui) <div className="flex flex-wrap gap-4 border-t border-border/20 pt-4 text-[10px] font-semibold text-muted-foreground">
<a
href={svc.links.engine_ui}
target="_blank"
rel="noopener"
className="flex items-center gap-1 text-primary hover:text-primary-foreground hover:bg-primary/10 px-2 py-1 rounded transition-colors"
>
<ExternalLink className="h-3 w-3" /> Engine Dashboard (llama-swap)
</a> </a>
<a href={svc.links.gateway} target="_blank" rel="noopener" className="flex items-center gap-1 text-primary hover:underline"> <a
<ExternalLink className="h-3 w-3" /> Gateway href={svc.links.gateway}
target="_blank"
rel="noopener"
className="flex items-center gap-1 text-primary hover:text-primary-foreground hover:bg-primary/10 px-2 py-1 rounded transition-colors"
>
<ExternalLink className="h-3 w-3" /> OpenAI Gateway
</a> </a>
</div> </div>
</div> </div>
)} )}
{/* Backup */} {/* Backup snapshot panel */}
<div className="flex items-center gap-3"> <div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<button onClick={doBackup} className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm hover:bg-accent"> <div className="space-y-1">
<Save className="h-3.5 w-3.5 text-primary" /> Backup jetzt <h3 className="text-xs font-bold uppercase tracking-wider text-foreground">System-Backup &amp; Snapshot</h3>
</button> <p className="text-[10px] text-muted-foreground">Erzeuge einen Git-Snapshot der aktuellen Konfigurationen und des System-Zustands.</p>
{backupMsg && <span className="text-xs text-muted-foreground">{backupMsg}</span>} </div>
<div className="flex items-center gap-3 self-start sm:self-auto shrink-0">
<button
onClick={doBackup}
className="h-9 px-4 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all flex items-center gap-1.5 cursor-pointer shadow-md shadow-primary/10"
>
<Save className="h-4 w-4" /> Snapshot erstellen
</button>
</div>
</div> </div>
<Maintenance /> {backupMsg && (
<div className="text-[10px] font-mono text-primary font-medium bg-primary/5 p-3 rounded-xl border border-primary/20">
{backupMsg}
</div>
)}
{/* Maintenance controls inside system view */}
<MaintenanceSection />
</div> </div>
) )
} }