991c9a2f82
Ampel / ampel (push) Successful in 22s
Die Queue war 703 der 1147 Zeilen in AuftragsbuchView — die MEHRHEIT einer Seite,
die nach etwas anderem benannt ist. Und sie laufen gegenlaeufig: Auftragsbuch =
die Box schlaegt DIR vor (Eingang), Ideen = DU beauftragst die Box (Ausgang).
Seit dem Umbau ist Idee -> Konzept -> Repo -> Zed der Haupteingang des Systems
und gehoert in die Seitenleiste, nicht in einen Unterabschnitt.
Umzug (reines Verschieben, kein Logik-Umbau):
AuftragsbuchView 1147 -> 406 Zeilen Bundle 51,0 -> 17,8 KB
IdeenView neu, 785 Zeilen Bundle 32,7 KB, laedt nur bei Bedarf
fmtWhen -> lib/format.ts (dessen Kopf genau davor warnt: "vorher in
einzelnen Views dupliziert")
SectionLabel -> components/ui/ (beide Seiten brauchen sie jetzt)
Zwei inhaltliche Aenderungen:
1. Die flache Einzelkarten-Liste wird gruppiert — mit der Ordnung, die
ProjektGruppe fuer Ketten LAENGST benutzt (AKTIV_RANG): aktiv zuerst,
Wartende und Fertige eingeklappt. Bei 19 Karten, davon 16 fertig, gingen die
drei relevanten in der Erstellzeit-Liste unter.
2. Die Kopfzeile sagt jetzt "1 braucht dich · 2 in Arbeit · 16 fertig" statt
einer nackten Gesamtzahl.
Das Auftragsbuch zaehlte im gruenen "Nichts zu entscheiden" bisher haengende
Ideen mit, damit es nicht luegt, waehrend direkt darunter eine Karte haengt.
Nach der Trennung wird daraus ein anklickbarer Querverweis auf die Ideen-Seite —
dieselbe Ehrlichkeit, jetzt mit Weg dorthin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
370 lines
18 KiB
TypeScript
370 lines
18 KiB
TypeScript
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 { SystemDrawer } from "@/components/SystemDrawer"
|
|
import { useHealth, useSystemStatus } from "@/lib/queries"
|
|
import type { Health, SystemStatus } from "@/lib/api"
|
|
import { useMetricsFeeder } from "@/lib/metricsStore"
|
|
import { useEventStream } from "@/lib/events"
|
|
import { cn } from "@/lib/utils"
|
|
import { CockpitView } from "@/views/cockpit/CockpitView"
|
|
|
|
// 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 IdeenView = lazy(() => import("@/views/IdeenView").then((m) => ({ default: m.IdeenView })))
|
|
const ChronikView = lazy(() => import("@/views/ChronikView").then((m) => ({ default: m.ChronikView })))
|
|
const EigenlebenView = lazy(() => import("@/views/EigenlebenView").then((m) => ({ default: m.EigenlebenView })))
|
|
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
|
|
useEventStream() // SSE-Invalidation-Bus (P3a): sofortige Updates, Poller nur Netz
|
|
// View ist im URL-Hash (#models) verankert → Reload/Teilen/Cmd-Klick funktionieren.
|
|
const [view, setView] = useState<ViewId>(() => {
|
|
const h = window.location.hash.slice(1) as ViewId
|
|
return NAV.some((n) => n.id === h) ? h : "dashboard"
|
|
})
|
|
const navigate = useCallback((v: ViewId) => {
|
|
if (window.location.hash.slice(1) === v) setView(v)
|
|
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")
|
|
|
|
// isError mitnehmen: React Query behält bei Fehlern die letzten Daten — ohne den
|
|
// Fehler-Blick zeigte die Sidebar bei totem Backend ewig den letzten grünen Stand.
|
|
const { data: health, isError: backendDown } = useHealth()
|
|
const { data: sysStatus } = useSystemStatus()
|
|
|
|
useEffect(() => {
|
|
document.documentElement.classList.add("dark")
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const handleOpen = (e: Event) => {
|
|
const customEvent = e as CustomEvent
|
|
setDrawerTab(customEvent.detail?.tab || "maintenance")
|
|
setDrawerOpen(true)
|
|
}
|
|
window.addEventListener("open-system-drawer", handleOpen)
|
|
return () => window.removeEventListener("open-system-drawer", handleOpen)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const handleNav = (e: Event) => {
|
|
const v = (e as CustomEvent).detail?.view as ViewId | undefined
|
|
if (v) navigate(v)
|
|
}
|
|
window.addEventListener("mc-navigate", handleNav)
|
|
return () => window.removeEventListener("mc-navigate", handleNav)
|
|
}, [navigate])
|
|
|
|
useEffect(() => {
|
|
const onHash = () => {
|
|
const h = window.location.hash.slice(1) as ViewId
|
|
if (NAV.some((n) => n.id === h)) setView(h)
|
|
}
|
|
window.addEventListener("hashchange", onHash)
|
|
return () => window.removeEventListener("hashchange", onHash)
|
|
}, [])
|
|
|
|
// Mobile-Navigation schließt nach jedem View-Wechsel (ein Tipp = ein Ziel).
|
|
useEffect(() => { setMobileNavOpen(false) }, [view])
|
|
|
|
const active = NAV.find((n) => n.id === view)!
|
|
|
|
return (
|
|
<div className="flex h-full relative">
|
|
{/* 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 (Desktop, ab md) */}
|
|
<aside className={cn(
|
|
"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"
|
|
)}>
|
|
<SidebarContent
|
|
collapsed={sidebarCollapsed}
|
|
onToggleCollapse={() => {
|
|
setSidebarCollapsed((c) => {
|
|
const next = !c
|
|
localStorage.setItem("mc_sidebar_collapsed", next.toString())
|
|
return next
|
|
})
|
|
}}
|
|
view={view}
|
|
health={health}
|
|
backendDown={backendDown}
|
|
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}
|
|
backendDown={backendDown}
|
|
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 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="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
|
|
</a>
|
|
|
|
<button
|
|
onClick={() => {
|
|
const ev = new KeyboardEvent("keydown", { key: "k", metaKey: true })
|
|
document.dispatchEvent(ev)
|
|
}}
|
|
className="flex items-center gap-2 rounded-md border border-border/40 bg-background/40 px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-all cursor-pointer"
|
|
>
|
|
<CommandIcon className="h-3.5 w-3.5" />
|
|
<span 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-4 md:p-6 scrollbar-thin">
|
|
<Suspense fallback={<ViewLoading />}>
|
|
{view === "dashboard" && <CockpitView onNavigate={(v) => navigate(v as ViewId)} />}
|
|
{view === "ideen" && <IdeenView />}
|
|
{view === "auftraege" && <AuftragsbuchView />}
|
|
{view === "models" && <ModelsView />}
|
|
{view === "connect" && <ConnectView />}
|
|
{view === "memory" && <MemoryView />}
|
|
{view === "wissen" && <WissenView />}
|
|
{view === "chronik" && <ChronikView />}
|
|
{view === "eigenleben" && <EigenlebenView />}
|
|
{view === "agent" && <AgentView />}
|
|
{view === "konsole" && <KonsoleView />}
|
|
{view === "guide" && <GuideView />}
|
|
</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, backendDown, sysStatus,
|
|
}: {
|
|
collapsed: boolean
|
|
onToggleCollapse?: () => void
|
|
onClose?: () => void
|
|
view: ViewId
|
|
health: Health | undefined
|
|
backendDown: boolean
|
|
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",
|
|
backendDown || !health
|
|
? "bg-red-500"
|
|
: (!health.engine_reachable ? "bg-amber-500"
|
|
: (health.brain && !health.brain.ready ? "bg-amber-500 animate-pulse" : "bg-emerald-500 animate-pulse"))
|
|
)} title={backendDown
|
|
? "Zentrale antwortet nicht — Anzeigen evtl. veraltet"
|
|
: 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">
|
|
{backendDown ? (
|
|
<span className="flex items-center gap-2 text-red-400">
|
|
<span className="h-2 w-2 rounded-full bg-red-500 animate-pulse" />
|
|
<span className="truncate">Zentrale antwortet nicht</span>
|
|
</span>
|
|
) : 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>
|
|
</>
|
|
)
|
|
}
|