feat: complete UI/UX Rework into Sleek Glassmorphic AI OS (June 2026)
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
-190
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+335
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 },
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
SSH→Windows, 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 & 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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -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 & 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 & 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 & 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 & 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">
|
||||||
auto→heavy 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">
|
||||||
|
> {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 < {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 >= {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
@@ -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 & 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 & 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 & Updates.</p>
|
System-Diagnose & 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 & 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user