Frontend-Erneuerung: Mobile, Diät, Tempo, UI-Politur (10-Punkte-Paket)

- Fix: 'Interaktives Terminal' im Cockpit führte ins Leere (#terminal) → Konsole
- Mobile: Off-Canvas-Sidebar mit Hamburger unter md, Inhalt volle Breite
  (vorher: 240px starre Sidebar, 135px Rest mit Quer-Scroll auf dem Handy)
- Dead Weight raus: src/mc3/ (verworfener Prototyp), DashboardView (alte
  Zentrale) + 6 nur dort genutzte Karten, 8 ungenutzte npm-Pakete
  (three, three-vrm, react-three fiber/drei, sigma, graphology ×2, @types/three)
- Code-Splitting: alle Views außer Cockpit per React.lazy → Haupt-Bundle
  983 → 739 KB, jede View ein eigenes Chunk
- Fonts lokal (@fontsource) statt Google-CDN → UI offline identisch
- Polling harmonisiert: EINE Takt-Tabelle in queries.ts (Graphen 3s, Rest
  8-60s), keine Override-Intervalle mehr in Komponenten; Updates-Check
  (apt/git!) von 4s auf 60s
- CSS-Hack-Layer aufgelöst: !important-Klassen-Sniffing → explizite
  .mc-card/.mc-card-sm/.nav-active-Klassen (Look identisch, ungeschichtete
  Regeln statt Utility-Raten); totes .light-Theme raus; Scrollbars 1× definiert
- Aurora: animierte Fullscreen-Blur-Filter → statische radial-gradients
  (gleicher Look, keine Dauer-GPU-Last)
- Sicherheit: Sudo-Passwort/HF-Token nur noch bei mutierenden Requests,
  nicht mehr auf jedem GET-Poll
- Strg+K statt ⌘K auf Windows; dist neu gebaut

Live gegen die Box verifiziert: alle 10 Views + Mobile-Flow, 0 Konsolen-Fehler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-13 12:26:27 +02:00
parent f88f8e8bf9
commit 94444f4dcb
174 changed files with 1265 additions and 3229 deletions
+252 -163
View File
@@ -1,24 +1,39 @@
import { useEffect, useState, useCallback } from "react"
import { Command as CommandIcon, ChevronLeft, ChevronRight } from "lucide-react"
import { useEffect, useState, useCallback, lazy, Suspense } from "react"
import { Command as CommandIcon, ChevronLeft, ChevronRight, Loader2, Menu, X } from "lucide-react"
import { NAV, type ViewId } from "@/nav"
import { CommandPalette } from "@/components/CommandPalette"
import { ExpertToggle } from "@/components/ExpertToggle"
import { ModelsView } from "@/views/ModelsView"
import { ConnectView } from "@/views/ConnectView"
import { MemoryView } from "@/views/MemoryView"
import { AgentView } from "@/views/AgentView"
import { KonsoleView } from "@/views/KonsoleView"
import { GuideView } from "@/views/GuideView"
import { Placeholder } from "@/views/Placeholder"
import { SystemDrawer } from "@/components/SystemDrawer"
import { useHealth, useSystemStatus } from "@/lib/queries"
import type { Health, SystemStatus } from "@/lib/api"
import { useMetricsFeeder } from "@/lib/metricsStore"
import { cn } from "@/lib/utils"
import { Mc3App } from "@/mc3/Mc3App"
import { CockpitView } from "@/views/cockpit/CockpitView"
import { AuftragsbuchView } from "@/views/AuftragsbuchView"
import { ChronikView } from "@/views/ChronikView"
import { WissenView } from "@/views/WissenView"
// Code-Splitting: nur das Cockpit (Startseite) lädt sofort, jede weitere View
// kommt als eigenes Chunk erst beim ersten Aufruf — kleineres Start-Bundle.
const ModelsView = lazy(() => import("@/views/ModelsView").then((m) => ({ default: m.ModelsView })))
const ConnectView = lazy(() => import("@/views/ConnectView").then((m) => ({ default: m.ConnectView })))
const MemoryView = lazy(() => import("@/views/MemoryView").then((m) => ({ default: m.MemoryView })))
const AgentView = lazy(() => import("@/views/AgentView").then((m) => ({ default: m.AgentView })))
const KonsoleView = lazy(() => import("@/views/KonsoleView").then((m) => ({ default: m.KonsoleView })))
const GuideView = lazy(() => import("@/views/GuideView").then((m) => ({ default: m.GuideView })))
const AuftragsbuchView = lazy(() => import("@/views/AuftragsbuchView").then((m) => ({ default: m.AuftragsbuchView })))
const ChronikView = lazy(() => import("@/views/ChronikView").then((m) => ({ default: m.ChronikView })))
const WissenView = lazy(() => import("@/views/WissenView").then((m) => ({ default: m.WissenView })))
// Dezenter Lade-Zustand während eine View nachgeladen wird (lokales Netz: kaum sichtbar).
function ViewLoading() {
return (
<div className="flex h-64 items-center justify-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" aria-label="Lädt …" />
</div>
)
}
// Tastatur-Hinweis passend zum Betriebssystem (Palette hört auf Ctrl UND Cmd).
const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform)
export default function App() {
useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab
@@ -32,13 +47,12 @@ export default function App() {
else window.location.hash = v
}, [])
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true")
const [mobileNavOpen, setMobileNavOpen] = useState(false)
const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance")
// MC3-Prototyp läuft nicht-destruktiv unter #mc3 (produktives MC2 bleibt Default).
const [isMc3, setIsMc3] = useState(() => window.location.hash === "#mc3")
const { data: health } = useHealth()
const { data: sysStatus } = useSystemStatus(20_000)
const { data: sysStatus } = useSystemStatus()
useEffect(() => {
document.documentElement.classList.add("dark")
@@ -65,7 +79,6 @@ export default function App() {
useEffect(() => {
const onHash = () => {
setIsMc3(window.location.hash === "#mc3")
const h = window.location.hash.slice(1) as ViewId
if (NAV.some((n) => n.id === h)) setView(h)
}
@@ -73,158 +86,93 @@ export default function App() {
return () => window.removeEventListener("hashchange", onHash)
}, [])
const active = NAV.find((n) => n.id === view)!
// Mobile-Navigation schließt nach jedem View-Wechsel (ein Tipp = ein Ziel).
useEffect(() => { setMobileNavOpen(false) }, [view])
// Nicht-destruktiver MC3-Prototyp: alle Hooks liefen oben, hier nur die Weiche.
if (isMc3) return <Mc3App />
const active = NAV.find((n) => n.id === view)!
return (
<div className="flex h-full relative">
{/* Background Aurora Ambient Effects */}
<div className="fixed inset-0 -z-50 pointer-events-none overflow-hidden bg-[hsl(224,30%,6%)]">
<div className="absolute inset-0 opacity-35 animate-aurora bg-gradient-to-tr from-teal-500/20 via-indigo-500/15 to-purple-500/20 blur-[130px]" />
<div className="absolute top-[-10%] left-[-10%] h-[50%] w-[50%] rounded-full bg-teal-500/15 blur-[160px]" />
<div className="absolute bottom-[-10%] right-[-10%] h-[50%] w-[50%] rounded-full bg-purple-500/15 blur-[160px]" />
<div className="absolute top-[30%] right-[20%] h-[40%] w-[40%] rounded-full bg-indigo-500/10 blur-[140px]" />
</div>
{/* Ruhiger Ambient-Hintergrund: statische radial-gradients statt animierter
Fullscreen-Blur-Filter — gleicher Look, praktisch keine GPU-Last. */}
<div
className="fixed inset-0 -z-50 pointer-events-none"
style={{
backgroundColor: "hsl(224,30%,6%)",
backgroundImage: [
"radial-gradient(45% 40% at 12% 8%, hsl(172 72% 50% / 0.10), transparent 70%)",
"radial-gradient(50% 45% at 88% 92%, hsl(270 70% 60% / 0.10), transparent 70%)",
"radial-gradient(40% 35% at 75% 30%, hsl(239 70% 62% / 0.07), transparent 70%)",
].join(", "),
}}
/>
<CommandPalette onNavigate={navigate} />
<SystemDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} defaultTab={drawerTab} />
{/* Sidebar */}
{/* Sidebar (Desktop, ab md) */}
<aside className={cn(
"flex shrink-0 flex-col border-r border-border/40 bg-card/45 backdrop-blur-md transition-all duration-300 ease-in-out",
"hidden md:flex shrink-0 flex-col border-r border-border/40 bg-card/45 backdrop-blur-md transition-all duration-300 ease-in-out",
sidebarCollapsed ? "w-16" : "w-60"
)}>
<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="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>
<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"
aria-label={sidebarCollapsed ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
title={sidebarCollapsed ? "Maximieren" : "Minimieren"}
>
{sidebarCollapsed ? <ChevronRight className="h-4 w-4" aria-hidden="true" /> : <ChevronLeft className="h-4 w-4" aria-hidden="true" />}
</button>
</div>
<nav className="flex-1 space-y-1 px-3 py-4 overflow-y-auto">
{(() => {
let lastGroup: string | null = null
return NAV.map((item) => {
const showGroupHeader = item.group !== lastGroup
lastGroup = item.group
return (
<div key={item.id} className="space-y-1">
{showGroupHeader && (
sidebarCollapsed ? (
lastGroup !== NAV[0].group && <div className="my-3 border-t border-border/30" />
) : (
<div className="mt-5 mb-1.5 px-3 text-[10px] font-bold uppercase tracking-widest text-muted-foreground/40 first:mt-0 select-none">
{item.group}
</div>
)
)}
<a
href={`#${item.id}`}
aria-current={view === item.id ? "page" : undefined}
className={cn(
"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
? "bg-primary/15 text-primary shadow-sm shadow-primary/5"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
title={sidebarCollapsed ? item.label : undefined}
aria-label={sidebarCollapsed ? item.label : undefined}
>
<item.icon className="h-4.5 w-4.5 shrink-0" aria-hidden="true" />
{!sidebarCollapsed && <span className="truncate">{item.label}</span>}
</a>
</div>
)
<SidebarContent
collapsed={sidebarCollapsed}
onToggleCollapse={() => {
setSidebarCollapsed((c) => {
const next = !c
localStorage.setItem("mc_sidebar_collapsed", next.toString())
return next
})
})()}
</nav>
<div className={cn("py-3 text-xs text-muted-foreground border-t border-border/40 shrink-0", sidebarCollapsed ? "px-2 text-center" : "px-5")}>
{sidebarCollapsed ? (
<div className="flex justify-center">
<span className={cn(
"h-2.5 w-2.5 rounded-full ring-2 ring-black/40",
health
? (!health.engine_reachable ? "bg-amber-500"
: (health.brain && !health.brain.ready ? "bg-amber-500 animate-pulse" : "bg-emerald-500 animate-pulse"))
: "bg-red-500"
)} title={health
? (!health.engine_reachable ? "Engine offline"
: (health.brain && !health.brain.ready ? `Hirn offline (${health.brain.model ?? "fast"})` : "Engine + Hirn online"))
: "Backend offline"} />
</div>
) : (
<div className="space-y-2 text-left">
{health ? (
<>
<span className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full animate-pulse", health.engine_reachable ? "bg-emerald-500" : "bg-amber-500")} />
<span className="truncate">Engine {health.engine_reachable ? "online" : "offline"}</span>
</span>
{health.brain && !health.brain.ready && (
<span className="flex items-center gap-2 text-amber-400" title={`Agent-Hirn '${health.brain.model ?? "fast"}' lädt nicht/abgestürzt`}>
<span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
<span className="truncate">Hirn offline{health.brain.model ? ` (${health.brain.model})` : ""}</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>
)}
{sysStatus?.versions && (
<div className="space-y-1 text-[9px] text-muted-foreground/60 mt-2 pt-2 border-t border-border/30">
<div className="truncate" title={sysStatus.versions.mc2 ? `${sysStatus.versions.mc2.branch}-${sysStatus.versions.mc2.hash}${sysStatus.versions.mc2.dirty ? "*" : ""} (${sysStatus.versions.mc2.date})` : "nicht gefunden"}>
<strong>MC2:</strong> {sysStatus.versions.mc2 ? `${sysStatus.versions.mc2.hash}${sysStatus.versions.mc2.dirty ? "*" : ""}` : "—"}
</div>
<div className="truncate" title={sysStatus.versions.engine?.type === "git" ? `${sysStatus.versions.engine.branch}-${sysStatus.versions.engine.hash}${sysStatus.versions.engine.dirty ? "*" : ""} (${sysStatus.versions.engine.date})` : sysStatus.versions.engine?.version_text || "unbekannt"}>
<strong>Engine:</strong> {sysStatus.versions.engine?.type === "git" ? `${sysStatus.versions.engine.hash}${sysStatus.versions.engine.dirty ? "*" : ""}` : (sysStatus.versions.engine?.version_text?.split(" ").pop() || "—")}
</div>
<div className="truncate" title={sysStatus.versions.hermes_agent ? `${sysStatus.versions.hermes_agent.branch}-${sysStatus.versions.hermes_agent.hash}${sysStatus.versions.hermes_agent.dirty ? "*" : ""} (${sysStatus.versions.hermes_agent.date})` : "nicht gefunden"}>
<strong>Hermes Agent:</strong> {sysStatus.versions.hermes_agent ? `${sysStatus.versions.hermes_agent.hash}${sysStatus.versions.hermes_agent.dirty ? "*" : ""}` : "—"}
</div>
</div>
)}
</div>
)}
</div>
}}
view={view}
health={health}
sysStatus={sysStatus}
/>
</aside>
{/* Sidebar (Mobile, Off-Canvas mit Backdrop) */}
<div
className={cn(
"md:hidden fixed inset-0 z-50 transition-opacity duration-200",
mobileNavOpen ? "opacity-100" : "pointer-events-none opacity-0"
)}
aria-hidden={!mobileNavOpen}
>
<div className="absolute inset-0 bg-black/60" onClick={() => setMobileNavOpen(false)} />
<aside className={cn(
"absolute inset-y-0 left-0 flex w-72 max-w-[85vw] flex-col border-r border-border/40 bg-[hsl(224,28%,8%)] transition-transform duration-300 ease-in-out",
mobileNavOpen ? "translate-x-0" : "-translate-x-full"
)}>
<SidebarContent
collapsed={false}
onClose={() => setMobileNavOpen(false)}
view={view}
health={health}
sysStatus={sysStatus}
/>
</aside>
</div>
{/* Main */}
<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/40 px-6 bg-card/20 backdrop-blur-sm">
<div className="text-xs font-medium text-muted-foreground tracking-wide uppercase font-sans">{active.hint}</div>
<div className="flex items-center gap-2">
<header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-border/40 px-4 md:px-6 bg-card/20 backdrop-blur-sm">
<div className="flex min-w-0 items-center gap-3">
<button
onClick={() => setMobileNavOpen(true)}
className="md:hidden p-1.5 -ml-1 rounded-md hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
aria-label="Navigation öffnen"
>
<Menu className="h-5 w-5" aria-hidden="true" />
</button>
<div className="truncate text-xs font-medium text-muted-foreground tracking-wide uppercase font-sans">{active.hint}</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<ExpertToggle />
<a
href="https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2/src/branch/main/docs/BEDIENUNG.md"
target="_blank"
rel="noopener"
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"
className="hidden sm: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"
>
Hilfe
@@ -238,29 +186,170 @@ export default function App() {
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>
<span className="hidden sm:inline">Suchen</span>
<kbd className="hidden sm:inline rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">{IS_MAC ? "⌘K" : "Strg+K"}</kbd>
</button>
</div>
</header>
<main className="flex-1 overflow-y-auto p-6 scrollbar-thin">
{view === "dashboard" && <CockpitView onNavigate={(v) => navigate(v as ViewId)} />}
{view === "auftraege" && <AuftragsbuchView />}
{view === "models" && <ModelsView />}
{view === "connect" && <ConnectView />}
{view === "memory" && <MemoryView />}
{view === "wissen" && <WissenView />}
{view === "chronik" && <ChronikView />}
{view === "agent" && <AgentView />}
{view === "konsole" && <KonsoleView />}
{view === "guide" && <GuideView />}
{!["dashboard", "auftraege", "models", "connect", "memory", "wissen", "chronik", "agent", "konsole", "guide"].includes(view) && (
<Placeholder title={active.label} hint={active.hint} />
)}
<main className="flex-1 overflow-y-auto p-4 md:p-6 scrollbar-thin">
<Suspense fallback={<ViewLoading />}>
{view === "dashboard" && <CockpitView onNavigate={(v) => navigate(v as ViewId)} />}
{view === "auftraege" && <AuftragsbuchView />}
{view === "models" && <ModelsView />}
{view === "connect" && <ConnectView />}
{view === "memory" && <MemoryView />}
{view === "wissen" && <WissenView />}
{view === "chronik" && <ChronikView />}
{view === "agent" && <AgentView />}
{view === "konsole" && <KonsoleView />}
{view === "guide" && <GuideView />}
{!["dashboard", "auftraege", "models", "connect", "memory", "wissen", "chronik", "agent", "konsole", "guide"].includes(view) && (
<Placeholder title={active.label} hint={active.hint} />
)}
</Suspense>
</main>
</div>
</div>
)
}
// Sidebar-Inhalt (Logo, Navigation, Status-Fuß) — geteilt zwischen Desktop-Leiste
// und Mobile-Off-Canvas, damit beide immer identisch bleiben.
function SidebarContent({
collapsed, onToggleCollapse, onClose, view, health, sysStatus,
}: {
collapsed: boolean
onToggleCollapse?: () => void
onClose?: () => void
view: ViewId
health: Health | undefined
sysStatus: SystemStatus | undefined
}) {
return (
<>
<div className={cn("flex items-center py-4 border-b border-border/40 shrink-0", collapsed ? "flex-col gap-3 px-2" : "justify-between px-5")}>
<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" />
{!collapsed && (
<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>
{onToggleCollapse && (
<button
onClick={onToggleCollapse}
className="p-1 rounded-md hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
aria-label={collapsed ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
title={collapsed ? "Maximieren" : "Minimieren"}
>
{collapsed ? <ChevronRight className="h-4 w-4" aria-hidden="true" /> : <ChevronLeft className="h-4 w-4" aria-hidden="true" />}
</button>
)}
{onClose && (
<button
onClick={onClose}
className="p-1 rounded-md hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
aria-label="Navigation schließen"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
<nav className="flex-1 space-y-1 px-3 py-4 overflow-y-auto">
{(() => {
let lastGroup: string | null = null
return NAV.map((item) => {
const showGroupHeader = item.group !== lastGroup
lastGroup = item.group
return (
<div key={item.id} className="space-y-1">
{showGroupHeader && (
collapsed ? (
lastGroup !== NAV[0].group && <div className="my-3 border-t border-border/30" />
) : (
<div className="mt-5 mb-1.5 px-3 text-[10px] font-bold uppercase tracking-widest text-muted-foreground/40 first:mt-0 select-none">
{item.group}
</div>
)
)}
<a
href={`#${item.id}`}
onClick={onClose}
aria-current={view === item.id ? "page" : undefined}
className={cn(
"flex w-full items-center rounded-md text-sm transition-all cursor-pointer",
collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2",
view === item.id
? (collapsed ? "nav-active-collapsed" : "nav-active")
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
title={collapsed ? item.label : undefined}
aria-label={collapsed ? item.label : undefined}
>
<item.icon className="h-4.5 w-4.5 shrink-0" aria-hidden="true" />
{!collapsed && <span className="truncate">{item.label}</span>}
</a>
</div>
)
})
})()}
</nav>
<div className={cn("py-3 text-xs text-muted-foreground border-t border-border/40 shrink-0", collapsed ? "px-2 text-center" : "px-5")}>
{collapsed ? (
<div className="flex justify-center">
<span className={cn(
"h-2.5 w-2.5 rounded-full ring-2 ring-black/40",
health
? (!health.engine_reachable ? "bg-amber-500"
: (health.brain && !health.brain.ready ? "bg-amber-500 animate-pulse" : "bg-emerald-500 animate-pulse"))
: "bg-red-500"
)} title={health
? (!health.engine_reachable ? "Engine offline"
: (health.brain && !health.brain.ready ? `Hirn offline (${health.brain.model ?? "fast"})` : "Engine + Hirn online"))
: "Backend offline"} />
</div>
) : (
<div className="space-y-2 text-left">
{health ? (
<>
<span className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full animate-pulse", health.engine_reachable ? "bg-emerald-500" : "bg-amber-500")} />
<span className="truncate">Engine {health.engine_reachable ? "online" : "offline"}</span>
</span>
{health.brain && !health.brain.ready && (
<span className="flex items-center gap-2 text-amber-400" title={`Agent-Hirn '${health.brain.model ?? "fast"}' lädt nicht/abgestürzt`}>
<span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
<span className="truncate">Hirn offline{health.brain.model ? ` (${health.brain.model})` : ""}</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>
)}
{sysStatus?.versions && (
<div className="space-y-1 text-[9px] text-muted-foreground/60 mt-2 pt-2 border-t border-border/30">
<div className="truncate" title={sysStatus.versions.mc2 ? `${sysStatus.versions.mc2.branch}-${sysStatus.versions.mc2.hash}${sysStatus.versions.mc2.dirty ? "*" : ""} (${sysStatus.versions.mc2.date})` : "nicht gefunden"}>
<strong>MC2:</strong> {sysStatus.versions.mc2 ? `${sysStatus.versions.mc2.hash}${sysStatus.versions.mc2.dirty ? "*" : ""}` : "—"}
</div>
<div className="truncate" title={sysStatus.versions.engine?.type === "git" ? `${sysStatus.versions.engine.branch}-${sysStatus.versions.engine.hash}${sysStatus.versions.engine.dirty ? "*" : ""} (${sysStatus.versions.engine.date})` : sysStatus.versions.engine?.version_text || "unbekannt"}>
<strong>Engine:</strong> {sysStatus.versions.engine?.type === "git" ? `${sysStatus.versions.engine.hash}${sysStatus.versions.engine.dirty ? "*" : ""}` : (sysStatus.versions.engine?.version_text?.split(" ").pop() || "—")}
</div>
<div className="truncate" title={sysStatus.versions.hermes_agent ? `${sysStatus.versions.hermes_agent.branch}-${sysStatus.versions.hermes_agent.hash}${sysStatus.versions.hermes_agent.dirty ? "*" : ""} (${sysStatus.versions.hermes_agent.date})` : "nicht gefunden"}>
<strong>Hermes Agent:</strong> {sysStatus.versions.hermes_agent ? `${sysStatus.versions.hermes_agent.hash}${sysStatus.versions.hermes_agent.dirty ? "*" : ""}` : "—"}
</div>
</div>
)}
</div>
)}
</div>
</>
)
}
+1 -1
View File
@@ -2,7 +2,7 @@ import React from "react"
// Globale Error Boundary (Review F4): Ein JS-Fehler in einer View riss vorher die komplette
// App in einen weißen Screen. Hier: Fehler anzeigen + Neu-laden-Knopf, Rest bleibt bedienbar
// nach Reload. (GraphView hat zusätzlich seine eigene Boundary für Three.js-Crashes.)
// nach Reload. (GraphView hat zusätzlich seine eigene Boundary für Canvas-Crashes.)
interface State { error: Error | null }
export class AppErrorBoundary extends React.Component<React.PropsWithChildren, State> {
@@ -1,172 +0,0 @@
import { useState } from "react"
import { Bot, ExternalLink, Cpu, Layers, Wrench, X, Check } from "lucide-react"
import { api } from "@/lib/api"
import { useAgentStatus, useModels, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn, resolveExternalUrl } from "@/lib/utils"
export function AgentStatusCard() {
const qc = useQueryClient()
const { data: agent } = useAgentStatus(3_000)
const { data: modelsData } = useModels()
const { showAlert, dialogElement } = useDialog()
const [showBrainSelect, setShowBrainSelect] = useState(false)
const models = modelsData?.models ?? []
async function changeBrainModel(model: string) {
try {
await api("/api/agent/brain", { method: "POST", body: JSON.stringify({ model }) })
showAlert("Erfolgreich", `Hermes-Gehirn wurde auf '${model}' geändert. Der Gateway-Dienst wurde neu gestartet.`)
qc.invalidateQueries({ queryKey: qk.agentStatus })
setShowBrainSelect(false)
} catch (e: any) {
showAlert("Fehler", `Fehler beim Wechseln des Gehirns: ${e.message}`)
}
}
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Bot className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Hermes Agent</h2>
</div>
{agent?.hermes_ui_url && (
<a
href={resolveExternalUrl(agent.hermes_ui_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.hermes_ui_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" /> GUI öffnen
</a>
)}
</div>
{agent ? (
<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">Desktop-Gateway</div>
<div className="flex items-center gap-2 mt-1">
<span className={cn("h-2 w-2 rounded-full", agent.hermes_ui_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
<span className="text-xs font-medium">{agent.hermes_ui_reachable ? "Online" : "Offline"}</span>
</div>
</div>
<div
onClick={() => setShowBrainSelect(true)}
className="p-3 bg-background/20 rounded-xl border border-border/40 hover:border-primary/45 hover:bg-background/30 transition-all duration-300 cursor-pointer"
>
<div className="flex justify-between items-center">
<span className="text-[10px] text-muted-foreground uppercase font-semibold">Gehirn</span>
<Cpu className="h-3 w-3 text-primary" />
</div>
<div className="text-xs font-medium mt-1 font-mono text-primary truncate flex items-center gap-1">
<Layers className="h-3 w-3 shrink-0" />
{agent.brain_model || "auto"}
</div>
</div>
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
<div className="flex justify-between items-center">
<span className="text-[10px] text-muted-foreground uppercase font-semibold">Verdrahtung</span>
<Wrench className="h-3 w-3 text-primary" />
</div>
<div className="text-[10px] font-mono mt-1 space-y-0.5 text-muted-foreground">
<div>Config: {agent.has_config ? <span className="text-emerald-400"></span> : "—"}</div>
<div>Skills: {agent.has_skills ? <span className="text-emerald-400"></span> : "—"}</div>
<div>Memory: {agent.has_memories ? <span className="text-emerald-400"></span> : "—"}</div>
</div>
</div>
</div>
) : (
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">Lade Agenten-Status</div>
)}
</div>
{agent && (
<div className="mt-3 border-t border-border/30 pt-3 space-y-1.5">
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>Telegram</span>
<span className={cn("font-semibold", agent.telegram_enabled ? "text-emerald-400" : "")}>
{agent.telegram_enabled ? "aktiv" : "nicht konfiguriert"}
</span>
</div>
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>MCP-Server</span>
<span className="font-semibold text-foreground">{agent.mcp_server_count ?? 0} verbunden</span>
</div>
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>PC Executor</span>
<span className={cn("font-semibold", agent.pc_executor_reachable ? "text-emerald-400" : "")}>
{agent.pc_executor_reachable ? "erreichbar" : "nicht verbunden"}
</span>
</div>
</div>
)}
{agent && showBrainSelect && (
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-primary flex items-center gap-1.5">
<Cpu className="h-4 w-4" />
<span>Hermes-Gehirn konfigurieren</span>
</h3>
<button
onClick={() => setShowBrainSelect(false)}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
Das Gehirn" ist das primäre LLM für Hermes. Wähle ein Modell aus deiner Bibliothek oder nutze ein Gateway-Alias (<code className="text-primary font-semibold">auto</code> / <code className="text-primary font-semibold">fast</code> / <code className="text-primary font-semibold">heavy</code>):
</p>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
{(["auto", "fast", "heavy", ...models.map(m => m.name.split("/").pop()?.replace(".gguf", "") || m.name)]).map((m) => {
const isAlias = ["auto", "fast", "heavy"].includes(m)
return (
<button
key={m}
onClick={() => changeBrainModel(m)}
className={cn(
"w-full text-left px-3 py-2.5 rounded-lg text-xs hover:bg-accent flex items-center justify-between font-mono cursor-pointer border border-border/30",
agent.brain_model === m || (!agent.brain_model && m === "auto")
? "text-primary font-bold bg-primary/10 border-primary/30"
: "text-foreground bg-background/20"
)}
>
<div className="flex flex-col text-left">
<span className="font-semibold truncate max-w-[280px]">{m}</span>
<span className="text-[9px] text-muted-foreground mt-0.5">
{isAlias ? "Gateway Routing Alias" : "Installiertes GGUF Modell"}
</span>
</div>
{(agent.brain_model === m || (!agent.brain_model && m === "auto")) && (
<Check className="h-4 w-4 shrink-0 text-primary" />
)}
</button>
)
})}
</div>
</div>
</div>
)}
{dialogElement}
</div>
)
}
@@ -78,7 +78,7 @@ export function LatencyCard() {
const lead = latest ? culprit(latest) : null
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15">
<div className="mc-card p-5">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
@@ -1,165 +0,0 @@
import { useState } from "react"
import { HeartPulse, HardDrive, Wrench, Check, AlertTriangle, Loader2, ShieldQuestion } from "lucide-react"
import { api } from "@/lib/api"
import { useDialog } from "@/lib/useDialog"
import { useQueryClient, qk } from "@/lib/queries"
import { useLucyHealth, type LucyCheck, type Repair } from "@/lib/useLucyHealth"
import { cn } from "@/lib/utils"
// Farb-/Text-Ton pro Check-Status.
const DOT: Record<string, string> = {
ok: "bg-emerald-500",
warn: "bg-amber-500",
down: "bg-red-500",
loading: "bg-muted-foreground/40",
}
// `frame` = Erzählrahmen: "lucy" (MC2-Dashboard) oder "box" (MC3-Basisstation).
export function LucyHealthCard({ frame = "lucy" }: { frame?: "lucy" | "box" } = {}) {
const { verdict, checks, memory, problems } = useLucyHealth()
const { showAlert, dialogElement } = useDialog()
const qc = useQueryClient()
const [busy, setBusy] = useState<Record<string, boolean>>({})
async function runRepair(id: string, repair: Repair) {
setBusy((b) => ({ ...b, [id]: true }))
try {
if (repair.kind === "loadModel") {
await api(`/api/models/${encodeURIComponent(repair.model)}/load`, { method: "POST" })
} else {
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
method: "POST",
body: JSON.stringify({ service: repair.service }),
})
if (!r.ok) {
showAlert(
"Reparatur nicht ganz geklappt",
(r.err || "Unbekannter Fehler") +
(repair.needsSudo ? "\n\nTipp: Dafür wird evtl. das Box-Passwort gebraucht (oben rechts hinterlegen)." : ""),
)
}
}
// Status-Queries neu ziehen, damit die Ampel sich sofort aktualisiert.
qc.invalidateQueries({ queryKey: qk.health })
qc.invalidateQueries({ queryKey: qk.services })
qc.invalidateQueries({ queryKey: qk.models })
} catch (e: any) {
showAlert("Reparatur fehlgeschlagen", String(e?.message || e))
} finally {
setBusy((b) => ({ ...b, [id]: false }))
}
}
// Titel je Erzählrahmen (Box-Kontrollpanel vs. Lucy-Dashboard).
const T = frame === "box"
? { loading: "Box wird geprüft …", gut: "Alles okay", warn: "Kleinigkeit an der Box", problem: "Box braucht Hilfe" }
: { loading: "Lucy wird geprüft …", gut: "Lucy geht's gut 💚", warn: "Kleinigkeit bei Lucy", problem: "Lucy braucht Hilfe" }
// Banner-Optik je Gesamt-Verdikt.
const banner = {
loading: { ring: "border-border/60 bg-card/45", icon: Loader2, iconCls: "text-muted-foreground animate-spin", title: T.loading, sub: "Einen Moment, ich schaue nach dem Rechten." },
gut: { ring: "border-emerald-500/40 bg-emerald-500/5", icon: HeartPulse, iconCls: "text-emerald-400", title: T.gut, sub: "Alle wichtigen Fähigkeiten laufen." },
warn: { ring: "border-amber-500/40 bg-amber-500/5", icon: AlertTriangle, iconCls: "text-amber-400", title: T.warn, sub: "Nichts Schlimmes — nur ein Hinweis." },
problem: { ring: "border-red-500/50 bg-red-500/5", icon: AlertTriangle, iconCls: "text-red-400", title: T.problem, sub: `${problems.length} wichtige${problems.length === 1 ? "s" : ""} Problem${problems.length === 1 ? "" : "e"} gefunden.` },
}[verdict]
const BannerIcon = banner.icon
const memTone = {
ok: "bg-emerald-500", warn: "bg-amber-500", full: "bg-red-500", unknown: "bg-muted-foreground/40",
}[memory.status]
const memText = {
ok: "text-emerald-400", warn: "text-amber-400", full: "text-red-400", unknown: "text-muted-foreground",
}[memory.status]
return (
<div className={cn("rounded-2xl border p-5 shadow-lg shadow-black/15 backdrop-blur-md transition-colors", banner.ring)}>
{/* Gesamt-Verdikt */}
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-border/40 bg-background/30">
<BannerIcon className={cn("h-6 w-6", banner.iconCls)} />
</div>
<div className="min-w-0">
<h2 className="text-base font-bold tracking-tight text-foreground">{banner.title}</h2>
<p className="text-xs text-muted-foreground">{banner.sub}</p>
</div>
</div>
{/* Speicher-Ampel */}
<div className="mt-4 rounded-xl border border-border/30 bg-background/20 p-3">
<div className="mb-1.5 flex items-center justify-between text-[11px]">
<span className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-muted-foreground">
<HardDrive className="h-3.5 w-3.5" /> Speicher
</span>
<span className={cn("font-mono font-bold", memText)}>
{memory.status === "unknown" ? "—" : `${memory.usedGb.toFixed(0)} / ${memory.totalGb.toFixed(0)} GB`}
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full border border-border/30 bg-background/50">
<div className={cn("h-full rounded-full transition-all duration-500", memTone)} style={{ width: `${memory.pct}%` }} />
</div>
<p className={cn("mt-1.5 text-[11px]", memText)}>{memory.text}</p>
</div>
{/* Fähigkeiten-Checkliste */}
<div className="mt-4 space-y-1.5">
{checks.map((c) => (
<CheckRow key={c.id} c={c} busy={!!busy[c.id]} onRepair={() => c.repair && runRepair(c.id, c.repair)} />
))}
</div>
{dialogElement}
</div>
)
}
function CheckRow({ c, busy, onRepair }: { c: LucyCheck; busy: boolean; onRepair: () => void }) {
const Icon = c.icon
const bad = c.status === "down" || c.status === "warn"
return (
<div
className={cn(
"flex items-center justify-between gap-3 rounded-xl border p-2.5 transition-colors",
c.status === "down" ? "border-red-500/25 bg-red-500/5"
: c.status === "warn" ? "border-amber-500/20 bg-amber-500/5"
: "border-border/30 bg-background/15",
)}
>
<div className="flex min-w-0 items-center gap-2.5">
<span className="relative flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border/40 bg-background/30 text-foreground/80">
<Icon className="h-4 w-4" />
<span className={cn("absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full ring-2 ring-black/40", DOT[c.status], c.status === "down" && "animate-pulse")} />
</span>
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-xs font-bold text-foreground">
{c.label}
{c.status === "ok" && <Check className="h-3 w-3 text-emerald-400" />}
{!c.critical && <span className="rounded bg-background/40 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wider text-muted-foreground/60">optional</span>}
</div>
<div className="truncate text-[11px] text-muted-foreground">{c.detail}</div>
</div>
</div>
{bad && c.repair && (
<button
onClick={onRepair}
disabled={busy}
className={cn(
"flex h-8 shrink-0 items-center gap-1.5 rounded-lg border px-2.5 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer disabled:opacity-60",
c.status === "down"
? "border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20"
: "border-amber-500/40 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20",
)}
title={c.repair.label}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wrench className="h-3.5 w-3.5" />}
{c.repair.label}
</button>
)}
{bad && !c.repair && (
<span className="flex shrink-0 items-center gap-1 text-[10px] font-semibold text-muted-foreground" title="Keine Auto-Reparatur bekannt">
<ShieldQuestion className="h-3.5 w-3.5" /> manuell
</span>
)}
</div>
)
}
@@ -1,94 +0,0 @@
import { useState } from "react"
import { Brain, Plus } from "lucide-react"
import { api } from "@/lib/api"
import { useMemory, useQueryClient } from "@/lib/queries"
export function MemoryInputCard() {
const qc = useQueryClient()
const { data: memories = [] } = useMemory({ limit: 3 })
const [memContent, setMemContent] = useState("")
const [memCat, setMemCat] = useState("stable")
const [savingMem, setSavingMem] = useState(false)
async function saveQuickMemory() {
if (!memContent.trim() || savingMem) return
setSavingMem(true)
try {
await api("/api/memory", {
method: "POST",
body: JSON.stringify({ content: memContent, category: memCat, source: "dashboard" }),
})
setMemContent("")
qc.invalidateQueries({ queryKey: ["memory"] })
} catch (e) {
console.error(e)
} finally {
setSavingMem(false)
}
}
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
<div>
<div className="flex items-center gap-2 mb-4">
<Brain className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Gedächtnis</h2>
</div>
<div className="space-y-3">
<textarea
value={memContent}
onChange={(e) => setMemContent(e.target.value)}
aria-label="Fakt oder Regel im Gedächtnis speichern"
placeholder="Fakt / Regel im Pool speichern…"
rows={2}
className="w-full resize-none rounded-xl border border-border/50 bg-background/30 p-2 text-[10px] outline-none focus:ring-1 focus:ring-primary transition-all leading-normal"
/>
<div className="flex items-center gap-2 justify-between">
<select
value={memCat}
onChange={(e) => setMemCat(e.target.value)}
aria-label="Kategorie"
className="h-7 rounded-lg border border-border/50 bg-background/50 px-2 text-[10px] outline-none cursor-pointer"
>
<option value="stable">🔵 Fakt</option>
<option value="instruction">📋 Regel</option>
<option value="user">👤 User</option>
<option value="versioned">🟡 Version</option>
</select>
<button
onClick={saveQuickMemory}
disabled={!memContent.trim() || savingMem}
className="flex h-7 items-center gap-1 rounded-lg bg-primary px-3 text-[10px] font-semibold text-primary-foreground hover:opacity-90 disabled:opacity-50 transition-all cursor-pointer"
>
<Plus className="h-3.5 w-3.5" /> Speichern
</button>
</div>
</div>
<div className="mt-3.5 space-y-1.5">
<div className="text-[9px] text-muted-foreground uppercase font-bold tracking-wider">Zuletzt gespeichert:</div>
<div className="max-h-20 overflow-y-auto space-y-1 pr-1 scrollbar-thin">
{memories.length === 0 ? (
<div className="text-[10px] text-muted-foreground/75 py-1">Keine Einträge vorhanden.</div>
) : (
memories.map((m) => (
<div key={m.id} className="text-[10px] bg-background/10 border border-border/30 rounded p-1.5 flex items-start gap-1.5">
<span className="shrink-0 text-[8px] font-mono text-muted-foreground/80 px-1 py-0.5 rounded bg-muted/20">
{m.category}
</span>
<span className="truncate flex-1 text-muted-foreground hover:text-foreground transition-colors" title={m.content}>
{m.content}
</span>
</div>
))
)}
</div>
</div>
</div>
<div className="mt-4 text-[10px] text-muted-foreground/80 border-t border-border/30 pt-3">
Steht allen Clients per MCP zur Verfügung.
</div>
</div>
)
}
@@ -1,136 +0,0 @@
import { useEffect, useRef, useState } from "react"
import { Boxes, Zap, Moon } from "lucide-react"
import { useModels, useTokenStats, useSystemStatus } from "@/lib/queries"
import { fmtSize, gb } from "@/lib/format"
import { cn } from "@/lib/utils"
import { ROLES, RoleLabel } from "@/components/models/ModelBadges"
// Vereint die frühere RolesCard (Rolle→Modell) + ActiveModelsCard (warm/Inferenz/Größe)
// zu EINER Karte — dieselbe Info stand vorher doppelt auf der Zentrale.
export function ModelsCard() {
const { data } = useModels(2_000)
const { data: stats } = useTokenStats(2_000)
const { data: sysStatus } = useSystemStatus()
const models = data?.models ?? []
const running = data?.running ?? []
// Echte Pool-Belegung (Unified-RAM inkl. KV) — schlanke Glance-Bar. Die volle
// Segment-Bar + „Alle entladen" bleibt im Modell-Manager.
const gttTotal = sysStatus?.gpu?.gtt_total || sysStatus?.gpu?.vram_total || 0
const gttUsed = sysStatus?.gpu?.gtt_used || 0
const poolPct = gttTotal > 0 ? Math.min(100, (gttUsed / gttTotal) * 100) : 0
const poolBar = poolPct >= 88 ? "bg-red-500" : poolPct >= 70 ? "bg-amber-500" : "bg-teal-500"
const poolText = poolPct >= 88 ? "text-red-400" : poolPct >= 70 ? "text-amber-400" : "text-foreground"
// „Inferenz aktiv": total_tokens seit letztem Poll gestiegen → es wird generiert.
const prev = useRef<number | null>(null)
const [generating, setGenerating] = useState(false)
useEffect(() => {
if (!stats) return
const total = stats.total_tokens
if (prev.current !== null && total > prev.current) {
setGenerating(true)
const t = setTimeout(() => setGenerating(false), 4_000)
prev.current = total
return () => clearTimeout(t)
}
prev.current = total
}, [stats?.total_tokens])
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Boxes className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Modelle</h2>
</div>
<span className={cn(
"flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider font-mono px-2 py-0.5 rounded-full border transition-colors",
generating
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/30"
: "bg-background/30 text-muted-foreground/70 border-border/40"
)}>
{generating ? <Zap className="h-3 w-3 animate-pulse" /> : <Moon className="h-3 w-3" />}
{generating ? "Inferenz aktiv" : "Idle"}
</span>
</div>
{gttTotal > 0 && (
<div className="mb-4">
<div className="flex items-center justify-between text-[10px] font-mono text-muted-foreground/80 mb-1">
<span className="uppercase tracking-wider font-semibold">Speicher-Pool</span>
<span><span className={cn("font-bold", poolText)}>{gb(gttUsed)}</span> / {gb(gttTotal)} GB belegt</span>
</div>
<div className="h-1.5 w-full rounded-full bg-background/50 border border-border/30 overflow-hidden">
<div className={cn("h-full rounded-full transition-all duration-500", poolBar)} style={{ width: `${poolPct}%` }} />
</div>
</div>
)}
<div className="space-y-2 flex-1">
{ROLES.map((role) => {
const m = models.find((x) => x.role === role)
const isRunning = m ? running.includes(m.name) : false
return (
<div
key={role}
className={cn(
"flex items-center justify-between gap-2 p-2 rounded-xl border transition-all duration-300",
isRunning
? "border-emerald-500/30 bg-emerald-500/5 shadow-sm shadow-emerald-500/5"
: m
? "border-primary/20 bg-primary/5"
: "border-border/30 bg-background/10 opacity-60"
)}
>
<div className="flex items-center gap-2 min-w-0">
<RoleLabel role={role} className="shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold truncate font-mono text-foreground">
{m ? m.name.split("/").pop()?.replace(/\.gguf$/i, "") : "nicht zugewiesen"}
</span>
{m && (
<div className="flex gap-1 items-center mt-0.5 flex-wrap text-muted-foreground/70">
<span className="text-[9px] font-mono">{fmtSize(m.size_bytes)}</span>
{m.prompt_cache && (
<span className="text-[7px] leading-none font-mono font-bold bg-cyan-500/10 text-cyan-400 border border-cyan-500/20 px-1 py-0.5 rounded" title="Prompt Caching aktiv">PC</span>
)}
{m.spec_active && (
<span className="text-[7px] leading-none font-mono font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-1 py-0.5 rounded" title={`Speculative Decoding aktiv (Draft: ${m.spec_draft_model})`}>SPEC</span>
)}
{m.parallel_slots > 1 && (
<span className="text-[7px] leading-none font-mono font-bold bg-violet-500/10 text-violet-400 border border-violet-500/20 px-1 py-0.5 rounded" title={`${m.parallel_slots} parallele Slots aktiv`}>SLOTS: {m.parallel_slots}</span>
)}
{m.incomplete && (
<span className="text-[7px] leading-none font-mono font-bold bg-amber-500/10 text-amber-400 border border-amber-500/20 px-1 py-0.5 rounded" title="GGUF-Datei fehlt"> fehlt</span>
)}
</div>
)}
</div>
</div>
<span className="shrink-0">
{m ? (
isRunning ? (
<span className="flex items-center gap-1 text-[8px] font-semibold text-emerald-400 uppercase tracking-wider font-mono">
<span className={cn("h-1.5 w-1.5 rounded-full bg-emerald-500", generating && "animate-pulse")} /> warm
</span>
) : (
<span className="text-[8px] font-semibold text-muted-foreground uppercase tracking-wider font-mono">bereit</span>
)
) : (
<span className="text-[8px] font-semibold text-muted-foreground/60 uppercase tracking-wider font-mono"></span>
)}
</span>
</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. Details &amp; Routing im Modell-Manager.
</div>
</div>
)
}
@@ -1,79 +0,0 @@
import { useState } from "react"
import { ExternalLink, RefreshCw, Server } from "lucide-react"
import { api } from "@/lib/api"
import { useServices } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn, resolveExternalUrl } from "@/lib/utils"
export function ServicesCard() {
const { data: svc } = useServices(3_000)
const { showAlert, dialogElement } = useDialog()
const [restarting, setRestarting] = useState<Record<string, boolean>>({})
async function restart(id: string) {
setRestarting((p) => ({ ...p, [id]: true }))
try {
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
method: "POST", body: JSON.stringify({ service: id }),
})
if (!r.ok) showAlert("Fehler", `Neustart fehlgeschlagen: ${r.err || "Unbekannt"}`)
} catch (e: any) {
showAlert("Fehler", `Fehler: ${e.message}`)
} finally {
setRestarting((p) => ({ ...p, [id]: false }))
}
}
return (
<div className="flex flex-col rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Server className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Dienste</h2>
</div>
<button
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "logs" } }))}
className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground transition-colors hover:text-primary cursor-pointer"
>
Logs / Pflege
</button>
</div>
{svc ? (
<div className="flex flex-1 flex-col">
<div className="space-y-1.5">
{svc.services.map((x) => (
<div key={x.unit} className="group flex items-center justify-between gap-2 rounded-xl border border-border/30 bg-background/20 p-2.5">
<div className="flex min-w-0 items-center gap-2">
<span className={cn("h-2.5 w-2.5 shrink-0 rounded-full ring-2 ring-black/40", x.ok ? "bg-emerald-500" : "bg-amber-500")} />
<div className="min-w-0">
<div className="truncate text-xs font-bold text-foreground">{x.name}</div>
<div className="truncate font-mono text-[9px] text-muted-foreground/60">{x.url}</div>
</div>
</div>
<button
onClick={() => restart(x.unit)} disabled={restarting[x.unit]}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border/40 text-muted-foreground opacity-0 transition-all hover:bg-primary/5 hover:text-primary group-hover:opacity-100"
title="Dienst neu starten"
>
<RefreshCw className={cn("h-3.5 w-3.5", restarting[x.unit] && "animate-spin")} />
</button>
</div>
))}
</div>
<div className="mt-auto flex flex-wrap gap-3 border-t border-border/20 pt-2.5 text-[10px] font-semibold text-muted-foreground">
<a href={resolveExternalUrl(svc.links.engine_ui)} target="_blank" rel="noopener" className="flex items-center gap-1 rounded px-1.5 py-0.5 text-primary transition-colors hover:bg-primary/10">
<ExternalLink className="h-3 w-3" /> Engine
</a>
<a href={resolveExternalUrl(svc.links.gateway)} target="_blank" rel="noopener" className="flex items-center gap-1 rounded px-1.5 py-0.5 text-primary transition-colors hover:bg-primary/10">
<ExternalLink className="h-3 w-3" /> Gateway
</a>
</div>
</div>
) : (
<div className="flex h-24 items-center justify-center text-xs text-muted-foreground">Lade Dienste</div>
)}
{dialogElement}
</div>
)
}
@@ -28,7 +28,7 @@ export function SystemStatusCard() {
}
return (
<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 className="flex flex-col justify-between mc-card p-5">
<div>
<div className="mb-3 flex items-center gap-2">
<Cpu className="h-4.5 w-4.5 text-primary" />
@@ -9,14 +9,14 @@ const SERIES: ChartSeries[] = [
]
export function TokenPerformanceCard() {
const { data: ts } = useTokenStats(3_000)
const { data: ts } = useTokenStats()
const hist = useTokHistory() // Durchsatz-Verlauf aus dem modul-globalen Store
const last = hist[hist.length - 1]
const curRate = last ? Math.round(last.prompt + last.completion) : 0
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15">
<div className="mc-card p-5">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
@@ -1,72 +0,0 @@
import { ShieldAlert, ChevronRight } from "lucide-react"
import { useUpdates } from "@/lib/queries"
import { cn } from "@/lib/utils"
function StatusRow({ label, value, tone }: { label: React.ReactNode; value: string; tone: "alert" | "accent" | "muted" }) {
return (
<div className={cn(
"flex items-center justify-between rounded-lg border px-3 py-1.5 text-xs",
tone === "alert" ? "border-amber-500/30 bg-amber-500/5 font-semibold text-amber-400"
: tone === "accent" ? "border-primary/30 bg-primary/5 font-semibold text-primary"
: "border-border/30 bg-background/25 text-muted-foreground"
)}>
<span className="flex items-center gap-1.5">{label}</span>
<span className="font-mono text-[10px]">{value}</span>
</div>
)
}
export function UpdatesCard() {
const { data: updates } = useUpdates(3_000)
const openDrawer = () => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "maintenance" } }))
return (
<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 className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/20 pb-2">
<div className="flex items-center gap-2">
<ShieldAlert className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Updates &amp; Pflege</h2>
</div>
{updates?.last_check && (
<span className="font-mono text-[9px] text-muted-foreground/80">
Zuletzt gesucht: {new Date(updates.last_check * 1000).toLocaleString("de-DE", {
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
})}
</span>
)}
</div>
{updates ? (
<div className="space-y-1.5">
<StatusRow label="OS-Pakete" tone={updates.os > 0 ? "alert" : "muted"}
value={updates.os > 0 ? `${updates.os} verfügbar` : "aktuell"} />
<StatusRow label="Inferenz-Engine (llama.cpp)" tone={updates.engine > 0 ? "alert" : "muted"}
value={updates.engine > 0 ? "Update verfügbar" : "aktuell"} />
<StatusRow label="Router (llama-swap)" tone={updates.swap > 0 ? "alert" : "muted"}
value={updates.swap > 0 ? "Update verfügbar" : "aktuell"} />
<StatusRow label="Modell-Upgrades" tone={updates.models > 0 ? "accent" : "muted"}
value={updates.models > 0 ? `${updates.models} verfügbar` : "aktuell"} />
{updates.components?.map((c) => (
<StatusRow
key={c.key}
tone={c.update === true ? "alert" : "muted"}
label={<>{c.name}{c.reachable === false && <span className="text-[8px] font-bold uppercase text-red-400/80">offline</span>}</>}
value={c.update === true ? `Update: ${c.latest}` : c.update === false ? "aktuell" : c.latest ? `neueste: ${c.latest}` : "—"}
/>
))}
</div>
) : (
<div className="flex h-24 items-center justify-center text-xs text-muted-foreground">Lade Updates...</div>
)}
</div>
<button
onClick={openDrawer}
className="mt-4 flex h-9 w-full items-center justify-center gap-1.5 rounded-lg bg-primary text-xs font-semibold text-primary-foreground shadow-md shadow-primary/10 transition-all hover:opacity-90 cursor-pointer"
>
Updates verwalten &amp; Pflege <ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
)
}
+2 -2
View File
@@ -6,7 +6,7 @@ import { fmtBytes, fmtEta } from "@/lib/format"
export function JobsBar() {
const qc = useQueryClient()
const { data: jobs = [] } = useJobs(2_000)
const { data: jobs = [] } = useJobs()
const { showAlert, dialogElement } = useDialog()
async function cancelJob(jobId: string) {
@@ -24,7 +24,7 @@ export function JobsBar() {
if (active.length === 0 && recent.length === 0) return null
return (
<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="space-y-3 mc-card p-4">
<div className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">Aktive Downloads</div>
{active.map((j) => (
@@ -23,7 +23,7 @@ export function LaneEditor() {
if (isLoading || !meta || !draft) {
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 text-xs text-muted-foreground">
<div className="mc-card p-5 text-xs text-muted-foreground">
Lade Routing-Policy
</div>
)
@@ -116,7 +116,7 @@ export function LaneEditor() {
}
return (
<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="mc-card p-5 space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Sliders className="h-4.5 w-4.5 text-primary" />
@@ -3,7 +3,7 @@ import { cn } from "@/lib/utils"
import { ROLES, roleTone, roleMeta } from "@/lib/roleMeta"
// Klartext-Schicht: Rollen-Namen/Icons/Farben leben jetzt zentral in lib/roleMeta.ts.
// Re-Export hält bestehende Importe (Cockpit, ModelsCard, …) stabil.
// Re-Export hält bestehende Importe (Cockpit, Workbench, …) stabil.
export { ROLES, roleTone, roleMeta }
// Rollen-Badge in Klartext: Icon + Zweck-Name, technisches Kürzel als Tooltip.
@@ -12,7 +12,7 @@ import { cn } from "@/lib/utils"
// mit Speicher-Budget und Geländer (Hirn/Gedächtnis nicht entfernbar).
export function WarmSetManager() {
const qc = useQueryClient()
const { data: modelsResp } = useModels(4_000)
const { data: modelsResp } = useModels()
const { data: groupsResp } = useGroups()
const { data: sysStatus } = useSystemStatus()
const { data: brain } = useHermesBrain()
@@ -69,7 +69,7 @@ export function WarmSetManager() {
if (!group) {
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10">
<div className="mc-card p-5">
<Header />
<p className="mt-3 text-xs text-muted-foreground">
Es gibt noch kein Immer-bereit-Set. Es entsteht automatisch, sobald Lucys Hirn zugewiesen ist.
@@ -80,7 +80,7 @@ export function WarmSetManager() {
}
return (
<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="mc-card p-5 space-y-4">
<Header />
{/* Mitglieder */}
+123 -150
View File
@@ -1,8 +1,20 @@
@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');
/* Fonts lokal gebündelt (@fontsource) — die Box-UI braucht kein Internet für Schrift. */
@import "@fontsource/inter/300.css";
@import "@fontsource/inter/400.css";
@import "@fontsource/inter/500.css";
@import "@fontsource/inter/600.css";
@import "@fontsource/inter/700.css";
@import "@fontsource/space-grotesk/400.css";
@import "@fontsource/space-grotesk/500.css";
@import "@fontsource/space-grotesk/600.css";
@import "@fontsource/space-grotesk/700.css";
@import "@fontsource/jetbrains-mono/400.css";
@import "@fontsource/jetbrains-mono/500.css";
@import "tailwindcss";
/* Design-System: EINE Akzentfarbe Teal. Dark als Default.
Glassmorphismus & Aurora Keyframes für den Vibe 2026. */
/* Design-System: EINE Akzentfarbe Teal, bewusst dark-only (Glassmorphismus-Look).
Karten-Optik lebt in .mc-card / .mc-card-sm (unten) — Komponenten sagen es explizit,
statt dass CSS Utility-Klassen-Kombinationen errät. */
:root {
--background: 224 30% 6%;
@@ -24,25 +36,6 @@
color-scheme: dark;
}
.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%;
color-scheme: light;
}
@theme inline {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
@@ -67,55 +60,6 @@
--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) / 0.85);
outline-color: hsl(var(--primary) / 0.5);
}
/* Sichtbarer Fokus für Tastatur-Nutzer auf allen interaktiven Elementen
(Inputs haben ihren eigenen Ring unten via box-shadow). */
:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.7);
outline-offset: 2px;
}
/* Bewegungs-Sensibilität respektieren: Animationen/Transitions praktisch aus. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
html,
body,
#root {
@@ -131,11 +75,109 @@ body {
-webkit-font-smoothing: antialiased;
}
* {
border-color: hsl(var(--border) / 0.85);
outline-color: hsl(var(--primary) / 0.5);
}
/* Sichtbarer Fokus für Tastatur-Nutzer auf allen interaktiven Elementen. */
:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.7);
outline-offset: 2px;
}
/* Eingabefelder: Teal-Fokus statt Browser-Default (ungeschichtet → gewinnt ohne !important). */
input:focus, textarea:focus, select:focus {
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.15);
outline: none;
}
/* Schmale Teal-Scrollbars im Glas-Look (EINE Definition). */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--primary) / 0.25);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--primary) / 0.5);
}
/* ==========================================================================
Model Control HUD 3.0 & Rework Visual Enhancements (Vibrant & Structured)
Karten-Optik (ehem. „HUD 3.0"-Layer, jetzt explizite Klassen ohne !important;
ungeschichtete Regeln schlagen Tailwind-Utilities von selbst)
========================================================================== */
/* SVG Bezier Flow Animation */
/* Große Inhalts-Karte: Glas-Grund, Farbverlaufs-Linie oben, sanftes Anheben bei Hover. */
.mc-card {
position: relative;
overflow: hidden;
border-radius: 1rem;
border: 1px solid hsl(var(--border) / 0.95);
background-color: hsl(var(--card) / 0.85);
box-shadow: 0 10px 30px -15px rgb(0 0 0 / 0.45), inset 0 1px 0 0 rgb(255 255 255 / 0.05);
transition:
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.mc-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3.5px;
background: linear-gradient(90deg, hsl(var(--primary)), #6366f1, #a855f7);
opacity: 0.75;
transition: opacity 0.3s ease;
}
.mc-card:hover {
transform: translateY(-3px);
background-color: hsl(var(--card) / 0.92);
border-color: hsl(var(--primary) / 0.35);
box-shadow: 0 20px 40px -20px rgb(0 0 0 / 0.65), 0 0 18px 2px hsl(var(--primary) / 0.05);
}
.mc-card:hover::before {
opacity: 1;
}
/* Kleine Karte / Listen-Container: ruhiger, ohne Verlaufs-Linie und Anheben. */
.mc-card-sm {
border-radius: 0.75rem;
border: 1px solid hsl(var(--border) / 0.9);
background-color: hsl(var(--card) / 0.88);
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.mc-card-sm:hover {
background-color: hsl(var(--card) / 0.95);
border-color: hsl(var(--primary) / 0.3);
}
/* Aktiver Navigations-Eintrag in der Sidebar (ausgeklappt / eingeklappt). */
.nav-active {
background: linear-gradient(90deg, hsl(var(--primary) / 0.18), rgb(99 102 241 / 0.12));
color: hsl(var(--primary));
border-left: 3.5px solid hsl(var(--primary));
border-radius: 0 var(--radius) var(--radius) 0;
padding-left: calc(0.75rem - 3.5px);
box-shadow: inset 0 1px 0 0 rgb(255 255 255 / 0.02);
}
.nav-active-collapsed {
background: hsl(var(--primary) / 0.18);
color: hsl(var(--primary));
box-shadow: 0 0 12px 1px hsl(var(--primary) / 0.1);
border: 1px solid hsl(var(--primary) / 0.3);
}
/* SVG-Fluss-Animation (Modell-Cockpit-Verdrahtung). */
@keyframes flow-cyan {
0% {
stroke-dasharray: 6 3;
@@ -151,83 +193,14 @@ body {
animation: flow-cyan 1.2s linear infinite;
}
/* Base enhancement for major cards to make them clearly visible and pop out */
.rounded-2xl.border.bg-card\/45 {
background-color: hsl(var(--card) / 0.85) !important;
border-color: hsl(var(--border) / 0.95) !important;
box-shadow: 0 10px 30px -15px rgba(0, 0, 0, 0.45), inset 0 1px 0 0 rgba(255, 255, 255, 0.05) !important;
position: relative;
overflow: hidden;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
/* Gorgeous gradient top accent line to all main cards to inject color and structure */
.rounded-2xl.border.bg-card\/45::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3.5px;
background: linear-gradient(90deg, hsl(var(--primary)), #6366f1, #a855f7);
opacity: 0.75;
transition: opacity 0.3s ease !important;
}
/* Smooth hover lifting effect */
.rounded-2xl.border.bg-card\/45:hover {
transform: translateY(-3px);
background-color: hsl(var(--card) / 0.92) !important;
border-color: hsl(var(--primary) / 0.35) !important;
box-shadow: 0 20px 40px -20px rgba(0, 0, 0, 0.65), 0 0 18px 2px hsl(var(--primary) / 0.05) !important;
}
.rounded-2xl.border.bg-card\/45:hover::before {
opacity: 1;
}
/* Enhancements for smaller cards / lists */
.rounded-xl.border.bg-card\/45 {
background-color: hsl(var(--card) / 0.88) !important;
border-color: hsl(var(--border) / 0.9) !important;
transition: background-color 0.2s ease, border-color 0.2s ease !important;
}
.rounded-xl.border.bg-card\/45:hover {
background-color: hsl(var(--card) / 0.95) !important;
border-color: hsl(var(--primary) / 0.3) !important;
}
/* Sidebar active link styling */
aside nav a[class*="bg-primary/15"]:not([class*="justify-center"]) {
background: linear-gradient(90deg, hsl(var(--primary) / 0.18), rgba(99, 102, 241, 0.12)) !important;
color: hsl(var(--primary)) !important;
border-left: 3.5px solid hsl(var(--primary)) !important;
border-radius: 0 var(--radius) var(--radius) 0 !important;
padding-left: calc(0.75rem - 3.5px) !important;
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.02) !important;
}
aside nav a[class*="bg-primary/15"][class*="justify-center"] {
background: hsl(var(--primary) / 0.18) !important;
color: hsl(var(--primary)) !important;
box-shadow: 0 0 12px 1px hsl(var(--primary) / 0.1) !important;
border: 1px solid hsl(var(--primary) / 0.3) !important;
}
/* Colorful scrollbars */
::-webkit-scrollbar-thumb {
background: hsl(var(--primary) / 0.25) !important;
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--primary) / 0.5) !important;
}
/* Dynamic input focus styling */
input:focus, textarea:focus, select:focus {
border-color: hsl(var(--primary)) !important;
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.15) !important;
outline: none;
/* Bewegungs-Sensibilität respektieren: Animationen/Transitions praktisch aus. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* WQHD & 4K Monitor Responsive Scaling (Fluid Typography & UI Scaling) */
+6 -3
View File
@@ -6,15 +6,18 @@ export async function api<T = unknown>(path: string, init?: RequestInit): Promis
...init?.headers,
} as Record<string, string>
const sudoPassword = localStorage.getItem("mc_sudo_password")
const hfToken = localStorage.getItem("mc_hf_token")
const method = init?.method?.toUpperCase() || "GET"
// Geheimnisse (Box-Passwort, HF-Token) reisen NUR bei mutierenden Requests mit —
// die Dauer-Polls (GET) brauchen sie nie und sollen sie auch nie im Netz tragen.
const sudoPassword = method === "GET" ? null : localStorage.getItem("mc_sudo_password")
const hfToken = method === "GET" ? null : localStorage.getItem("mc_hf_token")
if (sudoPassword) {
headers["X-Sudo-Password"] = sudoPassword
}
let body = init?.body
const method = init?.method?.toUpperCase() || "GET"
if (method === "POST") {
if (typeof body === "string") {
try {
+2 -2
View File
@@ -26,8 +26,8 @@ export const useTokHistory = () => useSyncExternalStore(subscribe, () => tokHist
/** Immer-laufender Sammler (in App eingehängt, also stets gemountet): füttert den
* Store bei jedem Poll — unabhängig davon, welcher Tab gerade sichtbar ist. */
export function useMetricsFeeder() {
const { data: sys, dataUpdatedAt: sysAt } = useSystemStatus(3_000)
const { data: ts, dataUpdatedAt: tsAt } = useTokenStats(3_000)
const { data: sys, dataUpdatedAt: sysAt } = useSystemStatus()
const { data: ts, dataUpdatedAt: tsAt } = useTokenStats()
const prevTok = useRef<{ p: number; c: number; t: number } | null>(null)
useEffect(() => {
+32 -17
View File
@@ -61,9 +61,22 @@ export const qk = {
reminders: ["reminders"] as const,
}
// ── Polling-Takte ────────────────────────────────────────────────────────────
// EINE Stelle für alle Intervalle. Regel: Die Live-Graphen (System/Token) takten
// schnell, alles andere gemächlich — Mutationen invalidieren ohnehin gezielt,
// Polling ist nur das Sicherheitsnetz. Komponenten übergeben KEINE eigenen
// Intervalle mehr (mehrere Beobachter = kleinstes Intervall gewinnt).
const TAKT = {
graph: 3_000, // Live-Graphen im Cockpit (System, Token-Durchsatz)
schnell: 8_000, // „fühlt sich live an": Modelle, Voice-Trace, Auftragsbuch
normal: 10_000, // Dienste, Routing, Agent-Status, Health
gemuetlich: 30_000, // Chronik, Ideen
traege: 60_000, // Updates-Check, Reminders, Zeitmaschine, HF-Abfragen
} as const
// Auftragsbuch: Vorschlags-Inbox (Branches + Skill-Kandidaten). Pollt, damit laufende
// Annahme-Läufe (Status aus der JSON-Datei des Runners) live sichtbar werden.
export const useAuftragsbuch = (refetchInterval = 8_000) =>
export const useAuftragsbuch = (refetchInterval: number = TAKT.schnell) =>
useQuery({
queryKey: qk.auftragsbuch,
queryFn: () => api<AuftragsbuchResp>("/api/auftragsbuch"),
@@ -71,14 +84,14 @@ export const useAuftragsbuch = (refetchInterval = 8_000) =>
})
// Ideen-Queue (natives Hermes-Kanban): das Backend cached ~15 s, öfter pollen lohnt nicht.
export const useIdeen = (refetchInterval = 20_000) =>
export const useIdeen = (refetchInterval: number = TAKT.gemuetlich) =>
useQuery({
queryKey: qk.ideen,
queryFn: () => api<IdeenResp>("/api/ideen"),
refetchInterval,
})
export const useChronik = (limit = 150, refetchInterval = 15_000) =>
export const useChronik = (limit = 150, refetchInterval: number = TAKT.gemuetlich) =>
useQuery({
queryKey: qk.chronik,
queryFn: () => api<ChronikResp>(`/api/chronik?limit=${limit}`),
@@ -89,14 +102,14 @@ export const useChronik = (limit = 150, refetchInterval = 15_000) =>
export const useWissen = () =>
useQuery({ queryKey: qk.wissen, queryFn: () => api<WissenResp>("/api/wissen") })
export const useZeitmaschine = (refetchInterval = 30_000) =>
export const useZeitmaschine = (refetchInterval: number = TAKT.traege) =>
useQuery({
queryKey: qk.zeitmaschine,
queryFn: () => api<ZeitmaschineResp>("/api/zeitmaschine"),
refetchInterval,
})
export const useReminders = (refetchInterval = 20_000) =>
export const useReminders = (refetchInterval: number = TAKT.traege) =>
useQuery({
queryKey: qk.reminders,
queryFn: () => api<{ items: Reminder[] }>("/api/reminders"),
@@ -105,7 +118,7 @@ export const useReminders = (refetchInterval = 20_000) =>
})
// Per-Turn-Latenz-Trace (letzte N Voice/Lucy-Turns mit Stufen-Breakdown).
export const useVoiceTrace = (limit = 12, refetchInterval = 4_000) =>
export const useVoiceTrace = (limit = 12, refetchInterval: number = TAKT.schnell) =>
useQuery({
queryKey: qk.voiceTrace,
queryFn: () => api<VoiceTraceResp>(`/api/voice/trace?limit=${limit}`),
@@ -121,27 +134,28 @@ export const useMemoryGraph = (enabled = true) =>
})
export const useHealth = () =>
useQuery({ queryKey: qk.health, queryFn: () => api<Health>("/api/health"), refetchInterval: 10_000 })
useQuery({ queryKey: qk.health, queryFn: () => api<Health>("/api/health"), refetchInterval: TAKT.normal })
export const useSystemStatus = (refetchInterval = 5_000) =>
export const useSystemStatus = (refetchInterval: number = TAKT.graph) =>
useQuery({ queryKey: qk.systemStatus, queryFn: () => api<SystemStatus>("/api/system/status"), refetchInterval })
export const useServices = (refetchInterval = 3_000) =>
export const useServices = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.services, queryFn: () => api<ServicesResp>("/api/system/services"), refetchInterval })
export const useModels = (refetchInterval = 4_000) =>
export const useModels = (refetchInterval: number = TAKT.schnell) =>
useQuery({ queryKey: qk.models, queryFn: () => api<ModelsResp>("/api/models"), refetchInterval })
export const useGroups = (refetchInterval = 8_000) =>
export const useGroups = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.groups, queryFn: () => api<GroupsResp>("/api/groups"), refetchInterval })
export const useRouting = (refetchInterval = 4_000) =>
export const useRouting = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.routing, queryFn: () => api<RoutingResp>("/api/routing"), refetchInterval })
export const useRoutingPolicy = () =>
useQuery({ queryKey: qk.routingPolicy, queryFn: () => api<RoutingPolicyMeta>("/api/routing/policy") })
export const useJobs = (refetchInterval = 2_000) =>
// Jobs (Downloads) takten etwas schneller, damit Fortschritts-Balken flüssig wirken.
export const useJobs = (refetchInterval = 3_000) =>
useQuery({
queryKey: qk.jobs,
queryFn: () => api<{ jobs: Job[] }>("/api/jobs"),
@@ -149,17 +163,18 @@ export const useJobs = (refetchInterval = 2_000) =>
select: (d) => d.jobs ?? [],
})
export const useTokenStats = (refetchInterval = 3_000) =>
export const useTokenStats = (refetchInterval: number = TAKT.graph) =>
useQuery({ queryKey: qk.tokenStats, queryFn: () => api<TokenStats>("/api/system/token-stats"), refetchInterval })
export const useAgentStatus = (refetchInterval = 5_000) =>
export const useAgentStatus = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.agentStatus, queryFn: () => api<AgentStatus>("/api/agent/status"), refetchInterval })
// Agent-Hirn (Hermes) + bestes NousResearch-Update. Selten pollen (HF-Abfrage).
export const useHermesBrain = (refetchInterval = 60_000) =>
export const useHermesBrain = (refetchInterval: number = TAKT.traege) =>
useQuery({ queryKey: qk.hermesBrain, queryFn: () => api<HermesBrainResp>("/api/agent/brain"), refetchInterval })
export const useUpdates = (refetchInterval?: number) =>
// Updates-Check ist teuer (apt/git auf der Box) — träge pollen reicht völlig.
export const useUpdates = (refetchInterval: number = TAKT.traege) =>
useQuery({ queryKey: qk.updates, queryFn: () => api<UpdatesResp>("/api/maintenance/updates"), refetchInterval })
export const useDiscover = () =>
+1 -1
View File
@@ -6,7 +6,7 @@ export type { SysSample }
/** Aktuelle System-Werte (für Legende) + rollende Live-Historie aus dem modul-globalen
* Store (überlebt Tab-Wechsel; gefüllt vom App-Feeder). */
export function useSystemHistory() {
const { data: sys, error } = useSystemStatus(3_000)
const { data: sys, error } = useSystemStatus()
const hist = useSysHistory()
return { sys, hist, error }
}
-90
View File
@@ -1,90 +0,0 @@
import { useEffect, useState } from "react"
import { Activity, Code, Bookmark, Wrench, ArrowLeft, type LucideIcon } from "lucide-react"
import { ConnectView } from "@/views/ConnectView"
import { MemoryView } from "@/views/MemoryView"
import { SystemDrawer } from "@/components/SystemDrawer"
import { useHealth } from "@/lib/queries"
import { cn } from "@/lib/utils"
import { Mc3Start } from "./Mc3Start"
import { Mc3Maschinenraum } from "./Mc3Maschinenraum"
// MC3-Prototyp-Schale. Läuft nicht-destruktiv unter #mc3, das produktive MC2 bleibt
// unangetastet. Vier Türen; Innereien sind bewusst die bestehenden, bewährten Views.
type Door = "start" | "vibe" | "konstitution" | "maschinenraum"
const DOORS: { id: Door; label: string; icon: LucideIcon }[] = [
{ id: "start", label: "Start", icon: Activity },
{ id: "vibe", label: "Vibe-Coding", icon: Code },
{ id: "konstitution", label: "Konstitution", icon: Bookmark },
{ id: "maschinenraum", label: "Maschinenraum", icon: Wrench },
]
export function Mc3App() {
const [door, setDoor] = useState<Door>("start")
const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance")
const { data: health } = useHealth()
useEffect(() => {
const onOpen = (e: Event) => {
setDrawerTab(((e as CustomEvent).detail?.tab as "maintenance" | "logs") || "maintenance")
setDrawerOpen(true)
}
window.addEventListener("open-system-drawer", onOpen)
return () => window.removeEventListener("open-system-drawer", onOpen)
}, [])
const boxOk = health && health.engine_reachable && (health.brain ? health.brain.ready : true)
return (
<div className="flex h-full relative">
<div className="fixed inset-0 -z-50 pointer-events-none overflow-hidden bg-[hsl(224,30%,6%)]">
<div className="absolute inset-0 opacity-30 animate-aurora bg-gradient-to-tr from-teal-500/15 via-indigo-500/10 to-purple-500/15 blur-[130px]" />
</div>
<SystemDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} defaultTab={drawerTab} />
{/* Sidebar — 4 Türen */}
<aside className="flex w-56 shrink-0 flex-col border-r border-border/40 bg-card/45 backdrop-blur-md">
<div className="flex items-center gap-2 px-5 py-4 border-b border-border/40">
<div className="h-7 w-7 rounded-lg bg-primary shadow-md shadow-primary/20" />
<div className="leading-tight">
<div className="text-sm font-semibold tracking-wide font-space">Mission Control</div>
<div className="text-[10px] text-muted-foreground">3 · Prototyp</div>
</div>
</div>
<nav className="flex-1 space-y-1 px-3 py-4">
{DOORS.map((d) => (
<button key={d.id} onClick={() => setDoor(d.id)}
className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm transition-all cursor-pointer",
door === d.id ? "bg-primary/15 text-primary" : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}>
<d.icon className="h-4.5 w-4.5 shrink-0" /> {d.label}
</button>
))}
</nav>
<div className="px-3 py-3 border-t border-border/40 space-y-2">
<div className="flex items-center gap-2 px-2 text-xs text-muted-foreground">
<span className={cn("h-2 w-2 rounded-full", boxOk ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
{boxOk ? "Box läuft" : "prüfen"}
</div>
<a href="#dashboard"
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="h-3.5 w-3.5" /> zurück zu MC2
</a>
</div>
</aside>
{/* Main */}
<main className="flex-1 min-w-0 overflow-y-auto p-6 scrollbar-thin">
{door === "start" && <Mc3Start />}
{door === "vibe" && <ConnectView />}
{door === "konstitution" && <MemoryView />}
{door === "maschinenraum" && <Mc3Maschinenraum />}
</main>
</div>
)
}
-60
View File
@@ -1,60 +0,0 @@
import { useState } from "react"
import { Cpu, ShieldCheck, Bot, ChevronRight, ArrowLeft } from "lucide-react"
import { Cockpit } from "@/views/models/Cockpit"
import { AgentView } from "@/views/AgentView"
import { ExpertToggle } from "@/components/ExpertToggle"
// Maschinenraum = die eine Tür für alles Technische. Faltet Modelle/Wartung/Hermes
// zusammen; jede Sektion rendert die BESTEHENDE, bewährte View wieder.
function SectionCard({ icon: Icon, title, sub, onClick }: {
icon: any; title: string; sub: string; onClick: () => void
}) {
return (
<button onClick={onClick}
className="w-full flex items-center gap-4 rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 text-left transition-all hover:border-primary/40 cursor-pointer">
<Icon className="h-6 w-6 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm text-foreground">{title}</div>
<div className="text-xs text-muted-foreground">{sub}</div>
</div>
<ChevronRight className="h-5 w-5 text-muted-foreground shrink-0" />
</button>
)
}
export function Mc3Maschinenraum() {
const [section, setSection] = useState<null | "modelle" | "hermes">(null)
if (section) {
return (
<div className="space-y-4">
<button onClick={() => setSection(null)}
className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
<ArrowLeft className="h-4 w-4" /> Maschinenraum
</button>
{section === "modelle" ? <Cockpit /> : <AgentView />}
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<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">
Maschinenraum
</h1>
<p className="text-sm text-muted-foreground">Nur nötig, wenn du wirklich was ändern willst.</p>
</div>
<ExpertToggle />
</div>
<SectionCard icon={Cpu} title="Modelle" sub="Hirne, Coder, Immer-bereit-Set, Bibliothek"
onClick={() => setSection("modelle")} />
<SectionCard icon={ShieldCheck} title="Wartung und Logs" sub="Updates, Backup, Dienste, Logs, Zugangsdaten"
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "maintenance" } }))} />
<SectionCard icon={Bot} title="Hermes und Verdrahtung" sub="Agent, Gehirn-Modell, PC-Verbindung"
onClick={() => setSection("hermes")} />
</div>
)
}
-93
View File
@@ -1,93 +0,0 @@
import { ArrowUpCircle, ChevronRight, Check, Cpu, Code } from "lucide-react"
import { LucyHealthCard } from "@/components/dashboard/LucyHealthCard"
import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard"
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
import { useUpdates, useModels } from "@/lib/queries"
import { cn } from "@/lib/utils"
// MC3-Startseite = der eine „Basisstation"-Blick: Ampel (wiederverwendet) + Updates
// (klickbar) + was gerade läuft + die geschätzte Telemetrie (System/Token/Sprach-Latenz).
// Bewusst KEINE Wärme/Platte/Speicher-Kacheln — die stehen schon im System-Status.
export function Mc3Start() {
const { data: updates } = useUpdates(30_000)
const { data: modelsResp } = useModels()
const upCount = (updates?.os || 0) + (updates?.engine || 0) + (updates?.swap || 0) + (updates?.models || 0)
const openUpdates = () => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "maintenance" } }))
const models = modelsResp?.models ?? []
const running = modelsResp?.running ?? []
const brain = models.find((m) => m.role === "hermes")
const coder = models.find((m) => m.role === "coder-lite") || models.find((m) => m.role === "coder")
const isWarm = (name?: string) => (name ? running.includes(name) : false)
const shortName = (n?: string) => n?.split("/").pop()?.replace(/\.gguf$/i, "") || "—"
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">
Basisstation
</h1>
<p className="text-sm text-muted-foreground">Geht's der Box gut, gibt's Updates, was läuft gerade.</p>
</div>
{/* Gesamt-Ampel + Fähigkeiten + Speicher (voll wiederverwendet, Box-Rahmen) */}
<LucyHealthCard frame="box" />
{/* Updates — klickbar, führt direkt zur Liste, was aktualisiert wird */}
<button
onClick={openUpdates}
className={cn(
"w-full flex items-center gap-3 rounded-2xl border p-4 text-left transition-all cursor-pointer",
upCount > 0
? "border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/10"
: "border-border/60 bg-card/45 hover:border-primary/40",
)}
>
{upCount > 0
? <ArrowUpCircle className="h-6 w-6 shrink-0 text-amber-400" />
: <Check className="h-6 w-6 shrink-0 text-emerald-400" />}
<div className="flex-1 min-w-0">
<div className={cn("text-sm font-semibold", upCount > 0 ? "text-amber-400" : "text-foreground")}>
{upCount > 0 ? `${upCount} Update${upCount === 1 ? "" : "s"} verfügbar` : "Alles aktuell"}
</div>
<div className="text-xs text-muted-foreground">
{upCount > 0 ? "Antippen, um zu sehen, was genau aktualisiert wird." : "Keine ausstehenden Updates."}
</div>
</div>
{upCount > 0 && (
<span className="shrink-0 flex items-center gap-1 text-xs font-semibold text-amber-400">
Ansehen <ChevronRight className="h-4 w-4" />
</span>
)}
</button>
{/* Was gerade läuft — beide Lanes */}
<div>
<div className="text-xs text-muted-foreground mb-2">Was gerade läuft</div>
<div className="rounded-2xl border border-border/60 overflow-hidden">
{[
{ icon: Cpu, label: "Lucys Hirn", model: brain?.name, warm: isWarm(brain?.name) },
{ icon: Code, label: "Coder (Vibe-Coding)", model: coder?.name, warm: isWarm(coder?.name) },
].map((r) => (
<div key={r.label} className="flex items-center gap-3 px-4 py-3 border-b border-border/40 last:border-0">
<span className={cn("h-2 w-2 shrink-0 rounded-full", r.warm ? "bg-emerald-500 animate-pulse" : "bg-muted-foreground/40")} />
<r.icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm text-foreground w-40 shrink-0">{r.label}</span>
<span className="text-xs text-muted-foreground flex-1 truncate font-mono">{shortName(r.model)}</span>
<span className={cn("text-xs shrink-0", r.warm ? "text-emerald-400" : "text-muted-foreground")}>
{r.model ? (r.warm ? "warm" : "bereit") : "nicht gesetzt"}
</span>
</div>
))}
</div>
</div>
{/* Geschätzte Telemetrie — 1:1 aus MC2 übernommen */}
<div className="grid gap-6 lg:grid-cols-2">
<SystemStatusCard />
<TokenPerformanceCard />
</div>
</div>
)
}
+4 -4
View File
@@ -9,7 +9,7 @@ import { cn, resolveExternalUrl } from "@/lib/utils"
const ALIASES = ["chat", "coding", "fast", "heavy"]
export function AgentView() {
const { data: s, error: sErr } = useAgentStatus(5_000)
const { data: s, error: sErr } = useAgentStatus()
const { data: modelsData } = useModels()
const { showAlert, dialogElement } = useDialog()
const qc = useQueryClient()
@@ -83,7 +83,7 @@ export function AgentView() {
{s && (
<div className="space-y-6">
{/* Agent-Fluss: Terminal → Gateway → Hirn / Verdrahtung / PC (ruhig, statt SVG-Graph) */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10">
<div className="mc-card p-5">
<div className="mb-4 flex items-center gap-2">
<Bot className="h-4.5 w-4.5 text-primary" />
<span className="text-xs font-bold uppercase tracking-wider text-foreground">Agent-Fluss</span>
@@ -168,7 +168,7 @@ export function AgentView() {
</div>
{/* PC Executor Status Card */}
<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="mc-card p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Monitor className="h-5 w-5 text-primary" />
@@ -218,7 +218,7 @@ export function AgentView() {
{/* Diagnostic Details if Offline */}
{!s.gateway_reachable && (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-6 space-y-4 shadow-lg shadow-black/10 text-left">
<div className="mc-card p-6 space-y-4 text-left">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-amber-500" />
<h3 className="text-sm font-bold uppercase tracking-wider text-foreground">Hermes-Agent Diagnostics</h3>
+2 -2
View File
@@ -195,7 +195,7 @@ function IdeenSection() {
<SectionLabel icon={Lightbulb}>
Ideen-Queue{data ? `${data.offen} offen` : ""}
</SectionLabel>
<div className="rounded-2xl border border-border/60 bg-card/45 p-4 shadow-lg shadow-black/15 backdrop-blur-md space-y-3">
<div className="mc-card p-4 space-y-3">
<div className="flex gap-2">
<input
value={text}
@@ -311,7 +311,7 @@ function ProposalCard({
}
return (
<div className="rounded-2xl border border-border/60 bg-card/45 p-4 shadow-lg shadow-black/15 backdrop-blur-md space-y-3">
<div className="mc-card p-4 space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
+2 -2
View File
@@ -120,7 +120,7 @@ export function ConnectView() {
</div>
{/* Hero: die zwei Leitungen */}
<div className="p-5 rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md shadow-lg shadow-black/10">
<div className="p-5 mc-card">
<div className="grid gap-4 md:grid-cols-[170px_1fr] items-center">
{/* Lokaler Agent */}
<div className="rounded-2xl border border-border/60 bg-background/40 p-4 text-center">
@@ -186,7 +186,7 @@ export function ConnectView() {
</div>
{/* Gemeinsame Variablen */}
<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">
<div className="grid gap-4 md:grid-cols-2 p-5 mc-card">
<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
-55
View File
@@ -1,55 +0,0 @@
import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard"
import { UpdatesCard } from "@/components/dashboard/UpdatesCard"
import { AgentStatusCard } from "@/components/dashboard/AgentStatusCard"
import { MemoryInputCard } from "@/components/dashboard/MemoryInputCard"
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
import { ServicesCard } from "@/components/dashboard/ServicesCard"
import { LatencyCard } from "@/components/dashboard/LatencyCard"
import { ModelsCard } from "@/components/dashboard/ModelsCard"
import { LucyHealthCard } from "@/components/dashboard/LucyHealthCard"
function ZoneLabel({ children }: { children: React.ReactNode }) {
return <p className="mb-2 ml-0.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">{children}</p>
}
export function DashboardView() {
return (
<div className="space-y-7">
<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>
{/* „Geht's Lucy gut?" — Klartext-Ampel + 1-Klick-Reparatur, ganz oben. */}
<LucyHealthCard />
{/* Hero-Banner — System, Durchsatz, Modelle (Rollen + warm + Inferenz in einer Karte) */}
<div className="grid items-stretch gap-6 lg:grid-cols-3">
<SystemStatusCard />
<TokenPerformanceCard />
<ModelsCard />
</div>
{/* Stack & Telemetrie */}
<section>
<ZoneLabel>Stack &amp; Telemetrie</ZoneLabel>
<div className="grid gap-6 lg:grid-cols-2">
<ServicesCard />
<LatencyCard />
</div>
</section>
{/* Betrieb & Wissen */}
<section>
<ZoneLabel>Betrieb &amp; Wissen</ZoneLabel>
<div className="grid gap-6 md:grid-cols-3">
<UpdatesCard />
<AgentStatusCard />
<MemoryInputCard />
</div>
</section>
</div>
)
}
+3 -3
View File
@@ -50,7 +50,7 @@ function Card({ id, icon: Icon, color, title, kicker, children, wide }: {
<section
id={id}
className={cn(
"scroll-mt-20 rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-6 shadow-lg shadow-black/10 space-y-3",
"scroll-mt-20 mc-card p-6 space-y-3",
wide && "md:col-span-2"
)}
>
@@ -109,7 +109,7 @@ export function GuideView() {
return (
<div className="space-y-7">
{/* Hero */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-6 shadow-lg shadow-black/10 flex items-start gap-4">
<div className="mc-card p-6 flex items-start gap-4">
<div className="h-11 w-11 rounded-xl bg-primary/15 flex items-center justify-center shrink-0">
<BookOpen className="h-6 w-6 text-primary" />
</div>
@@ -419,7 +419,7 @@ description: Treibt Entwicklung mit strikter TDD-Praxis
</Card>
{/* 13 — Ressourcen */}
<section id="ressourcen" className="scroll-mt-20 md:col-span-2 rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-6 shadow-lg shadow-black/10 space-y-4">
<section id="ressourcen" className="scroll-mt-20 md:col-span-2 mc-card p-6 space-y-4">
<div className="flex items-center gap-2.5 border-b border-border/20 pb-3">
<Compass className="h-5 w-5 text-primary" />
<div>
+1 -1
View File
@@ -6,7 +6,7 @@ import { resolveExternalUrl, cn } from "@/lib/utils"
// per iframe — Schwester der Hermes-Terminal-Seite. Login per Box-Passwort (su-Prompt im
// Terminal selbst). Die Passwort-Verwaltung liegt in System-Wartung → Einstellungen.
export function KonsoleView() {
const { data: agent } = useAgentStatus(5_000)
const { data: agent } = useAgentStatus()
const url = agent?.box_console_url ? resolveExternalUrl(agent.box_console_url) : undefined
const ok = agent?.box_console_reachable
+1 -1
View File
@@ -18,7 +18,7 @@ export function ModelsView() {
</p>
</div>
<div className="flex rounded-xl border border-border/60 bg-card/45 backdrop-blur-md p-1 self-start">
<div className="flex mc-card-sm p-1 self-start">
{(["cockpit", "discover"] as const).map((t) => (
<button
key={t}
+1 -1
View File
@@ -134,7 +134,7 @@ export function WissenView() {
</aside>
{/* Inhalt */}
<div className="min-w-0 flex-1 rounded-2xl border border-border/60 bg-card/45 p-5 shadow-lg shadow-black/15 backdrop-blur-md">
<div className="min-w-0 flex-1 mc-card p-5">
{docLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" /> Notiz wird geladen </div>
) : doc ? (
+1 -1
View File
@@ -42,7 +42,7 @@ export function CockpitTile({
<button
onClick={onClick}
className={cn(
"group relative flex flex-col items-start rounded-2xl border border-border/60 bg-card/45 p-5 text-left shadow-lg shadow-black/15 backdrop-blur-md transition-all duration-200 cursor-pointer hover:bg-card/70 hover:shadow-xl",
"group relative flex flex-col items-start mc-card p-5 text-left cursor-pointer",
RING[tone],
)}
>
+5 -5
View File
@@ -27,13 +27,13 @@ const openDrawer = (tab: "maintenance" | "logs") =>
// dich"-Bereich für offene Aktionen, darunter die Bereichs-Kacheln (je EINE Live-Zahl).
// Überblick außen, Tiefe hinter der Kachel — Daten/Aktionen aus den IST-Hooks.
export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void }) {
const { data: models } = useModels(4_000)
const { data: svc } = useServices(4_000)
const { data: updates } = useUpdates(8_000)
const { data: models } = useModels()
const { data: svc } = useServices()
const { data: updates } = useUpdates()
const { data: memory } = useMemory()
const { data: agent } = useAgentStatus()
const { data: connect } = useConnectHealth()
const { data: auftraege } = useAuftragsbuch(20_000)
const { data: auftraege } = useAuftragsbuch()
const health = useLucyHealth()
const { showAlert, dialogElement } = useDialog()
@@ -369,7 +369,7 @@ function ToolListCard({ agent, svcErrors, onNavigate }: {
label: "Interaktives Terminal",
status: "Öffnen",
tone: "muted",
action: () => onNavigate("terminal"),
action: () => onNavigate("konsole"),
},
{
label: "Wissens-Vault",
@@ -48,7 +48,7 @@ export function ErinnerungenCard() {
const sorted = [...items].sort((a, b) => a.next_ts - b.next_ts)
return (
<div className="rounded-2xl border border-border/60 bg-card/45 p-4 shadow-lg shadow-black/15 backdrop-blur-md">
<div className="mc-card p-4">
<div className="mb-3 flex items-center justify-between">
<span className="flex items-center gap-2 text-sm font-bold text-foreground">
<span className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/40 bg-background/30 text-amber-300">
+6 -6
View File
@@ -15,8 +15,8 @@ import { useExpertMode } from "@/lib/useExpertMode"
export function Cockpit() {
const qc = useQueryClient()
const { data: modelsResp, isLoading: loading, error: loadErr } = useModels(4_000)
const { data: updates } = useUpdates(4_000)
const { data: modelsResp, isLoading: loading, error: loadErr } = useModels()
const { data: updates } = useUpdates()
const { data: brain } = useHermesBrain()
const { data: sysStatus } = useSystemStatus()
const { data: groupsResp } = useGroups()
@@ -265,7 +265,7 @@ export function Cockpit() {
`}</style>
{/* ZONE A: Widescreen VRAM HUD */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-3.5">
<div className="mc-card p-5 space-y-3.5">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<HardDrive className="h-4.5 w-4.5 text-primary" />
@@ -329,7 +329,7 @@ export function Cockpit() {
{expert && <LaneEditor />}
{/* ZONE B.5: Slot-Belegung */}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-3.5">
<div className="mc-card p-5 space-y-3.5">
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground px-1">
{expert ? "Gateway Steckplatz-Belegung (Slot-Zuweisung)" : "Wer macht was bei Lucy — zum Ändern antippen"}
</div>
@@ -515,7 +515,7 @@ export function Cockpit() {
<div
key={m.name}
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 group relative overflow-hidden",
"mc-card p-5 flex flex-col justify-between gap-4 group relative overflow-hidden",
isRunning
? "border-primary/45 shadow-primary/5"
: m.role
@@ -725,7 +725,7 @@ export function Cockpit() {
<div
key={m.name}
className={cn(
"rounded-xl border bg-card/45 backdrop-blur-md p-3.5 shadow-lg shadow-black/5 flex flex-col sm:flex-row sm:items-center justify-between gap-3 hover:border-primary/40 transition-all",
"mc-card-sm p-3.5 flex flex-col sm:flex-row sm:items-center justify-between gap-3",
isRunning
? "border-primary/45"
: m.role
+2 -2
View File
@@ -77,7 +77,7 @@ export function Discover() {
return (
<div className="space-y-6">
{/* Modus-Umschalter: geführt (Empfohlen) vs. Stöbern & Suchen */}
<div className="flex rounded-xl border border-border/60 bg-card/45 backdrop-blur-md p-1 w-fit">
<div className="flex mc-card-sm p-1 w-fit">
{([["recommended", "Empfohlen"], ["browse", "Stöbern & Suchen"]] as const).map(([m, label]) => (
<button
key={m}
@@ -145,7 +145,7 @@ export function Discover() {
<div
key={cat.role}
className={cn(
"rounded-2xl border bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-5 transition-all duration-300 hover:border-primary/40",
"mc-card p-5 flex flex-col justify-between gap-5",
installedModel ? "border-border/60" : "border-primary/20 shadow-primary/5"
)}
>
+1 -1
View File
@@ -190,7 +190,7 @@ export function ModelBrowse() {
<div
key={r.repo}
className={cn(
"rounded-xl border bg-card/45 backdrop-blur-md transition-all",
"mc-card-sm",
isOpen ? "border-primary/40 shadow-lg shadow-primary/5" : "border-border/50 hover:border-primary/30",
)}
>
@@ -26,7 +26,7 @@ export function MaschinenraumBar({
const freePct = (freeBytes / capacity) * 100
return (
<div className="rounded-2xl border border-border/60 bg-card/45 p-5 shadow-lg shadow-black/15 backdrop-blur-md">
<div className="mc-card p-5">
<div className="mb-3 flex flex-wrap items-end justify-between gap-2">
<div className="flex items-center gap-2">
<HardDrive className="h-5 w-5 text-primary" />
@@ -24,7 +24,7 @@ const short = (name: string) => name.split("/").pop()?.replace(/\.gguf$/i, "") ?
// Eingebettet als Haupt-Tab im Modell-Manager (Überschrift + Discover liefert ModelsView).
export function ModelsWorkbench() {
const qc = useQueryClient()
const { data: modelsResp, isLoading, error } = useModels(4_000)
const { data: modelsResp, isLoading, error } = useModels()
const { data: sys } = useSystemStatus()
const { data: groupsResp } = useGroups()
const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog()
@@ -139,7 +139,7 @@ export function ModelsWorkbench() {
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)]">
{/* Master — Liste */}
<div className="rounded-2xl border border-border/60 bg-card/45 p-4 shadow-lg shadow-black/15 backdrop-blur-md">
<div className="mc-card p-4">
<div className="mb-3 flex items-center gap-2">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" />
@@ -194,7 +194,7 @@ export function ModelsWorkbench() {
</div>
{/* Detail — verwalten */}
<div className="rounded-2xl border border-border/60 bg-card/45 p-5 shadow-lg shadow-black/15 backdrop-blur-md">
<div className="mc-card p-5">
{!selected ? (
<div className="flex h-full min-h-[16rem] flex-col items-center justify-center gap-2 text-center text-muted-foreground">
<Cpu className="h-8 w-8 opacity-40" />