umbau(boxwart): neue Oberflaeche im Cockpit-Stil (Richtung A), Werkzeuge angehoben
Drei Seiten statt zehn: Start, Updates, Modelle — am PC mit Kopfnavigation, am Handy mit Leiste unten. Umsetzung von Mockup A („Cockpit“), vom User am 23.09. gewaehlt. - Start: Hauptwarnleuchte + 8 Warnlampen, Rundinstrumente mit Live-Werten aus dem Strom (Speicher, Temperatur, Platte) und Laufzeit-Zaehlwerk, Checkliste der Waechter-Hinweise mit ihren Knoepfen, Flugplan (heute gelaufen / geplant), Radar-Kasten. - Updates: Bausteine mit „Laeuft → Neu“ und Zusammenfassung, laufende Auftraege, Verlauf der Sonntagslaeufe (neu: GET /api/updates/verlauf), Sicherungen samt Zurueckspielen mit Rueckfrage. - Modelle: Speicherbalken, Rollen Hirn/Coder/Dritte Rolle, wer die Modelle nutzt (7 Tage + 24 h je Stunde), Modell-Radar, weitere Eintraege, Modelle selbst suchen. - Schubladen: Dienste mit Protokoll und Neustart, Einstellungen (HF-Zugang), Hermes-Link. - Werkzeuge: Vite 8, React 19.3 mit React Compiler 1.0 (Babel), vitest 5, Tailwind 4.3, shadcn 4 (Radix) fuer Dialog/Schublade/Knopf, Schriften Barlow/Barlow Condensed/ JetBrains Mono. Entfernt: recharts, cmdk, Kraftgraph, zustand, Inter, Space Grotesk. - Startbuendel 115 KB gzip (Budget 140); Updates/Modelle/Schubladen laden bei Bedarf. - 16 Oberflaechen-Tests (Instrument-Bogen, Hauptleuchte, Zeitformate, Versionen). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3d2c9549fb
commit
907289d7dc
@@ -1,382 +0,0 @@
|
||||
import { useEffect, useState, useCallback, Suspense, lazy } from "react"
|
||||
import { Link, Outlet, useNavigate, useRouterState } from "@tanstack/react-router"
|
||||
import { Command as CommandIcon, ChevronLeft, ChevronRight, Loader2, Menu, X } from "lucide-react"
|
||||
import { NAV, navFuerPfad } from "@/nav"
|
||||
import { ExpertToggle } from "@/components/ExpertToggle"
|
||||
|
||||
import { useHealth, useSystemStatus } from "@/lib/queries"
|
||||
import type { Health } from "@/lib/api"
|
||||
import { useMetricsFeeder } from "@/lib/metricsStore"
|
||||
import { useEventStream } from "@/lib/events"
|
||||
import { useMC, usePaletteOffen, useSchieneEingeklappt } from "@/app/store"
|
||||
import { Statusleiste } from "@/app/shell/Statusleiste"
|
||||
import { Meldungen } from "@/app/shell/Meldungen"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Beide sind beim Start UNSICHTBAR und gehoeren deshalb nicht ins Start-Buendel (P2):
|
||||
// die Schublade zieht Wartung, Logs, Einstellungen und den Update-Dialog mit, die Palette
|
||||
// bringt cmdk mit. Zusammen waren das rund 30 kB gzip, die niemand beim ersten Bild braucht.
|
||||
// Der Router kostete P2 ungefaehr dasselbe — so bleibt das Buendel-Budget der Ampel scharf,
|
||||
// statt es einfach hochzusetzen.
|
||||
const SystemDrawer = lazy(() => import("@/components/SystemDrawer").then((m) => ({ default: m.SystemDrawer })))
|
||||
const CommandPalette = lazy(() => import("@/components/CommandPalette").then((m) => ({ default: m.CommandPalette })))
|
||||
|
||||
// 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)
|
||||
|
||||
/**
|
||||
* Die Shell: Schiene, Kopfzeile, System-Schublade — alles, was über JEDER Ansicht liegt.
|
||||
*
|
||||
* v3-Umbau P2 (28.08.2026): Hier stand vorher der Ansichts-Umschalter
|
||||
* (`{view === "ideen" && <IdeenView />}`), gespeist aus einem `useState`, das am URL-Hash
|
||||
* hing. Das funktionierte für Reload und Lesezeichen — aber nur auf View-Ebene. Jetzt
|
||||
* rendert der Router in den `<Outlet/>`, und der Zustand DARUNTER (offene Idee, Reiter,
|
||||
* Schublade) liegt in echten Suchparametern. Siehe app/router.tsx.
|
||||
*/
|
||||
export function Shell() {
|
||||
useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab
|
||||
useEventStream() // SSE-Invalidation-Bus (P3a): sofortige Updates, Poller nur Netz
|
||||
|
||||
const navigate = useNavigate()
|
||||
const pfad = useRouterState({ select: (s) => s.location.pathname })
|
||||
// Die Schublade liegt über allem und gehört deshalb in die Wurzel-Suche (?system=).
|
||||
// Vorher war sie lokaler State und wurde per CustomEvent von außen aufgestoßen —
|
||||
// nicht teilbar, nicht reload-fest.
|
||||
const drawerTab = useRouterState({
|
||||
select: (s) => (s.location.search as { system?: "wartung" | "logs" }).system,
|
||||
})
|
||||
|
||||
// v3-Umbau P3: Schienen-Zustand und Palette liegen jetzt im Store (Befund B-08/B-09).
|
||||
// Vorher: ein localStorage-Zugriff mitten in useState plus ein GEFAELSCHTES
|
||||
// KeyboardEvent, das der Kopfzeilen-Knopf aufs Dokument feuerte.
|
||||
const schieneEingeklappt = useSchieneEingeklappt()
|
||||
const schieneUmschalten = useMC((st) => st.schieneUmschalten)
|
||||
const paletteOffen = usePaletteOffen()
|
||||
const palette = useMC((st) => st.palette)
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
||||
// Die Palette wird beim ERSTEN Ruf geladen und bleibt dann gemountet — sie haelt ihren
|
||||
// Inhalt und soll nicht bei jedem Strg+K neu nachgeladen werden.
|
||||
const [paletteBereit, setPaletteBereit] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (paletteOffen) setPaletteBereit(true)
|
||||
}, [paletteOffen])
|
||||
|
||||
useEffect(() => {
|
||||
const taste = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault()
|
||||
palette(!useMC.getState().ui.paletteOffen)
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", taste)
|
||||
return () => document.removeEventListener("keydown", taste)
|
||||
}, [palette])
|
||||
|
||||
const schubladeSetzen = useCallback((tab: "wartung" | "logs" | undefined) => {
|
||||
navigate({ to: ".", search: (alt) => ({ ...alt, system: tab }), replace: true })
|
||||
}, [navigate])
|
||||
|
||||
// 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()
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add("dark")
|
||||
}, [])
|
||||
|
||||
// Hier hingen bis P2 zwei CustomEvent-Zuhörer (`open-system-drawer`, `mc-navigate`),
|
||||
// über die andere Ansichten die Shell fernsteuerten. In P3 sind sie ersatzlos weg:
|
||||
// Wer die Schublade öffnen will, setzt `?system=`; wer springen will, navigiert. Der
|
||||
// Zustand liegt damit da, wo man ihn sehen und teilen kann (Befund B-08).
|
||||
|
||||
// Mobile-Navigation schließt nach jedem View-Wechsel (ein Tipp = ein Ziel).
|
||||
useEffect(() => { setMobileNavOpen(false) }, [pfad])
|
||||
|
||||
const active = navFuerPfad(pfad)
|
||||
|
||||
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(", "),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Beilaeufige Rueckmeldungen — EINE Stelle, und endlich mit aria-live (B-11). */}
|
||||
<Meldungen />
|
||||
|
||||
{/* Erst laden, wenn wirklich gebraucht — ohne Platzhalter, beide sind Overlays. */}
|
||||
<Suspense fallback={null}>
|
||||
{paletteBereit && <CommandPalette />}
|
||||
{drawerTab && (
|
||||
<SystemDrawer
|
||||
open
|
||||
onClose={() => schubladeSetzen(undefined)}
|
||||
defaultTab={drawerTab === "logs" ? "logs" : "maintenance"}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
|
||||
{/* 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",
|
||||
schieneEingeklappt ? "w-16" : "w-60"
|
||||
)}>
|
||||
<SidebarContent
|
||||
collapsed={schieneEingeklappt}
|
||||
onToggleCollapse={schieneUmschalten}
|
||||
pfad={pfad}
|
||||
health={health}
|
||||
backendDown={backendDown}
|
||||
/>
|
||||
</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)}
|
||||
pfad={pfad}
|
||||
health={health}
|
||||
backendDown={backendDown}
|
||||
/>
|
||||
</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={() => palette(true)}
|
||||
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>
|
||||
|
||||
{/* Bahnbreiten-Deckel (Befund B-10): Ohne ihn zieht sich das Cockpit auf einem
|
||||
3440-px-Ultrawide auf ueber 3 000 px Breite; die Wurzel-Schriftgroesse waechst
|
||||
dabei mit und macht die Zeilen GROESSER statt lesbarer. 1600 px sind die Grenze,
|
||||
ab der eine Zeile nicht mehr am Stueck ueberblickt wird — der Rest bleibt Rand. */}
|
||||
<main className="flex-1 overflow-y-auto scrollbar-thin">
|
||||
<div className="mx-auto w-full max-w-[1600px] p-4 md:p-6">
|
||||
<Suspense fallback={<ViewLoading />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Zeigt den Zustand der Box IMMER, ohne den Arbeitsbereich zu blockieren (§4.1). */}
|
||||
<Statusleiste />
|
||||
</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, pfad, health, backendDown,
|
||||
}: {
|
||||
collapsed: boolean
|
||||
onToggleCollapse?: () => void
|
||||
onClose?: () => void
|
||||
pfad: string
|
||||
health: Health | undefined
|
||||
backendDown: boolean
|
||||
}) {
|
||||
const aktiv = navFuerPfad(pfad)
|
||||
// Kein Prop-Drilling mehr: derselbe Query-Key, also dieselbe Anfrage — TanStack Query
|
||||
// dedupliziert. Die Versionen stehen bewusst hier unten und nicht in der Statusleiste:
|
||||
// Man schaut sie selten an, und die Leiste soll knapp bleiben.
|
||||
const { data: sysStatus } = useSystemStatus()
|
||||
|
||||
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
|
||||
const istAktiv = aktiv?.id === item.id
|
||||
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>
|
||||
)
|
||||
)}
|
||||
<Link
|
||||
to={item.pfad}
|
||||
onClick={onClose}
|
||||
aria-current={istAktiv ? "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",
|
||||
istAktiv
|
||||
? (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>}
|
||||
</Link>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { ExternalLink, ScrollText, Settings } from "lucide-react"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
|
||||
/** Handy-Menü „Mehr“: Hermes, Dienste, Einstellungen. Eigene Datei, damit es erst beim Öffnen lädt. */
|
||||
export function MehrMenue({ verbindung, onDienste, onEinstellungen, onSchliessen }: {
|
||||
verbindung: ReactNode
|
||||
onDienste: () => void
|
||||
onEinstellungen: () => void
|
||||
onSchliessen: () => void
|
||||
}) {
|
||||
return (
|
||||
<Sheet open onOpenChange={(o) => !o && onSchliessen()}>
|
||||
<SheetContent side="bottom" className="border-linie bg-panel pb-[calc(1rem+env(safe-area-inset-bottom))]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="schild text-lg">Mehr</SheetTitle>
|
||||
<SheetDescription className="sr-only">Weitere Bereiche</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col gap-2 px-4">
|
||||
{verbindung}
|
||||
<a href="/hermes-ui/" target="_blank" rel="noopener" className="flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base">
|
||||
<ExternalLink className="size-5" aria-hidden /> Hermes-Dashboard öffnen
|
||||
</a>
|
||||
<button type="button" onClick={onDienste} className="flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base">
|
||||
<ScrollText className="size-5" aria-hidden /> Dienste und Protokolle
|
||||
</button>
|
||||
<button type="button" onClick={onEinstellungen} className="flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base">
|
||||
<Settings className="size-5" aria-hidden /> Einstellungen
|
||||
</button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { X } from "lucide-react"
|
||||
import { cn } from "cn"
|
||||
import { entfernen, useMeldungen } from "@/lib/meldungen"
|
||||
|
||||
const FARBE = {
|
||||
erfolg: "border-gruen-rand bg-gruen-grund text-gruen-text",
|
||||
fehler: "border-rot-rand bg-rot-grund text-rot-text",
|
||||
info: "border-cyan-rand bg-cyan-grund text-cyan-text",
|
||||
}
|
||||
|
||||
/** Rückmeldungen unten rechts (am Handy über der Tab-Leiste), für Screenreader angesagt. */
|
||||
export function Meldungen() {
|
||||
const liste = useMeldungen()
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="pointer-events-none fixed right-4 bottom-24 left-4 z-50 flex flex-col items-end gap-2 md:bottom-6 md:left-auto md:w-[420px]"
|
||||
>
|
||||
{liste.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
role={m.art === "fehler" ? "alert" : "status"}
|
||||
className={cn("pointer-events-auto flex w-full items-start gap-3 rounded-xl border px-4 py-3 text-[15px] shadow-lg", FARBE[m.art])}
|
||||
>
|
||||
<span className="min-w-0 flex-1">{m.text}</span>
|
||||
<button type="button" onClick={() => entfernen(m.id)} aria-label="Meldung schließen" className="-m-2 p-2 opacity-70 hover:opacity-100">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react"
|
||||
import { Link, Outlet } from "@tanstack/react-router"
|
||||
import { Cpu, Ellipsis, ExternalLink, House, RefreshCw, ScrollText, Settings } from "lucide-react"
|
||||
import { cn } from "cn"
|
||||
import { useStrom, useStromLage } from "@/lib/strom"
|
||||
import { Meldungen } from "./Meldungen"
|
||||
|
||||
// Schubladen werden erst beim Öffnen geladen — sie gehören nicht zum Normalfall, und die
|
||||
// Radix-Dialog-Kette (Fokus-Falle, Scroll-Sperre) sprengte sonst das 140-KB-Budget.
|
||||
const Dienste = lazy(() => import("@/views/Dienste").then((m) => ({ default: m.Dienste })))
|
||||
const Einstellungen = lazy(() => import("@/views/Einstellungen").then((m) => ({ default: m.Einstellungen })))
|
||||
const MehrMenue = lazy(() => import("./MehrMenue").then((m) => ({ default: m.MehrMenue })))
|
||||
|
||||
const NAV = [
|
||||
{ to: "/", label: "Start", Icon: House },
|
||||
{ to: "/updates", label: "Updates", Icon: RefreshCw },
|
||||
{ to: "/modelle", label: "Modelle", Icon: Cpu },
|
||||
] as const
|
||||
|
||||
const uhrzeit = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin" })
|
||||
const datum = new Intl.DateTimeFormat("de-DE", { weekday: "short", day: "2-digit", month: "2-digit", timeZone: "Europe/Berlin" })
|
||||
|
||||
function Uhr() {
|
||||
const [jetzt, setJetzt] = useState(() => new Date())
|
||||
useEffect(() => {
|
||||
const t = window.setInterval(() => setJetzt(new Date()), 20_000)
|
||||
return () => window.clearInterval(t)
|
||||
}, [])
|
||||
return (
|
||||
<div className="flex flex-col items-end leading-tight">
|
||||
<span className="ziffern text-2xl font-medium">{uhrzeit.format(jetzt)}</span>
|
||||
<span className="schild text-[13px] text-text-3">{datum.format(jetzt).replace(",", "")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Verbindung() {
|
||||
const lage = useStromLage()
|
||||
const live = lage === "live"
|
||||
return (
|
||||
<div className="schild flex items-center gap-2 text-[15px] text-text-3" aria-live="polite">
|
||||
<span
|
||||
className={cn("size-2 rounded-full", live ? "bg-gruen shadow-[0_0_8px_rgba(61,220,138,0.7)]" : "bg-bernstein")}
|
||||
/>
|
||||
<span>{live ? "Box online" : lage === "verbinde" ? "Verbinde" : "Getrennt"}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Marke() {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" aria-hidden>
|
||||
<rect x="1.5" y="1.5" width="31" height="31" rx="8" stroke="var(--linie-stark)" strokeWidth="2" />
|
||||
<path d="M7 18h5l3-7 4 13 3-6h5" stroke="var(--bernstein)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span className="font-anzeige text-[28px] leading-none font-bold tracking-[0.06em]">MC2</span>
|
||||
<span className="hidden h-6 w-px bg-linie sm:block" />
|
||||
<span className="schild hidden text-base tracking-[0.22em] text-text-3 sm:block">Box-Wart</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type Schublade = "dienste" | "einstellungen" | "mehr" | null
|
||||
|
||||
export function Shell() {
|
||||
useStrom()
|
||||
const [schublade, setSchublade] = useState<Schublade>(null)
|
||||
const zu = () => setSchublade(null)
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-dvh w-full max-w-[1440px] flex-col gap-5 px-4 pt-4 pb-28 sm:px-6 md:gap-6 md:px-10 md:pt-8 md:pb-10">
|
||||
<header className="flex items-center justify-between gap-4 md:gap-6">
|
||||
<Marke />
|
||||
<nav aria-label="Hauptnavigation" className="hidden gap-1 rounded-xl border border-linie bg-[#121518] p-1 md:flex">
|
||||
{NAV.map(({ to, label }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className="schild flex h-11 items-center rounded-[9px] border border-transparent px-5 text-base text-text-3 hover:text-foreground"
|
||||
activeProps={{ className: "!border-linie-stark bg-erhaben !text-foreground" }}
|
||||
activeOptions={{ exact: true }}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="flex items-center gap-4 md:gap-5">
|
||||
<div className="hidden lg:block">
|
||||
<Verbindung />
|
||||
</div>
|
||||
<div className="hidden items-center gap-1 md:flex">
|
||||
<a
|
||||
href="/hermes-ui/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="flex size-11 items-center justify-center rounded-lg text-text-2 hover:bg-erhaben hover:text-foreground"
|
||||
title="Hermes-Dashboard öffnen"
|
||||
aria-label="Hermes-Dashboard öffnen"
|
||||
>
|
||||
<ExternalLink className="size-5" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSchublade("dienste")}
|
||||
className="flex size-11 items-center justify-center rounded-lg text-text-2 hover:bg-erhaben hover:text-foreground"
|
||||
title="Dienste und Protokolle"
|
||||
aria-label="Dienste und Protokolle"
|
||||
>
|
||||
<ScrollText className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSchublade("einstellungen")}
|
||||
className="flex size-11 items-center justify-center rounded-lg text-text-2 hover:bg-erhaben hover:text-foreground"
|
||||
title="Einstellungen"
|
||||
aria-label="Einstellungen"
|
||||
>
|
||||
<Settings className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<Uhr />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex min-w-0 flex-col gap-5 md:gap-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Handy: Leiste unten, mit Abstand zur Wisch-Leiste des Telefons. */}
|
||||
<nav
|
||||
aria-label="Hauptnavigation"
|
||||
className="fixed inset-x-0 bottom-0 z-40 grid grid-cols-4 gap-1 border-t border-linie bg-[#0e1119]/95 px-2 pt-2 pb-[calc(0.75rem+env(safe-area-inset-bottom))] backdrop-blur md:hidden"
|
||||
>
|
||||
{NAV.map(({ to, label, Icon }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className="schild flex h-14 flex-col items-center justify-center gap-1 rounded-lg text-xs text-text-3"
|
||||
activeProps={{ className: "bg-erhaben !text-foreground" }}
|
||||
activeOptions={{ exact: true }}
|
||||
>
|
||||
<Icon className="size-5" aria-hidden />
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSchublade("mehr")}
|
||||
className="schild flex h-14 flex-col items-center justify-center gap-1 rounded-lg text-xs text-text-3"
|
||||
>
|
||||
<Ellipsis className="size-5" aria-hidden />
|
||||
Mehr
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
{schublade === "mehr" && (
|
||||
<MehrMenue
|
||||
verbindung={<Verbindung />}
|
||||
onDienste={() => setSchublade("dienste")}
|
||||
onEinstellungen={() => setSchublade("einstellungen")}
|
||||
onSchliessen={zu}
|
||||
/>
|
||||
)}
|
||||
{schublade === "dienste" && <Dienste offen onSchliessen={zu} />}
|
||||
{schublade === "einstellungen" && <Einstellungen offen onSchliessen={zu} />}
|
||||
</Suspense>
|
||||
<Meldungen />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { befehle, suchtext, type BefehlsKontext } from "./register"
|
||||
import { NAV } from "@/nav"
|
||||
|
||||
function kontext(ueberschreiben: Partial<BefehlsKontext> = {}): BefehlsKontext {
|
||||
return {
|
||||
text: "",
|
||||
navigate: vi.fn(),
|
||||
schublade: vi.fn(),
|
||||
melden: vi.fn(),
|
||||
api: vi.fn(async () => ({}) as never),
|
||||
...ueberschreiben,
|
||||
}
|
||||
}
|
||||
|
||||
describe("Befehls-Register", () => {
|
||||
it("führt jedes Navigations-Ziel auf — genau einmal", () => {
|
||||
const ziele = befehle("").filter((b) => b.gruppe === "gehe")
|
||||
expect(ziele).toHaveLength(NAV.length)
|
||||
expect(new Set(ziele.map((z) => z.id)).size).toBe(NAV.length)
|
||||
})
|
||||
|
||||
it("zeigt Suche und „An Lucy“ erst, wenn etwas getippt ist", () => {
|
||||
const leer = befehle("")
|
||||
expect(leer.some((b) => b.gruppe === "finden")).toBe(false)
|
||||
expect(leer.some((b) => b.gruppe === "lucy")).toBe(false)
|
||||
|
||||
const getippt = befehle("Wissensgraph-Ansicht bauen")
|
||||
expect(getippt.filter((b) => b.gruppe === "finden")).toHaveLength(2)
|
||||
expect(getippt.filter((b) => b.gruppe === "lucy")).toHaveLength(1)
|
||||
})
|
||||
|
||||
// Unter acht Zeichen ist es kein Auftrag, sondern ein Tippfehler auf dem Weg zu
|
||||
// einem Sprungziel. Sonst böte die Palette bei „mod" an, daraus eine Idee zu machen.
|
||||
it("bietet kurze Eingaben NICHT als Idee an", () => {
|
||||
expect(befehle("mod").some((b) => b.gruppe === "lucy")).toBe(false)
|
||||
expect(befehle("modelle").some((b) => b.gruppe === "lucy")).toBe(false)
|
||||
expect(befehle("modellwechsel").some((b) => b.gruppe === "lucy")).toBe(true)
|
||||
})
|
||||
|
||||
// ── Die Sicherheitsregel ──────────────────────────────────────────────────
|
||||
// Die Palette ist schnell genug, dass „Strg+K, mot, Enter" einen Dienst neu startet,
|
||||
// bevor man es zu Ende gedacht hat. Alles Eingreifende MUSS deshalb `rueckfrage`
|
||||
// tragen — die Oberfläche hält dann an und sagt erst, was passieren wird.
|
||||
it("verlangt für jeden eingreifenden Befehl eine Rückfrage", () => {
|
||||
const eingreifend = /neu starten|löschen|loeschen|deploy|zurücksetzen|reboot|entfernen/i
|
||||
for (const b of befehle("etwas getipptes")) {
|
||||
if (eingreifend.test(b.label)) {
|
||||
expect(b.rueckfrage, `„${b.label}" muss nachfragen`).toBeTruthy()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("nennt in der Rückfrage die Folge, nicht nur die Aktion", () => {
|
||||
const motor = befehle("").find((b) => b.id === "tun:engine-neustart")!
|
||||
expect(motor.rueckfrage).toBeTruthy()
|
||||
// Der Text muss sagen, was der Nutzer DANACH merkt.
|
||||
expect(motor.rueckfrage).toMatch(/Modelle/)
|
||||
expect(motor.rueckfrage).toMatch(/aufwärmen|abbrechen|abbrechen/i)
|
||||
})
|
||||
|
||||
it("lässt harmlose Befehle ohne Rückfrage durch", () => {
|
||||
const wartung = befehle("").find((b) => b.id === "tun:wartung")!
|
||||
expect(wartung.rueckfrage).toBeUndefined()
|
||||
})
|
||||
|
||||
// ── Verhalten einzelner Befehle ───────────────────────────────────────────
|
||||
it("öffnet die Schublade über die URL, nicht über ein Ereignis", () => {
|
||||
const ctx = kontext()
|
||||
befehle("").find((b) => b.id === "tun:logs")!.ausfuehren(ctx)
|
||||
expect(ctx.schublade).toHaveBeenCalledWith("logs")
|
||||
})
|
||||
|
||||
it("erfasst eine Idee zum Durchdenken — und startet keinen Bau", async () => {
|
||||
const ctx = kontext({ text: "Wissensgraph-Ansicht bauen" })
|
||||
await befehle(ctx.text).find((b) => b.gruppe === "lucy")!.ausfuehren(ctx)
|
||||
|
||||
expect(ctx.api).toHaveBeenCalledTimes(1)
|
||||
const [pfad, init] = (ctx.api as ReturnType<typeof vi.fn>).mock.calls[0]
|
||||
expect(pfad).toBe("/api/ideen")
|
||||
const rumpf = JSON.parse((init as RequestInit).body as string)
|
||||
// `art: "idee"` heisst durchdenken. `"projekt"` wuerde sofort ein Repo anlegen —
|
||||
// das darf die Palette niemals aus einem Tastendruck heraus tun.
|
||||
expect(rumpf.art).toBe("idee")
|
||||
expect(rumpf.titel).toBe("Wissensgraph-Ansicht bauen")
|
||||
})
|
||||
|
||||
it("kürzt einen überlangen Ideen-Titel und hebt den vollen Text in die Notiz", async () => {
|
||||
const lang = "a".repeat(200)
|
||||
const ctx = kontext({ text: lang })
|
||||
await befehle(lang).find((b) => b.gruppe === "lucy")!.ausfuehren(ctx)
|
||||
const rumpf = JSON.parse(
|
||||
((ctx.api as ReturnType<typeof vi.fn>).mock.calls[0][1] as RequestInit).body as string,
|
||||
)
|
||||
expect(rumpf.titel.length).toBeLessThanOrEqual(160)
|
||||
expect(rumpf.notiz).toBe(lang)
|
||||
})
|
||||
|
||||
it("packt Suchwörter in den Suchtext, damit cmdk sie findet", () => {
|
||||
const wartung = befehle("").find((b) => b.id === "tun:wartung")!
|
||||
expect(suchtext(wartung)).toMatch(/neustart/) // steht nur in den Stichworten
|
||||
expect(suchtext(wartung)).toMatch(/System-Wartung/)
|
||||
})
|
||||
})
|
||||
@@ -1,194 +0,0 @@
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import {
|
||||
Camera, CloudDownload, FileText, Lightbulb, RefreshCw, ScrollText, Search, Wrench,
|
||||
} from "lucide-react"
|
||||
import { NAV } from "@/nav"
|
||||
|
||||
// Befehls-Register der Palette (v3-Umbau P6, Spezifikation §4.6).
|
||||
//
|
||||
// Bis hierher listete die Palette die elf Navigations-Einträge — sie war ein Sprungbrett.
|
||||
// Jetzt ist sie das primäre Bedienwerkzeug: Man tippt, was man will, nicht wo es liegt.
|
||||
//
|
||||
// VIER KATEGORIEN, in der Reihenfolge, in der man sie braucht:
|
||||
// Gehe zu Sprungziele (wie bisher)
|
||||
// Tun Aktionen — Dienste, Modelle, Wartung
|
||||
// Finden Suche in Chronik und Wissens-Vault
|
||||
// An Lucy den getippten Text direkt als Idee erfassen
|
||||
//
|
||||
// SICHERHEITSREGEL (aus der Spezifikation, hier hart verdrahtet): Alles, was neu startet,
|
||||
// löscht oder deployt, trägt `rueckfrage` und wird zweistufig bestätigt. Die Palette ist
|
||||
// zu schnell für einen unbedachten Treffer — zwei Tastendrücke und ein Dienst wäre weg.
|
||||
|
||||
export type BefehlsGruppe = "gehe" | "tun" | "finden" | "lucy"
|
||||
|
||||
export interface Befehl {
|
||||
id: string
|
||||
gruppe: BefehlsGruppe
|
||||
label: string
|
||||
hinweis?: string
|
||||
icon: LucideIcon
|
||||
/** Zusätzliche Wörter für die Suche — was der Nutzer tippen könnte, aber nicht dasteht. */
|
||||
stichworte?: string
|
||||
/** Gesetzt = zweistufige Rückfrage mit diesem Text, bevor irgendetwas passiert. */
|
||||
rueckfrage?: string
|
||||
/** Was der Befehl tut. `text` ist die aktuelle Eingabe der Palette. */
|
||||
ausfuehren: (ctx: BefehlsKontext) => void | Promise<void>
|
||||
}
|
||||
|
||||
export interface BefehlsKontext {
|
||||
text: string
|
||||
navigate: (pfad: string) => void
|
||||
schublade: (tab: "wartung" | "logs") => void
|
||||
melden: (art: "info" | "erfolg" | "warnung" | "fehler", text: string) => void
|
||||
api: <T>(pfad: string, init?: RequestInit) => Promise<T>
|
||||
}
|
||||
|
||||
export const GRUPPEN_TITEL: Record<BefehlsGruppe, string> = {
|
||||
gehe: "Gehe zu",
|
||||
tun: "Tun",
|
||||
finden: "Finden",
|
||||
lucy: "An Lucy",
|
||||
}
|
||||
|
||||
/** Sprungziele — direkt aus der Navigations-Tabelle, damit es nie zwei Listen gibt. */
|
||||
const ZIELE: Befehl[] = NAV.map((n) => ({
|
||||
id: `gehe:${n.id}`,
|
||||
gruppe: "gehe" as const,
|
||||
label: n.label,
|
||||
hinweis: n.hint,
|
||||
icon: n.icon,
|
||||
ausfuehren: ({ navigate }) => navigate(n.pfad),
|
||||
}))
|
||||
|
||||
/** Aktionen. Bewusst wenige: nur was man wirklich aus dem Stand heraus tut. */
|
||||
const AKTIONEN: Befehl[] = [
|
||||
{
|
||||
id: "tun:wartung",
|
||||
gruppe: "tun",
|
||||
label: "System-Wartung öffnen",
|
||||
hinweis: "Dienste, Updates, Snapshot",
|
||||
icon: Wrench,
|
||||
stichworte: "pflege dienste neustart update",
|
||||
ausfuehren: ({ schublade }) => schublade("wartung"),
|
||||
},
|
||||
{
|
||||
id: "tun:logs",
|
||||
gruppe: "tun",
|
||||
label: "System-Logs öffnen",
|
||||
hinweis: "Diagnose je Dienst",
|
||||
icon: ScrollText,
|
||||
stichworte: "fehler journal diagnose",
|
||||
ausfuehren: ({ schublade }) => schublade("logs"),
|
||||
},
|
||||
{
|
||||
id: "tun:updates",
|
||||
gruppe: "tun",
|
||||
label: "Nach Updates suchen",
|
||||
hinweis: "apt + Engine + Agent prüfen",
|
||||
icon: CloudDownload,
|
||||
stichworte: "aktualisieren apt engine",
|
||||
ausfuehren: async ({ api, melden }) => {
|
||||
await api("/api/maintenance/check-updates", { method: "POST" })
|
||||
melden("info", "Update-Suche läuft — das Ergebnis erscheint in der Wartung.")
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tun:snapshot",
|
||||
gruppe: "tun",
|
||||
label: "Snapshot anlegen",
|
||||
hinweis: "Sicherung vor riskanten Schritten",
|
||||
icon: Camera,
|
||||
stichworte: "backup sicherung zeitmaschine",
|
||||
ausfuehren: async ({ api, melden }) => {
|
||||
await api("/api/system/backup", { method: "POST" })
|
||||
melden("erfolg", "Snapshot angestoßen.")
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tun:engine-neustart",
|
||||
gruppe: "tun",
|
||||
label: "Motor neu starten",
|
||||
hinweis: "llama-swap — alle Modelle fallen kurz weg",
|
||||
icon: RefreshCw,
|
||||
stichworte: "engine llama swap modell neu starten",
|
||||
// Zweistufig: Ein Neustart wirft jedes geladene Modell aus dem Speicher, und ein
|
||||
// grosses Modell braucht danach ueber 20 Sekunden zum Aufwaermen.
|
||||
rueckfrage:
|
||||
"Der Motor (llama-swap) wird neu gestartet. Alle geladenen Modelle fallen dabei aus " +
|
||||
"dem Speicher und müssen neu aufwärmen — das dauert je nach Modell über 20 Sekunden. " +
|
||||
"Laufende Antworten brechen ab.",
|
||||
ausfuehren: async ({ api, melden }) => {
|
||||
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ service: "llama-swap" }),
|
||||
})
|
||||
melden(r.ok ? "erfolg" : "fehler",
|
||||
r.ok ? "Motor neu gestartet." : `Neustart fehlgeschlagen: ${r.err || "Unbekannter Fehler"}`)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/** Suchen — führen in die jeweilige Ansicht, mit dem Suchwort in der URL. */
|
||||
const SUCHEN = (text: string): Befehl[] => {
|
||||
const q = text.trim()
|
||||
if (!q) return []
|
||||
return [
|
||||
{
|
||||
id: "finden:chronik",
|
||||
gruppe: "finden",
|
||||
label: `„${q}“ in der Chronik`,
|
||||
hinweis: "Was die Box getan hat",
|
||||
icon: Search,
|
||||
ausfuehren: ({ navigate }) => navigate(`/chronik?q=${encodeURIComponent(q)}`),
|
||||
},
|
||||
{
|
||||
id: "finden:wissen",
|
||||
gruppe: "finden",
|
||||
label: `„${q}“ im Wissens-Vault`,
|
||||
hinweis: "Lucys Notizen",
|
||||
icon: FileText,
|
||||
ausfuehren: ({ navigate }) => navigate(`/wissen?suche=${encodeURIComponent(q)}`),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** An Lucy — den getippten Text als Idee erfassen. Erfasst nur, startet nichts. */
|
||||
const AN_LUCY = (text: string): Befehl[] => {
|
||||
const q = text.trim()
|
||||
if (q.length < 8) return [] // unter acht Zeichen ist es kein Auftrag, sondern ein Tippfehler
|
||||
return [
|
||||
{
|
||||
id: "lucy:idee",
|
||||
gruppe: "lucy",
|
||||
label: `Als Idee erfassen: „${q.length > 60 ? q.slice(0, 57) + "…" : q}“`,
|
||||
hinweis: "Wird nur notiert — gebaut wird erst auf deinen Klick",
|
||||
icon: Lightbulb,
|
||||
ausfuehren: async ({ api, melden, navigate }) => {
|
||||
// Rumpf exakt wie im Ideen-Verfasser (routers/ideen.py → IdeeIn). `art: "idee"`
|
||||
// heisst durchdenken, nicht bauen — die Palette startet nie einen Bau.
|
||||
await api("/api/ideen", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
titel: q.length > 160 ? q.slice(0, 157) + "…" : q,
|
||||
notiz: q.length > 160 ? q : "",
|
||||
welt: "ide",
|
||||
tiefe: "",
|
||||
ziel: "",
|
||||
art: "idee",
|
||||
}),
|
||||
})
|
||||
melden("erfolg", "Als Idee erfasst.")
|
||||
navigate("/ideen")
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** Das vollständige Register für die aktuelle Eingabe. */
|
||||
export function befehle(text: string): Befehl[] {
|
||||
return [...ZIELE, ...AKTIONEN, ...SUCHEN(text), ...AN_LUCY(text)]
|
||||
}
|
||||
|
||||
/** Suchtext eines Befehls — cmdk filtert darüber. */
|
||||
export const suchtext = (b: Befehl) =>
|
||||
[b.label, b.hinweis, b.stichworte, GRUPPEN_TITEL[b.gruppe]].filter(Boolean).join(" ")
|
||||
+18
-185
@@ -1,197 +1,30 @@
|
||||
import { lazy } from "react"
|
||||
import {
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
redirect,
|
||||
useNavigate,
|
||||
} from "@tanstack/react-router"
|
||||
import { Shell } from "@/App"
|
||||
import { NAV } from "@/nav"
|
||||
import { RouteFehler } from "@/components/RouteFehler"
|
||||
import { CockpitView } from "@/views/cockpit/CockpitView"
|
||||
import { createRootRoute, createRoute, createRouter, lazyRouteComponent, Navigate } from "@tanstack/react-router"
|
||||
import { Shell } from "./Shell"
|
||||
import { StartSeite } from "@/views/Start"
|
||||
|
||||
// Routenbaum (v3-Umbau P2).
|
||||
//
|
||||
// Bewusst code-based statt dateibasiert: Es sind zwölf Routen, und sie stehen dadurch
|
||||
// alle an EINER Stelle — inklusive dem, was in den Suchparametern erlaubt ist. Eine
|
||||
// Datei-Konvention würde diese Übersicht auf zwölf Dateien verteilen, ohne dass jemand
|
||||
// etwas davon hätte.
|
||||
//
|
||||
// Code-Splitting bleibt exakt wie bisher: die Views hängen an React.lazy, das Cockpit als
|
||||
// Startseite lädt eifrig. Kein Chunk verschiebt sich durch den Router.
|
||||
const ModelsView = lazy(() => import("@/views/ModelsView").then((m) => ({ default: m.ModelsView })))
|
||||
const ConnectView = lazy(() => import("@/views/ConnectView").then((m) => ({ default: m.ConnectView })))
|
||||
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 IdeenView = lazy(() => import("@/features/ideen/IdeenAnsicht").then((m) => ({ default: m.IdeenView })))
|
||||
const SkillsView = lazy(() => import("@/views/SkillsView").then((m) => ({ default: m.SkillsView })))
|
||||
const ChronikView = lazy(() => import("@/views/ChronikView").then((m) => ({ default: m.ChronikView })))
|
||||
const WissenView = lazy(() => import("@/views/WissenView").then((m) => ({ default: m.WissenView })))
|
||||
|
||||
// ── Suchparameter ───────────────────────────────────────────────────────────
|
||||
// Regel für das, was hier steht: Alles, was ein Kollege per Link teilen können soll und
|
||||
// was einen Reload überleben muss. Nichts Flüchtiges (Hover, Entwürfe im Eingabefeld).
|
||||
|
||||
/** Wurzel: die System-Schublade. Gehört hierher, weil sie über JEDER Ansicht liegt. */
|
||||
export interface WurzelSuche {
|
||||
system?: "wartung" | "logs"
|
||||
}
|
||||
|
||||
const wurzelSuche = (roh: Record<string, unknown>): WurzelSuche => {
|
||||
const s = roh.system
|
||||
return s === "wartung" || s === "logs" ? { system: s } : {}
|
||||
}
|
||||
|
||||
export const rootRoute = createRootRoute({
|
||||
const wurzel = createRootRoute({
|
||||
component: Shell,
|
||||
validateSearch: wurzelSuche,
|
||||
notFoundComponent: () => (
|
||||
<RouteFehler
|
||||
titel="Diese Seite gibt es nicht"
|
||||
text="Der Link zeigt auf eine Ansicht, die Mission Control nicht kennt. Vielleicht stammt er aus einer älteren Fassung."
|
||||
/>
|
||||
),
|
||||
// Alte Lesezeichen (/ideen, /wissen, /cockpit …) führen einfach zum Start.
|
||||
notFoundComponent: () => <Navigate to="/" />,
|
||||
})
|
||||
|
||||
// Wurzelpfad → Cockpit. `redirect` statt einer Kopie der Startseite: sonst gäbe es
|
||||
// zwei URLs für denselben Inhalt und die aktive Markierung in der Sidebar flackert.
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/",
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/cockpit" })
|
||||
},
|
||||
// Der Start ist der Normalfall und kommt im ersten Bündel mit; Updates und Modelle
|
||||
// werden erst beim ersten Besuch nachgeladen (Budget der CI-Ampel: 140 KB gzip).
|
||||
const start = createRoute({ getParentRoute: () => wurzel, path: "/", component: StartSeite })
|
||||
const updates = createRoute({
|
||||
getParentRoute: () => wurzel,
|
||||
path: "/updates",
|
||||
component: lazyRouteComponent(() => import("@/views/Updates"), "UpdatesSeite"),
|
||||
})
|
||||
|
||||
/** Eine Route je Ansicht — kurze Schreibweise, weil sich nur Pfad und Bauteil ändern.
|
||||
* Generisch über den Pfad, damit TanStack Router den Literal-Typ behält: mit
|
||||
* `path: string` verlöre `navigate({ to: "/cockpit" })` seine Typprüfung. */
|
||||
function ansicht<P extends string>(path: P, component: () => React.ReactNode) {
|
||||
return createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path,
|
||||
component,
|
||||
// Fehlergrenze PRO Route (Befund B-13): Vorher hing eine einzige Grenze in main.tsx
|
||||
// um die ganze App — ein Renderfehler in den Modellen nahm Cockpit, Konsole und
|
||||
// Chronik mit. Jetzt bleiben Schiene, Kopfzeile und Statusanzeige stehen.
|
||||
errorComponent: ({ error, reset }) => (
|
||||
<RouteFehler
|
||||
titel="Diese Ansicht ist abgestürzt"
|
||||
text="Der Rest von Mission Control läuft weiter. Erneut versuchen — oder in eine andere Ansicht wechseln."
|
||||
fehler={error}
|
||||
onErneut={reset}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
// Das Cockpit verlinkt in andere Bereiche und bekam den Sprung bisher als Prop von der
|
||||
// App gereicht. Jetzt kommt er aus dem Router — die Ansicht selbst bleibt unverändert.
|
||||
function CockpitSeite() {
|
||||
const navigate = useNavigate()
|
||||
return <CockpitView onNavigate={(v) => {
|
||||
const ziel = NAV.find((n) => n.id === v)
|
||||
if (ziel) navigate({ to: ziel.pfad })
|
||||
}} />
|
||||
}
|
||||
|
||||
const cockpitRoute = ansicht("/cockpit", () => <CockpitSeite />)
|
||||
const skillsRoute = ansicht("/skills", () => <SkillsView />)
|
||||
/** Chronik: `?q=` filtert den Verlauf — Ziel der „Finden"-Einträge der Palette (P6). */
|
||||
export interface ChronikSuche {
|
||||
q?: string
|
||||
}
|
||||
const chronikRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/chronik",
|
||||
validateSearch: (roh: Record<string, unknown>): ChronikSuche =>
|
||||
typeof roh.q === "string" && roh.q ? { q: roh.q } : {},
|
||||
component: () => <ChronikView />,
|
||||
errorComponent: ({ error, reset }) => (
|
||||
<RouteFehler titel="Die Chronik ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
|
||||
),
|
||||
})
|
||||
const verbindenRoute = ansicht("/verbinden", () => <ConnectView />)
|
||||
const konsoleRoute = ansicht("/konsole", () => <KonsoleView />)
|
||||
const anleitungRoute = ansicht("/anleitung", () => <GuideView />)
|
||||
const agentRoute = ansicht("/agent", () => <AgentView />)
|
||||
|
||||
/** Ideen: `?offen=<id>` hält die aufgeklappte Karte fest (Befund B-05).
|
||||
* Vorher lag das in `useState` — wer während eines laufenden Workers neu lud,
|
||||
* verlor seinen Platz in der Liste. */
|
||||
export interface IdeenSuche {
|
||||
offen?: string
|
||||
}
|
||||
const ideenRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/ideen",
|
||||
validateSearch: (roh: Record<string, unknown>): IdeenSuche =>
|
||||
typeof roh.offen === "string" && roh.offen ? { offen: roh.offen } : {},
|
||||
component: () => <IdeenView />,
|
||||
errorComponent: ({ error, reset }) => (
|
||||
<RouteFehler titel="Die Ideen-Ansicht ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
|
||||
),
|
||||
})
|
||||
|
||||
/** Wissen: `?datei=` macht jede Notiz verlinkbar, `?suche=` trägt das Suchwort
|
||||
* aus der Befehlspalette herein (P6). */
|
||||
export interface WissenSuche {
|
||||
datei?: string
|
||||
suche?: string
|
||||
}
|
||||
const wissenRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/wissen",
|
||||
validateSearch: (roh: Record<string, unknown>): WissenSuche => ({
|
||||
...(typeof roh.datei === "string" && roh.datei ? { datei: roh.datei } : {}),
|
||||
...(typeof roh.suche === "string" && roh.suche ? { suche: roh.suche } : {}),
|
||||
}),
|
||||
component: () => <WissenView />,
|
||||
errorComponent: ({ error, reset }) => (
|
||||
<RouteFehler titel="Die Wissens-Ansicht ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
|
||||
),
|
||||
})
|
||||
|
||||
/** Modelle: `?reiter=` — der Reiter war lokaler State und damit nicht teilbar. */
|
||||
export type ModellReiter = "werkbank" | "routing" | "discover"
|
||||
export interface ModelleSuche {
|
||||
reiter?: ModellReiter
|
||||
}
|
||||
const modelleRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
const modelle = createRoute({
|
||||
getParentRoute: () => wurzel,
|
||||
path: "/modelle",
|
||||
validateSearch: (roh: Record<string, unknown>): ModelleSuche => {
|
||||
const r = roh.reiter
|
||||
return r === "werkbank" || r === "routing" || r === "discover" ? { reiter: r } : {}
|
||||
},
|
||||
component: () => <ModelsView />,
|
||||
errorComponent: ({ error, reset }) => (
|
||||
<RouteFehler titel="Der Modell-Manager ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
|
||||
),
|
||||
component: lazyRouteComponent(() => import("@/views/Modelle"), "ModelleSeite"),
|
||||
})
|
||||
|
||||
export const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
cockpitRoute,
|
||||
ideenRoute,
|
||||
skillsRoute,
|
||||
chronikRoute,
|
||||
wissenRoute,
|
||||
verbindenRoute,
|
||||
konsoleRoute,
|
||||
anleitungRoute,
|
||||
modelleRoute,
|
||||
agentRoute,
|
||||
])
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
// Ein LAN-Gerät: der Verlauf muss nicht scrollen-restaurieren, aber die Position
|
||||
// oben zu haben ist beim Ansichtswechsel richtig.
|
||||
scrollRestoration: true,
|
||||
defaultPreload: false,
|
||||
routeTree: wurzel.addChildren([start, updates, modelle]),
|
||||
defaultPreload: "intent",
|
||||
})
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useEffect } from "react"
|
||||
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react"
|
||||
import { useMC, useMeldungen, type MeldungsArt } from "@/app/store"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Meldungs-Region (v3-Umbau P3, Befund B-11).
|
||||
//
|
||||
// Vorher gab es im ganzen Frontend KEIN einziges `aria-live`. Dienst neu gestartet,
|
||||
// Idee angenommen, Modell geladen, Backend weg — für Screenreader war all das stumm,
|
||||
// und für sehende Nutzer stand es je nach Ansicht irgendwo anders (oder in einem Dialog,
|
||||
// den man wegklicken musste).
|
||||
//
|
||||
// Jetzt eine Stelle für alle beiläufigen Rückmeldungen. `role="status"` mit
|
||||
// `aria-live="polite"` heißt: vorlesen, aber den Nutzer nicht unterbrechen. Fehler
|
||||
// bekommen `assertive` — die dürfen unterbrechen.
|
||||
//
|
||||
// Dialoge bleiben, was sie sind: Sie fragen etwas und warten auf eine Antwort. Hier
|
||||
// landet nur, was man zur Kenntnis nimmt.
|
||||
|
||||
const AUSBLENDEN_NACH_MS = 6_000
|
||||
|
||||
const STIL: Record<MeldungsArt, { rand: string; farbe: string; Icon: typeof Info }> = {
|
||||
info: { rand: "border-sky-500/40", farbe: "text-sky-300", Icon: Info },
|
||||
erfolg: { rand: "border-emerald-500/40", farbe: "text-emerald-300", Icon: CheckCircle2 },
|
||||
warnung: { rand: "border-amber-500/40", farbe: "text-amber-300", Icon: AlertTriangle },
|
||||
fehler: { rand: "border-red-500/40", farbe: "text-red-300", Icon: XCircle },
|
||||
}
|
||||
|
||||
export function Meldungen() {
|
||||
const meldungen = useMeldungen()
|
||||
const meldungWeg = useMC((s) => s.meldungWeg)
|
||||
|
||||
// Alte Meldungen räumen sich selbst weg. Fehler bleiben stehen — wer einen Fehler
|
||||
// verpasst, weil er gerade woanders hinsah, hat nichts davon.
|
||||
useEffect(() => {
|
||||
const uhren = meldungen
|
||||
.filter((m) => m.art !== "fehler")
|
||||
.map((m) =>
|
||||
window.setTimeout(
|
||||
() => meldungWeg(m.id),
|
||||
Math.max(1_000, AUSBLENDEN_NACH_MS - (Date.now() - m.seit)),
|
||||
),
|
||||
)
|
||||
return () => uhren.forEach(window.clearTimeout)
|
||||
}, [meldungen, meldungWeg])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed bottom-4 right-4 z-[70] flex w-full max-w-sm flex-col gap-2"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
>
|
||||
{meldungen.map((m) => {
|
||||
const { rand, farbe, Icon } = STIL[m.art]
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"pointer-events-auto flex items-start gap-2.5 rounded-lg border bg-card/95 px-3.5 py-2.5 shadow-xl backdrop-blur",
|
||||
rand,
|
||||
)}
|
||||
// Fehler duerfen unterbrechen, alles andere wartet auf eine Pause.
|
||||
aria-live={m.art === "fehler" ? "assertive" : "polite"}
|
||||
>
|
||||
<Icon className={cn("mt-0.5 h-4 w-4 shrink-0", farbe)} aria-hidden="true" />
|
||||
<p className="flex-1 text-xs leading-relaxed text-foreground">{m.text}</p>
|
||||
<button
|
||||
onClick={() => meldungWeg(m.id)}
|
||||
aria-label="Meldung schließen"
|
||||
className="shrink-0 cursor-pointer rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useHealth, useRouting, useSystemStatus, useTokenStats } from "@/lib/queries"
|
||||
import { useLetzteMetrik, useSysHistory, useTokHistory } from "@/lib/metricsStore"
|
||||
import { useStromLage } from "@/app/store"
|
||||
import { gb } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Die Statusleiste (v3-Umbau P3, Spezifikation §4.1).
|
||||
//
|
||||
// Sie zeigt den Zustand der Box IMMER und blockiert dabei nie den Arbeitsbereich.
|
||||
// Vorher stand dasselbe verstreut: die Ampel unten in der Sidebar, Auslastung nur im
|
||||
// Cockpit, das aktive Modell nur im Modell-Manager. Wer in der Konsole arbeitete, sah
|
||||
// gar nichts — und merkte einen Engine-Ausfall erst, wenn etwas nicht ging.
|
||||
//
|
||||
// Die wichtigste Zahl hier ist nicht CPU oder RAM: Es ist der geteilte Speicher. Die Box
|
||||
// hat 128 GB Unified Memory für alles — Modelle, System, Dienste. Deshalb steht er als
|
||||
// BALKEN da und nicht als Prozentzahl: Man muss auf einen Blick sehen, wie viel Platz ein
|
||||
// weiteres Modell noch hätte.
|
||||
|
||||
/** Sekunden → „12 T 4 Std". Bewusst grob: auf die Minute genau interessiert hier niemand. */
|
||||
function betriebszeit(s: number | null | undefined): string | null {
|
||||
if (s == null || s < 0) return null
|
||||
const tage = Math.floor(s / 86_400)
|
||||
const std = Math.floor((s % 86_400) / 3_600)
|
||||
const min = Math.floor((s % 3_600) / 60)
|
||||
if (tage > 0) return `${tage} T ${std} Std`
|
||||
if (std > 0) return `${std} Std ${min} min`
|
||||
return `${min} min`
|
||||
}
|
||||
|
||||
/** Winziger Sparkline-Pfad aus Messwerten. Eigenes SVG statt einer Diagramm-Bibliothek:
|
||||
* Für eine 60-Pixel-Linie in einer Statusleiste wäre alles andere Verschwendung. */
|
||||
function Sparkline({ werte, farbe, breite = 56, hoehe = 14 }: {
|
||||
werte: number[]
|
||||
farbe: string
|
||||
breite?: number
|
||||
hoehe?: number
|
||||
}) {
|
||||
if (werte.length < 2) return <div style={{ width: breite, height: hoehe }} aria-hidden="true" />
|
||||
const max = Math.max(...werte, 1)
|
||||
const schritt = breite / (werte.length - 1)
|
||||
const d = werte
|
||||
.map((v, i) => `${i === 0 ? "M" : "L"}${(i * schritt).toFixed(1)},${(hoehe - (v / max) * hoehe).toFixed(1)}`)
|
||||
.join(" ")
|
||||
return (
|
||||
<svg width={breite} height={hoehe} viewBox={`0 0 ${breite} ${hoehe}`} aria-hidden="true" className="shrink-0">
|
||||
<path d={d} fill="none" stroke={farbe} strokeWidth="1.25" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Feld({ children, titel, className }: {
|
||||
children: React.ReactNode
|
||||
titel?: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
title={titel}
|
||||
className={cn("flex shrink-0 items-center gap-1.5 border-r border-border/30 px-3", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Statusleiste() {
|
||||
const { data: health, isError: zentraleWeg } = useHealth()
|
||||
const { data: sys } = useSystemStatus()
|
||||
const { data: routing } = useRouting()
|
||||
const { data: tokens } = useTokenStats()
|
||||
const sysVerlauf = useSysHistory()
|
||||
const tokVerlauf = useTokHistory()
|
||||
const lage = useStromLage()
|
||||
|
||||
const engineOk = !zentraleWeg && !!health?.engine_reachable
|
||||
const hirnOk = engineOk && (health?.brain ? health.brain.ready : true)
|
||||
|
||||
// Rollen-Alias statt Dateiname: „fast", nicht „Qwen3.6-35B-A3B-Q4_K_M.gguf".
|
||||
// (Merksatz aus dem Projekt: Rollen-Aliase statt Modellnamen.)
|
||||
const aktivesModell = health?.brain?.role ?? routing?.lanes?.[0]?.name ?? null
|
||||
|
||||
// Live-Werte kommen aus dem Strom (jede Sekunde); die Query ist nur der Stand vom
|
||||
// Seitenaufbau und der Rückfall, wenn der Strom gerissen ist (v3-Umbau P4).
|
||||
const live = useLetzteMetrik()
|
||||
const cpuProzent = live?.cpu ?? sys?.cpu?.percent ?? 0
|
||||
const gpuProzent = live?.gpu ?? sys?.gpu?.busy_percent ?? null
|
||||
const ramProzent = live?.ram ?? sys?.ram?.percent ?? 0
|
||||
const ramBelegt = live?.ram_used ?? sys?.ram?.used
|
||||
const ramGesamt = live?.ram_total ?? sys?.ram?.total
|
||||
const tempMax = Math.max(live?.temp_cpu ?? sys?.temp?.cpu ?? 0, live?.temp_gpu ?? sys?.temp?.gpu ?? 0)
|
||||
const durchsatz = tokVerlauf.length ? tokVerlauf[tokVerlauf.length - 1].completion : 0
|
||||
const zeit = betriebszeit(live?.uptime_s ?? sys?.uptime_s)
|
||||
|
||||
// Die Anzeige beschreibt, WORAUF man gerade schaut — und muss dafür zwei Dinge
|
||||
// zusammenbringen: den Ereignisstrom UND die Erreichbarkeit der Zentrale.
|
||||
//
|
||||
// Beim Prüfen gegen die noch nicht aktualisierte Box aufgefallen: Dort gibt es
|
||||
// /api/stream nicht, der SPA-Catch-all beantwortet den Pfad mit index.html, und
|
||||
// EventSource scheitert. Die Leiste sagte daraufhin „Getrennt" — obwohl die Poller
|
||||
// sauber lieferten und die Zahlen stimmten. Eine Anzeige, die vor korrekten Daten
|
||||
// warnt, ist genauso falsch wie eine, die veraltete verschweigt.
|
||||
//
|
||||
// Getrennt = die Zentrale antwortet nicht. Die Zahlen sind wirklich alt.
|
||||
// Nachlauf = kein Strom, aber die Zentrale antwortet. Die Zahlen stimmen,
|
||||
// sie kommen nur im Takt statt sofort.
|
||||
// Live = Strom steht, Änderungen erscheinen unmittelbar.
|
||||
const anzeige = zentraleWeg ? "getrennt" : lage === "live" ? "live" : "nachlauf"
|
||||
|
||||
const LAGE_STIL = {
|
||||
live: { punkt: "bg-emerald-500", text: "Live", titel: "Ereignisstrom steht — Änderungen erscheinen sofort." },
|
||||
nachlauf: { punkt: "bg-amber-500", text: "Nachlauf", titel: "Kein Ereignisstrom — die Zahlen stimmen, sie kommen im Takt statt sofort." },
|
||||
getrennt: { punkt: "bg-red-500", text: "Getrennt", titel: "Die Zentrale antwortet nicht — die Zahlen sind veraltet." },
|
||||
} as const
|
||||
const lageStil = LAGE_STIL[anzeige]
|
||||
|
||||
return (
|
||||
<footer
|
||||
className="flex h-11 shrink-0 items-stretch overflow-x-auto border-t border-border/40 bg-card/40 font-mono text-[11px] backdrop-blur-sm scrollbar-thin"
|
||||
aria-label="Zustand der Box"
|
||||
>
|
||||
{/* Ampel: Motor + Hirn. Der Klick führt dorthin, wo man es reparieren kann. */}
|
||||
<Link to="/agent" className="flex shrink-0 items-center gap-2.5 border-r border-border/30 px-3 transition-colors hover:bg-accent/50">
|
||||
<span className="flex items-center gap-1.5" title={engineOk ? "Motor (Engine) läuft" : "Motor (Engine) läuft nicht"}>
|
||||
<span className={cn("h-2 w-2 rounded-full", engineOk ? "bg-emerald-500" : "bg-red-500")} aria-hidden="true" />
|
||||
<span className={engineOk ? "text-muted-foreground" : "font-semibold text-red-400"}>Motor</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5" title={hirnOk ? "Lucys Hirn ist bereit" : "Lucys Hirn ist nicht bereit"}>
|
||||
<span className={cn("h-2 w-2 rounded-full", hirnOk ? "bg-emerald-500" : "bg-amber-500")} aria-hidden="true" />
|
||||
<span className={hirnOk ? "text-muted-foreground" : "font-semibold text-amber-400"}>Hirn</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Aktives Modell + Durchsatz */}
|
||||
<Feld titel="Aktive Rolle und aktueller Ausgabe-Durchsatz">
|
||||
<span className="font-semibold text-primary">{aktivesModell ?? "—"}</span>
|
||||
<span className="tabular-nums text-muted-foreground">{durchsatz > 0 ? `${durchsatz.toFixed(0)} t/s` : "leerlauf"}</span>
|
||||
<Sparkline werte={tokVerlauf.map((p) => p.completion)} farbe="hsl(172 72% 50%)" />
|
||||
</Feld>
|
||||
|
||||
{/* Geteilter Speicher — die Kernzahl dieser Box, deshalb als Balken. */}
|
||||
<Feld titel={ramBelegt != null && ramGesamt != null ? `Geteilter Speicher (Unified Memory): ${gb(ramBelegt)} von ${gb(ramGesamt)} GB belegt` : "Speicher"}>
|
||||
<span className="text-muted-foreground">Speicher</span>
|
||||
<div className="h-2 w-24 overflow-hidden rounded-sm border border-border/50 bg-background/60" aria-hidden="true">
|
||||
<div
|
||||
className={cn("h-full transition-[width] duration-500",
|
||||
ramProzent > 90 ? "bg-red-500" : ramProzent > 75 ? "bg-amber-500" : "bg-primary")}
|
||||
style={{ width: `${Math.min(100, ramProzent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="tabular-nums text-foreground">
|
||||
{ramBelegt != null && ramGesamt != null ? `${gb(ramBelegt)}/${gb(ramGesamt)} GB` : "—"}
|
||||
</span>
|
||||
</Feld>
|
||||
|
||||
{/* Auslastung */}
|
||||
<Feld titel="Auslastung von Prozessor und Grafikeinheit">
|
||||
<span className="text-muted-foreground">CPU</span>
|
||||
<span className="tabular-nums text-foreground">{Math.round(cpuProzent)} %</span>
|
||||
<Sparkline werte={sysVerlauf.map((p) => p.cpu)} farbe="hsl(199 89% 58%)" />
|
||||
{gpuProzent != null && (
|
||||
<>
|
||||
<span className="ml-1 text-muted-foreground">GPU</span>
|
||||
<span className="tabular-nums text-foreground">{Math.round(gpuProzent)} %</span>
|
||||
</>
|
||||
)}
|
||||
</Feld>
|
||||
|
||||
{/* Temperatur — ab 85 °C bernsteinfarben, das ist bei dieser APU die Grenze,
|
||||
ab der die Taktrate fällt und alles langsamer wird. */}
|
||||
{tempMax > 0 && (
|
||||
<Feld titel="Höchste gemessene Temperatur (Prozessor oder Grafikeinheit)">
|
||||
<span className="text-muted-foreground">Temp</span>
|
||||
<span className={cn("tabular-nums", tempMax >= 85 ? "font-semibold text-amber-400" : "text-foreground")}>
|
||||
{Math.round(tempMax)} °C
|
||||
</span>
|
||||
</Feld>
|
||||
)}
|
||||
|
||||
{zeit && (
|
||||
<Feld titel="Betriebszeit seit dem letzten Neustart der Box">
|
||||
<span className="text-muted-foreground">Läuft seit</span>
|
||||
<span className="tabular-nums text-foreground">{zeit}</span>
|
||||
</Feld>
|
||||
)}
|
||||
|
||||
{tokens && (
|
||||
<Feld titel="Seit Beginn verarbeitete Token und die damit gesparten Cloud-Kosten">
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{tokens.total_tokens.toLocaleString("de-DE")} Token · {tokens.saved_eur.toFixed(0)} € gespart
|
||||
</span>
|
||||
</Feld>
|
||||
)}
|
||||
|
||||
{/* Ehrlichkeits-Anzeige: sagt, WORAUF man gerade schaut. Ohne sie altert die
|
||||
Oberflaeche still weiter, wenn der Strom reisst (Befund B-15). */}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5 px-3" title={lageStil.titel}>
|
||||
<span className={cn("h-2 w-2 rounded-full", lageStil.punkt, anzeige === "live" && "animate-pulse")} aria-hidden="true" />
|
||||
<span className="text-muted-foreground">{lageStil.text}</span>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
import { useMC, melden, stromLageSetzen } from "./store"
|
||||
|
||||
function zuruecksetzen() {
|
||||
useMC.setState({
|
||||
ui: { schieneEingeklappt: false, paletteOffen: false, expertenmodus: false },
|
||||
strom: { lage: "getrennt", letztesEreignis: null },
|
||||
meldungen: [],
|
||||
})
|
||||
}
|
||||
|
||||
describe("Store", () => {
|
||||
beforeEach(zuruecksetzen)
|
||||
|
||||
it("schaltet die Schiene um", () => {
|
||||
useMC.getState().schieneUmschalten()
|
||||
expect(useMC.getState().ui.schieneEingeklappt).toBe(true)
|
||||
useMC.getState().schieneUmschalten()
|
||||
expect(useMC.getState().ui.schieneEingeklappt).toBe(false)
|
||||
})
|
||||
|
||||
it("öffnet und schließt die Palette", () => {
|
||||
useMC.getState().palette(true)
|
||||
expect(useMC.getState().ui.paletteOffen).toBe(true)
|
||||
useMC.getState().palette(false)
|
||||
expect(useMC.getState().ui.paletteOffen).toBe(false)
|
||||
})
|
||||
|
||||
it("nimmt Meldungen entgegen und gibt jeder einen eigenen Schlüssel", () => {
|
||||
melden("erfolg", "Dienst neu gestartet")
|
||||
melden("erfolg", "Dienst neu gestartet")
|
||||
const m = useMC.getState().meldungen
|
||||
expect(m).toHaveLength(2)
|
||||
expect(m[0].id).not.toBe(m[1].id)
|
||||
})
|
||||
|
||||
// Ohne Deckel wächst die Liste bei einem Fehlersturm ins Unendliche und überdeckt
|
||||
// die halbe Oberfläche.
|
||||
it("hält höchstens sechs Meldungen und wirft die ältesten weg", () => {
|
||||
for (let i = 0; i < 10; i++) melden("info", `Meldung ${i}`)
|
||||
const m = useMC.getState().meldungen
|
||||
expect(m).toHaveLength(6)
|
||||
expect(m[0].text).toBe("Meldung 4")
|
||||
expect(m[5].text).toBe("Meldung 9")
|
||||
})
|
||||
|
||||
it("entfernt eine Meldung gezielt", () => {
|
||||
melden("warnung", "eins")
|
||||
melden("warnung", "zwei")
|
||||
const id = useMC.getState().meldungen[0].id
|
||||
useMC.getState().meldungWeg(id)
|
||||
expect(useMC.getState().meldungen.map((x) => x.text)).toEqual(["zwei"])
|
||||
})
|
||||
|
||||
it("führt die Lage des Ereignisstroms", () => {
|
||||
stromLageSetzen("live")
|
||||
expect(useMC.getState().strom.lage).toBe("live")
|
||||
stromLageSetzen("nachlauf")
|
||||
expect(useMC.getState().strom.lage).toBe("nachlauf")
|
||||
})
|
||||
})
|
||||
|
||||
// Die Regel aus dem Kopfkommentar des Stores, als Test: Hier darf NICHTS liegen, was
|
||||
// in die URL oder in TanStack Query gehört — und schon gar kein Geheimnis (Befund B-01).
|
||||
describe("Store hält nur, was er halten darf", () => {
|
||||
beforeEach(zuruecksetzen)
|
||||
|
||||
it("merkt sich ausschließlich Bedienvorlieben", () => {
|
||||
useMC.getState().schieneUmschalten()
|
||||
useMC.getState().expertenmodus(true)
|
||||
useMC.getState().palette(true)
|
||||
stromLageSetzen("live")
|
||||
melden("info", "sollte nicht überleben")
|
||||
|
||||
const gespeichert = JSON.parse(localStorage.getItem("mc_ui") ?? "{}")
|
||||
expect(gespeichert.state).toEqual({
|
||||
ui: { schieneEingeklappt: true, expertenmodus: true },
|
||||
})
|
||||
// Weder der Offen-Zustand der Palette noch Strom oder Meldungen wandern in den
|
||||
// Speicher: alles drei wäre beim nächsten Start gelogen.
|
||||
expect(JSON.stringify(gespeichert)).not.toContain("paletteOffen")
|
||||
expect(JSON.stringify(gespeichert)).not.toContain("meldungen")
|
||||
expect(JSON.stringify(gespeichert)).not.toContain("strom")
|
||||
})
|
||||
})
|
||||
@@ -1,119 +0,0 @@
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
|
||||
// Der EINE Client-Speicher (v3-Umbau P3, Befund B-08/B-09).
|
||||
//
|
||||
// Vorher verteilte sich derselbe Zweck über fünf Dateien: zwei handgebaute
|
||||
// `useSyncExternalStore`-Speicher (Metrik-Verlauf, Expertenmodus) und drei
|
||||
// `CustomEvent`-Kanäle (`mc-navigate`, `open-system-drawer`, `mc-expert-mode-change`,
|
||||
// dazu in P2 `mc-palette-open`). Das funktionierte — aber ein Tippfehler im
|
||||
// Ereignisnamen fiel erst zur Laufzeit auf, und niemand konnte auf einen Blick sagen,
|
||||
// welcher Zustand es überhaupt gibt.
|
||||
//
|
||||
// DIE REGEL, nach der hier etwas landet:
|
||||
// · Steht es in der URL? → gehört in den Router (app/router.tsx), nicht hierher.
|
||||
// · Kommt es vom Server? → gehört in TanStack Query, nicht hierher.
|
||||
// · Ist es flüchtig (Hover, Entwurf im Eingabefeld)? → useState in der Komponente.
|
||||
// · Sonst: hier.
|
||||
//
|
||||
// Der Metrik-Verlauf bleibt bewusst DRAUSSEN (lib/metricsStore.ts). Er nimmt ab P4
|
||||
// jede Sekunde einen Messpunkt entgegen; ein Store-Update pro Sekunde würde jede
|
||||
// abonnierende Komponente neu rendern. Ein eigener `useSyncExternalStore` außerhalb
|
||||
// von React ist dafür das richtige Werkzeug — und bleibt es.
|
||||
|
||||
export type MeldungsArt = "info" | "erfolg" | "warnung" | "fehler"
|
||||
|
||||
export interface Meldung {
|
||||
id: string
|
||||
art: MeldungsArt
|
||||
text: string
|
||||
/** Zeitpunkt in ms — die Meldungsliste blendet Alte selbst aus. */
|
||||
seit: number
|
||||
}
|
||||
|
||||
/** Zustand des Ereignisstroms. Ab P4 füttert ihn der Stream selbst; in P3 setzt ihn
|
||||
* lib/events.ts, damit die Statusleiste schon jetzt die Wahrheit zeigt. */
|
||||
export type StromLage = "live" | "nachlauf" | "getrennt"
|
||||
|
||||
interface MC {
|
||||
ui: {
|
||||
schieneEingeklappt: boolean
|
||||
paletteOffen: boolean
|
||||
expertenmodus: boolean
|
||||
}
|
||||
strom: {
|
||||
lage: StromLage
|
||||
letztesEreignis: number | null
|
||||
}
|
||||
meldungen: Meldung[]
|
||||
|
||||
schieneUmschalten: () => void
|
||||
palette: (offen: boolean) => void
|
||||
expertenmodus: (an: boolean) => void
|
||||
stromLage: (lage: StromLage) => void
|
||||
stromEreignis: () => void
|
||||
melden: (art: MeldungsArt, text: string) => void
|
||||
meldungWeg: (id: string) => void
|
||||
}
|
||||
|
||||
// Eigener Zähler statt Date.now()/Math.random(): zwei Meldungen in derselben
|
||||
// Millisekunde bekamen sonst denselben Schlüssel, und React zeigte nur eine an.
|
||||
let meldungsZaehler = 0
|
||||
|
||||
export const useMC = create<MC>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
ui: { schieneEingeklappt: false, paletteOffen: false, expertenmodus: false },
|
||||
strom: { lage: "getrennt", letztesEreignis: null },
|
||||
meldungen: [],
|
||||
|
||||
schieneUmschalten: () =>
|
||||
set((s) => ({ ui: { ...s.ui, schieneEingeklappt: !s.ui.schieneEingeklappt } })),
|
||||
|
||||
palette: (offen) => set((s) => ({ ui: { ...s.ui, paletteOffen: offen } })),
|
||||
|
||||
expertenmodus: (an) => set((s) => ({ ui: { ...s.ui, expertenmodus: an } })),
|
||||
|
||||
stromLage: (lage) => set((s) => ({ strom: { ...s.strom, lage } })),
|
||||
|
||||
stromEreignis: () =>
|
||||
set((s) => ({ strom: { ...s.strom, letztesEreignis: Date.now() } })),
|
||||
|
||||
melden: (art, text) =>
|
||||
set((s) => ({
|
||||
meldungen: [
|
||||
...s.meldungen,
|
||||
{ id: `m${++meldungsZaehler}`, art, text, seit: Date.now() },
|
||||
].slice(-6), // mehr als sechs liest ohnehin niemand
|
||||
})),
|
||||
|
||||
meldungWeg: (id) => set((s) => ({ meldungen: s.meldungen.filter((m) => m.id !== id) })),
|
||||
}),
|
||||
{
|
||||
name: "mc_ui",
|
||||
// NUR Bedienvorlieben werden gemerkt. Kein Geheimnis (siehe P1/B-01), kein
|
||||
// Serverdatum, kein Stromzustand — der wäre beim nächsten Start ohnehin gelogen.
|
||||
partialize: (s) => ({
|
||||
ui: { schieneEingeklappt: s.ui.schieneEingeklappt, expertenmodus: s.ui.expertenmodus },
|
||||
}),
|
||||
merge: (gespeichert, aktuell) => {
|
||||
const g = gespeichert as { ui?: Partial<MC["ui"]> } | undefined
|
||||
return { ...aktuell, ui: { ...aktuell.ui, ...(g?.ui ?? {}) } }
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// ── Schmale Zugriffe ────────────────────────────────────────────────────────
|
||||
// Komponenten sollen nur das abonnieren, was sie wirklich brauchen — sonst rendert
|
||||
// die Schiene neu, weil eine Meldung kam.
|
||||
export const useExpertenmodus = () => useMC((s) => s.ui.expertenmodus)
|
||||
export const useSchieneEingeklappt = () => useMC((s) => s.ui.schieneEingeklappt)
|
||||
export const usePaletteOffen = () => useMC((s) => s.ui.paletteOffen)
|
||||
export const useStromLage = () => useMC((s) => s.strom.lage)
|
||||
export const useMeldungen = () => useMC((s) => s.meldungen)
|
||||
|
||||
/** Für Code außerhalb von React (lib/events.ts, api-Fehlerbehandlung). */
|
||||
export const melden = (art: MeldungsArt, text: string) => useMC.getState().melden(art, text)
|
||||
export const stromLageSetzen = (lage: StromLage) => useMC.getState().stromLage(lage)
|
||||
export const stromEreignis = () => useMC.getState().stromEreignis()
|
||||
@@ -1,38 +0,0 @@
|
||||
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 Canvas-Crashes.)
|
||||
interface State { error: Error | null }
|
||||
|
||||
export class AppErrorBoundary extends React.Component<React.PropsWithChildren, State> {
|
||||
state: State = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error("Unbehandelter UI-Fehler:", error, info.componentStack)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-neutral-950 text-neutral-200 p-8">
|
||||
<div className="max-w-lg space-y-4 text-center">
|
||||
<div className="text-2xl">Da ist etwas schiefgelaufen.</div>
|
||||
<div className="text-sm text-neutral-400 break-all">
|
||||
{this.state.error.message}
|
||||
</div>
|
||||
<button
|
||||
className="px-4 py-2 rounded-lg bg-neutral-800 hover:bg-neutral-700 border border-neutral-700"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Neu laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { Capabilities } from "@/lib/api"
|
||||
|
||||
function Chip({ children, tone = "muted" }: { children: React.ReactNode; tone?: "muted" | "primary" | "warn" }) {
|
||||
const tones = {
|
||||
muted: "bg-muted text-muted-foreground",
|
||||
primary: "bg-primary/15 text-primary",
|
||||
warn: "bg-amber-500/15 text-amber-500",
|
||||
}
|
||||
return (
|
||||
<span className={`rounded px-1.5 py-0.5 text-[11px] font-medium ${tones[tone]}`}>{children}</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function CapsChips({ caps }: { caps?: Capabilities }) {
|
||||
if (!caps) return null
|
||||
return (
|
||||
<span className="inline-flex flex-wrap gap-1">
|
||||
{caps.coder && <Chip>💻 Code</Chip>}
|
||||
{caps.vision && <Chip>👁 Bild</Chip>}
|
||||
{caps.reasoning && <Chip>🧠 Reason</Chip>}
|
||||
{caps.moe && <Chip tone="primary">🧩 MoE{caps.active_b ? `·${caps.active_b}b` : ""}</Chip>}
|
||||
{caps.tools === "yes" && <Chip tone="primary">🛠 Tools</Chip>}
|
||||
{caps.tools === "likely" && <Chip tone="warn">🛠 Tools?</Chip>}
|
||||
{caps.embedding && <Chip>🔢 Embed</Chip>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { Command } from "cmdk"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { AlertTriangle, CornerDownLeft } from "lucide-react"
|
||||
import { useMC, usePaletteOffen } from "@/app/store"
|
||||
import { api } from "@/lib/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
GRUPPEN_TITEL, befehle, suchtext,
|
||||
type Befehl, type BefehlsGruppe,
|
||||
} from "@/app/palette/register"
|
||||
|
||||
// Befehlspalette 2.0 (v3-Umbau P6, Spezifikation §4.6).
|
||||
//
|
||||
// Vorher listete sie die elf Navigations-Einträge — ein Sprungbrett. Jetzt tippt man,
|
||||
// WAS man will, nicht wo es liegt: springen, tun, finden, oder den Text direkt als Idee
|
||||
// an Lucy geben. Das Register steht in app/palette/register.ts.
|
||||
//
|
||||
// Die Rückfrage ist keine Höflichkeit, sondern Absicht: Die Palette ist schnell genug,
|
||||
// dass „Strg+K, mot, Enter" den Motor neu startet, bevor man es zu Ende gedacht hat.
|
||||
// Befehle mit `rueckfrage` halten deshalb an und sagen erst, was passieren wird.
|
||||
|
||||
const GRUPPEN_REIHE: BefehlsGruppe[] = ["gehe", "tun", "finden", "lucy"]
|
||||
|
||||
export function CommandPalette() {
|
||||
const offen = usePaletteOffen()
|
||||
const palette = useMC((s) => s.palette)
|
||||
const melden = useMC((s) => s.melden)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [text, setText] = useState("")
|
||||
const [nachfrage, setNachfrage] = useState<Befehl | null>(null)
|
||||
const [laeuft, setLaeuft] = useState(false)
|
||||
|
||||
const schliessen = useCallback(() => {
|
||||
palette(false)
|
||||
setText("")
|
||||
setNachfrage(null)
|
||||
}, [palette])
|
||||
|
||||
const ausfuehren = useCallback(async (b: Befehl) => {
|
||||
setLaeuft(true)
|
||||
try {
|
||||
await b.ausfuehren({
|
||||
text,
|
||||
navigate: (pfad) => navigate({ to: pfad }),
|
||||
schublade: (tab) => navigate({ to: ".", search: (a) => ({ ...a, system: tab }) }),
|
||||
melden,
|
||||
api,
|
||||
})
|
||||
schliessen()
|
||||
} catch (e) {
|
||||
// Der echte Grund aus ApiError.detail (P1/B-14) — nicht „Fehler".
|
||||
melden("fehler", e instanceof Error ? e.message : String(e))
|
||||
setNachfrage(null)
|
||||
} finally {
|
||||
setLaeuft(false)
|
||||
}
|
||||
}, [text, navigate, melden, schliessen])
|
||||
|
||||
const liste = useMemo(() => befehle(text), [text])
|
||||
|
||||
return (
|
||||
<Command.Dialog
|
||||
open={offen}
|
||||
onOpenChange={(o) => (o ? palette(true) : schliessen())}
|
||||
label="Befehlspalette"
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overscroll-contain bg-black/50 p-4 pt-[12vh]"
|
||||
onClick={schliessen}
|
||||
shouldFilter={!nachfrage}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-xl overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{nachfrage ? (
|
||||
// ── Zweistufige Rückfrage ────────────────────────────────────────
|
||||
<div className="space-y-4 p-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-400" aria-hidden="true" />
|
||||
<h2 className="font-space text-sm font-bold tracking-tight">{nachfrage.label}</h2>
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">{nachfrage.rueckfrage}</p>
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => setNachfrage(null)}
|
||||
className="h-9 cursor-pointer rounded-lg border border-border/60 bg-background/20 px-4 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
autoFocus
|
||||
disabled={laeuft}
|
||||
onClick={() => ausfuehren(nachfrage)}
|
||||
className="h-9 cursor-pointer rounded-lg bg-amber-500/90 px-4 text-xs font-bold text-black transition-colors hover:bg-amber-500 disabled:opacity-50"
|
||||
>
|
||||
{laeuft ? "Läuft …" : "Ja, ausführen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Command.Input
|
||||
autoFocus
|
||||
value={text}
|
||||
onValueChange={setText}
|
||||
placeholder="Springen, tun, finden — oder schreib eine Idee …"
|
||||
className="w-full border-b border-border bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Command.List className="max-h-[26rem] overflow-y-auto p-2 scrollbar-thin">
|
||||
<Command.Empty className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Nichts gefunden.
|
||||
</Command.Empty>
|
||||
|
||||
{GRUPPEN_REIHE.map((gruppe) => {
|
||||
const eintraege = liste.filter((b) => b.gruppe === gruppe)
|
||||
if (!eintraege.length) return null
|
||||
return (
|
||||
<Command.Group
|
||||
key={gruppe}
|
||||
heading={GRUPPEN_TITEL[gruppe]}
|
||||
className="px-1 py-1 text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50"
|
||||
>
|
||||
{eintraege.map((b) => (
|
||||
<Command.Item
|
||||
key={b.id}
|
||||
value={suchtext(b)}
|
||||
onSelect={() => (b.rueckfrage ? setNachfrage(b) : ausfuehren(b))}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground aria-selected:bg-accent aria-selected:text-accent-foreground"
|
||||
>
|
||||
<b.icon
|
||||
className={cn("h-4 w-4 shrink-0", b.rueckfrage ? "text-amber-400" : "text-primary")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{b.label}</span>
|
||||
{b.rueckfrage && (
|
||||
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-amber-300">
|
||||
fragt nach
|
||||
</span>
|
||||
)}
|
||||
{b.hinweis && (
|
||||
<span className="ml-auto truncate pl-3 text-xs text-muted-foreground">{b.hinweis}</span>
|
||||
)}
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
)
|
||||
})}
|
||||
</Command.List>
|
||||
|
||||
<div className="flex items-center gap-3 border-t border-border/50 px-4 py-2 font-mono text-[10px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1">↑↓ wählen</span>
|
||||
<span className="flex items-center gap-1"><CornerDownLeft className="h-3 w-3" aria-hidden="true" /> ausführen</span>
|
||||
<span className="ml-auto">esc schließt</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Command.Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { useRef } from "react"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
export interface CustomDialogProps {
|
||||
type: "alert" | "confirm" | "prompt"
|
||||
title: string
|
||||
message: string
|
||||
defaultValue?: string
|
||||
autoValue?: string
|
||||
autoLabel?: string
|
||||
onConfirm: (val?: string) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function CustomDialog({ type, title, message, defaultValue, autoValue, autoLabel, onConfirm, onCancel }: CustomDialogProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
return (
|
||||
<div className="fixed inset-0 z-[99] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 overscroll-contain" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<div className="w-full max-w-sm 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">{title}</h3>
|
||||
<button
|
||||
onClick={onCancel || (() => onConfirm())}
|
||||
aria-label="Schließen"
|
||||
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{/* whitespace-pre-line + Scroll: mehrzeilige Botschaften (z. B. Dubletten-Vorschau,
|
||||
Annehmen-Warnungen) sauber rendern statt zu einer Wurst zusammenzufallen. */}
|
||||
<p className="max-h-72 overflow-y-auto whitespace-pre-line text-xs text-muted-foreground leading-relaxed scrollbar-thin">{message}</p>
|
||||
|
||||
{type === "prompt" && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
defaultValue={defaultValue}
|
||||
aria-label={title}
|
||||
className="flex-1 h-9 px-3 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
onConfirm(inputRef.current?.value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{autoValue !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (inputRef.current) inputRef.current.value = autoValue }}
|
||||
title="Setup-bewussten Optimalwert eintragen"
|
||||
className="h-9 px-3 shrink-0 text-xs font-semibold text-primary border border-primary/50 bg-primary/10 hover:bg-primary/20 rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
{autoLabel || "Auto"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
{(type === "confirm" || type === "prompt") && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="h-8 px-4 flex items-center justify-center text-xs font-semibold text-muted-foreground border border-border/60 bg-background/20 hover:bg-accent rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
const val = type === "prompt" ? inputRef.current?.value : undefined
|
||||
onConfirm(val)
|
||||
}}
|
||||
className="h-8 px-4 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/95 rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
{type === "confirm" ? "Ja, fortfahren" : type === "prompt" ? "Übernehmen" : "OK"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Sparkles, SlidersHorizontal } from "lucide-react"
|
||||
import { useExpertenmodus, useMC } from "@/app/store"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Zwei-Zustand-Pille im Header: „Einfach" (aufgeräumt) ↔ „Experte" (alle Details).
|
||||
export function ExpertToggle() {
|
||||
const expert = useExpertenmodus()
|
||||
const setExpertMode = useMC((s) => s.expertenmodus)
|
||||
return (
|
||||
<div
|
||||
className="flex items-center rounded-md border border-border/40 bg-background/40 p-0.5"
|
||||
role="group"
|
||||
aria-label="Ansichtsmodus"
|
||||
title="Einfach zeigt nur das Wichtigste. Experte zeigt alle technischen Details."
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpertMode(false)}
|
||||
aria-pressed={!expert}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded px-2 py-1 text-xs font-semibold transition-all cursor-pointer",
|
||||
!expert ? "bg-primary/15 text-primary shadow-sm" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" /> Einfach
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExpertMode(true)}
|
||||
aria-pressed={expert}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded px-2 py-1 text-xs font-semibold transition-all cursor-pointer",
|
||||
expert ? "bg-primary/15 text-primary shadow-sm" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<SlidersHorizontal className="h-3.5 w-3.5" /> Experte
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { AlertTriangle, RotateCcw } from "lucide-react"
|
||||
|
||||
// Fehlerfläche EINER Ansicht (v3-Umbau P2, Befund B-13).
|
||||
//
|
||||
// Vorher hing genau eine Fehlergrenze in main.tsx um die gesamte App: Ein Renderfehler
|
||||
// im Modell-Manager nahm Cockpit, Konsole und Chronik gleich mit — die Seite war weiß.
|
||||
// Jetzt fängt jede Route für sich; Schiene, Kopfzeile und Statusanzeige bleiben stehen,
|
||||
// und der Nutzer kann einfach woanders hin.
|
||||
//
|
||||
// Die Meldung sagt, was passiert ist und was man tun kann. Der technische Text steht
|
||||
// aufklappbar darunter — nicht versteckt (man braucht ihn für einen Fehlerbericht),
|
||||
// aber auch nicht als Erstes im Gesicht.
|
||||
export function RouteFehler({ titel, text, fehler, onErneut }: {
|
||||
titel: string
|
||||
text: string
|
||||
fehler?: unknown
|
||||
onErneut?: () => void
|
||||
}) {
|
||||
const detail = fehler instanceof Error ? (fehler.stack || fehler.message) : fehler ? String(fehler) : null
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-6">
|
||||
<div className="mc-card max-w-lg space-y-4 p-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-400" aria-hidden="true" />
|
||||
<h1 className="font-space text-lg font-bold tracking-tight">{titel}</h1>
|
||||
</div>
|
||||
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{text}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{onErneut && (
|
||||
<button
|
||||
onClick={onErneut}
|
||||
className="flex h-9 cursor-pointer items-center gap-1.5 rounded-lg bg-primary px-4 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" /> Erneut versuchen
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href="/cockpit"
|
||||
className="flex h-9 items-center rounded-lg border border-border/60 bg-background/20 px-4 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
Zum Cockpit
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{detail && (
|
||||
<details className="pt-1">
|
||||
<summary className="cursor-pointer text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 hover:text-foreground">
|
||||
Technische Einzelheiten
|
||||
</summary>
|
||||
<pre className="scrollbar-thin mt-2 max-h-52 overflow-auto rounded-lg border border-border/40 bg-background/40 p-3 font-mono text-[10px] leading-relaxed text-muted-foreground">
|
||||
{detail}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,576 +0,0 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { X, RefreshCw, Cpu, Server, Shield, Power, Camera, Bot, Box, Shuffle, DownloadCloud } from "lucide-react"
|
||||
import { api, type UpdateDetails } from "@/lib/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { useJobs, useServices, useUpdates, useZeitmaschine, useQueryClient, qk, invalidate } from "@/lib/queries"
|
||||
import { fmtBytes } from "@/lib/format"
|
||||
import { ServiceRow, UpdateRow } from "./system/rows"
|
||||
import { LogConsole } from "./system/LogConsole"
|
||||
import { SettingsTab } from "./system/SettingsTab"
|
||||
import { UpdateDetailModal, type UpdateDetailState } from "./system/UpdateDetailModal"
|
||||
|
||||
// System-Schublade (Pflege/Logs/Einstellungen). Datenweg: AUSSCHLIESSLICH die zentralen
|
||||
// React-Query-Hooks — früher pollte hier ein eigener setInterval alle 3 s dieselben
|
||||
// Endpunkte (inkl. des teuren Updates-Checks) am Query-Layer vorbei, wodurch Cockpit
|
||||
// und Schublade verschiedene Stände zeigen konnten (Review 15.07.). Die Dienste-Liste
|
||||
// kommt 1:1 aus /api/system/services (unit + scope), keine UI-Kopie mehr.
|
||||
|
||||
interface SystemDrawerProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
defaultTab?: "maintenance" | "logs" | "settings"
|
||||
}
|
||||
|
||||
export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: SystemDrawerProps) {
|
||||
const qc = useQueryClient()
|
||||
const { showAlert, showConfirm, dialogElement } = useDialog()
|
||||
|
||||
// Geteilte Caches; Jobs/Zeitmaschine beobachten nur bei offener Schublade.
|
||||
const { data: updates } = useUpdates()
|
||||
const { data: services } = useServices()
|
||||
const jobs = useJobs(3_000, open).data ?? []
|
||||
const { data: zeit } = useZeitmaschine(undefined, open)
|
||||
|
||||
const [selectedService, setSelectedService] = useState("llama-swap")
|
||||
const [logs, setLogs] = useState("")
|
||||
const [loadingLogs, setLoadingLogs] = useState(false)
|
||||
const [logStatus, setLogStatus] = useState<string | null>(null)
|
||||
const [restartingServices, setRestartingServices] = useState<Record<string, boolean>>({})
|
||||
const [activeTab, setActiveTab] = useState<"maintenance" | "logs" | "settings">("maintenance")
|
||||
const [checkingUpdates, setCheckingUpdates] = useState(false)
|
||||
const [backupMsg, setBackupMsg] = useState("")
|
||||
const [backupRunning, setBackupRunning] = useState(false)
|
||||
const [hermesUpdating, setHermesUpdating] = useState(false)
|
||||
|
||||
// Update-Detail-Fenster: zeigt VOR dem Anwenden, was genau aktualisiert wird.
|
||||
const [detail, setDetail] = useState<UpdateDetailState | null>(null)
|
||||
|
||||
const svcList = services?.services ?? []
|
||||
const refreshJobs = () => invalidate(qc, qk.jobs)
|
||||
|
||||
function formatLastCheck(ts?: number | null) {
|
||||
if (!ts) return "Nie"
|
||||
return new Date(ts * 1000).toLocaleString("de-DE", {
|
||||
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open && defaultTab) setActiveTab(defaultTab)
|
||||
}, [open, defaultTab])
|
||||
|
||||
// Beim Öffnen einmal frisch ziehen — danach übernehmen die normalen Poll-Takte.
|
||||
useEffect(() => {
|
||||
if (open) invalidate(qc, qk.updates, qk.services, qk.jobs, qk.zeitmaschine)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
function loadServiceLogs(service: string) {
|
||||
setLoadingLogs(true)
|
||||
setLogStatus(null)
|
||||
api<{ ok: boolean; text: string; err?: string; status?: string }>(`/api/maintenance/logs?service=${service}&lines=150`)
|
||||
.then((res) => {
|
||||
if (res.ok) {
|
||||
setLogs(res.text)
|
||||
} else {
|
||||
setLogs(`Fehler beim Laden der Logs: ${res.err || "Unbekannter Fehler"}`)
|
||||
if (res.status === "incorrect_password" || res.status === "password_required") {
|
||||
setLogStatus(res.status)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => setLogs(`Fehler: ${e.message}`))
|
||||
.finally(() => setLoadingLogs(false))
|
||||
}
|
||||
|
||||
// Load logs when tab is active or service changes
|
||||
useEffect(() => {
|
||||
if (!open || activeTab !== "logs") return
|
||||
loadServiceLogs(selectedService)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, activeTab, selectedService])
|
||||
|
||||
// Backend-Wartungsriegel: lehnt ein zweites Update ab, solange eines läuft.
|
||||
function handledBusy(res: any): boolean {
|
||||
if (res?.status === "busy") {
|
||||
showAlert("Update läuft bereits", `Es läuft gerade „${res.running}". Bitte warte, bis es fertig ist.`)
|
||||
refreshJobs()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Passwort-Fehler NICHT verschlucken: OS-Update braucht das Box-Passwort.
|
||||
function handledSudo(res: any): boolean {
|
||||
if (res?.status === "password_required" || res?.status === "incorrect_password") {
|
||||
showAlert(
|
||||
"Box-Passwort nötig",
|
||||
(res.status === "incorrect_password"
|
||||
? "Das gespeicherte Box-Passwort stimmt nicht. "
|
||||
: "Für dieses Update braucht die Box dein Passwort. ")
|
||||
+ "Bitte oben im Reiter „Einstellungen“ setzen bzw. korrigieren.",
|
||||
)
|
||||
return true
|
||||
}
|
||||
if (res && res.ok === false) {
|
||||
showAlert("Update fehlgeschlagen", res.err || "Unbekannter Fehler.")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function triggerUpdate(kind: "os" | "engine" | "swap") {
|
||||
const path = { os: "os-update", engine: "engine-update", swap: "swap-update" }[kind]
|
||||
try {
|
||||
const res = await api<any>(`/api/maintenance/${path}`, { method: "POST" })
|
||||
if (handledBusy(res) || handledSudo(res)) return
|
||||
refreshJobs()
|
||||
setActiveTab("maintenance")
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", `Update konnte nicht starten: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function doHermesUpdate() {
|
||||
setHermesUpdating(true)
|
||||
try {
|
||||
const res = await api<any>("/api/maintenance/hermes-update", { method: "POST" })
|
||||
if (handledBusy(res)) return
|
||||
refreshJobs()
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", `Hermes-Update fehlgeschlagen: ${e.message}`)
|
||||
} finally {
|
||||
setHermesUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Öffnet das Detail-Fenster und lädt, was genau aktualisiert würde.
|
||||
async function openUpdateDetails(kind: "os" | "engine" | "swap" | "hermes") {
|
||||
setDetail({ kind, loading: true, data: null })
|
||||
try {
|
||||
const d = await api<UpdateDetails>(`/api/maintenance/update-details?kind=${kind}`)
|
||||
setDetail({ kind, loading: false, data: d })
|
||||
} catch (e: any) {
|
||||
setDetail({ kind, loading: false, data: { kind, error: e.message } })
|
||||
}
|
||||
}
|
||||
|
||||
// Bestätigung aus dem Detail-Fenster → startet das passende Update.
|
||||
function applyFromDetail() {
|
||||
const kind = detail?.kind
|
||||
setDetail(null)
|
||||
if (kind === "os" || kind === "engine" || kind === "swap") triggerUpdate(kind)
|
||||
else if (kind === "hermes") doHermesUpdate()
|
||||
}
|
||||
|
||||
// „Alle aktualisieren": kettet alle AUSSTEHENDEN Updates in EINEM Wartungs-Job
|
||||
// (Engine → Router → Hermes → OS; bei einem Fehler stoppt die Kette).
|
||||
function triggerUpdateAll() {
|
||||
const teile = [
|
||||
updates?.engine ? "Engine" : null,
|
||||
updates?.swap ? "Router" : null,
|
||||
updates?.components?.some((c) => c.update === true) ? "Hermes-Agent" : null,
|
||||
updates?.os ? `OS (${updates.os} Pakete)` : null,
|
||||
].filter(Boolean)
|
||||
showConfirm(
|
||||
"Alle Updates einspielen?",
|
||||
`Nacheinander in einem Job: ${teile.join(" → ")}. Jeder Schritt sichert und prüft sich selbst; schlägt einer fehl, stoppt der Rest. (OS braucht das Box-Passwort aus den Einstellungen — fehlt es, wird OS übersprungen.)`,
|
||||
async () => {
|
||||
try {
|
||||
const res = await api<any>("/api/maintenance/update-all", { method: "POST" })
|
||||
if (handledBusy(res) || handledSudo(res)) return
|
||||
if (res?.status === "nothing") { showAlert("Nichts zu tun", "Es stehen keine Updates aus."); return }
|
||||
refreshJobs()
|
||||
setActiveTab("maintenance")
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", `„Alle aktualisieren" konnte nicht starten: ${e.message}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function triggerCheckUpdates() {
|
||||
setCheckingUpdates(true)
|
||||
try {
|
||||
await api("/api/maintenance/check-updates", { method: "POST" })
|
||||
invalidate(qc, qk.jobs, qk.updates)
|
||||
setActiveTab("maintenance")
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", `Fehler bei der Update-Suche: ${e.message}`)
|
||||
} finally {
|
||||
setCheckingUpdates(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerModelUpgrade(repo: string, role: string) {
|
||||
try {
|
||||
await api("/api/models/install", { method: "POST", body: JSON.stringify({ repo, role }) })
|
||||
showAlert("Gestartet", `Modell-Upgrade für '${role}' (${repo}) gestartet.`)
|
||||
refreshJobs()
|
||||
setActiveTab("maintenance")
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", `Fehler beim Starten des Modell-Upgrades: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerReboot() {
|
||||
showConfirm(
|
||||
"Reboot bestätigen",
|
||||
"Bist du sicher, dass du das gesamte Host-System neu starten willst?",
|
||||
async () => {
|
||||
try {
|
||||
await api("/api/maintenance/reboot", { method: "POST" })
|
||||
showAlert("Reboot", "Reboot ausgelöst. System startet neu...", () => onClose())
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", `Fehler beim Reboot: ${e.message}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function doBackup() {
|
||||
setBackupRunning(true)
|
||||
setBackupMsg("Snapshot wird erzeugt...")
|
||||
try {
|
||||
const r = await api<{ ok: boolean; snapshot: string; files: string[] }>("/api/system/backup", { method: "POST" })
|
||||
setBackupMsg(r.ok ? `Snapshot erzeugt: ${r.snapshot} (${r.files.length} Komponenten)` : "Backup fehlgeschlagen.")
|
||||
invalidate(qc, qk.zeitmaschine)
|
||||
} catch (e: any) {
|
||||
setBackupMsg(`Fehler: ${e.message}`)
|
||||
} finally {
|
||||
setBackupRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function restartService(serviceId: string) {
|
||||
setRestartingServices(prev => ({ ...prev, [serviceId]: true }))
|
||||
try {
|
||||
const res = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ service: serviceId })
|
||||
})
|
||||
if (res.ok) {
|
||||
showAlert("Dienst neu gestartet", `Dienst ${serviceId} wurde erfolgreich neu gestartet.`, () => {
|
||||
if (activeTab === "logs" && selectedService === serviceId) {
|
||||
loadServiceLogs(serviceId)
|
||||
}
|
||||
})
|
||||
invalidate(qc, qk.services, qk.health, qk.models)
|
||||
} else {
|
||||
showAlert("Fehler", `Fehler beim Neustart: ${res.err || "Unbekannter Fehler"}`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", `Fehler beim Neustart: ${e.message}`)
|
||||
} finally {
|
||||
setRestartingServices(prev => ({ ...prev, [serviceId]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelJob(jobId: string) {
|
||||
try {
|
||||
await api(`/api/jobs/${jobId}/cancel`, { method: "POST" })
|
||||
refreshJobs()
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", `Fehler beim Abbrechen: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Aktiver Wartungs-Job (System-Update) → UI-Aktionen sperren, Dashboard bleibt sichtbar.
|
||||
const maintenanceJob = jobs.find(j => (j.state === "running" || j.state === "queued") && j.group === "maintenance")
|
||||
// Wie viele Update-ARTEN ausstehen (für den „Alle aktualisieren"-Button).
|
||||
const pendingAll =
|
||||
(updates ? (updates.os > 0 ? 1 : 0) + (updates.engine ? 1 : 0) + (updates.swap ? 1 : 0) : 0) +
|
||||
(updates?.components?.filter((c) => c.update === true).length ?? 0)
|
||||
|
||||
const backups = zeit?.backups ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 bg-black/60 backdrop-blur-sm z-50 transition-opacity duration-300",
|
||||
open ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
|
||||
)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 right-0 w-full sm:w-[640px] bg-card/85 backdrop-blur-xl border-l border-border/60 shadow-2xl z-50 flex flex-col transform transition-transform duration-300 ease-in-out font-sans text-foreground",
|
||||
open ? "translate-x-0" : "translate-x-full"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex h-16 shrink-0 items-center justify-between border-b border-border/40 px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold tracking-wide uppercase font-space">OS-Zentrale & Pflege</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="System-Schublade schließen"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex border-b border-border/40 px-6">
|
||||
{(["maintenance", "logs", "settings"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"flex-1 py-3 text-xs font-semibold uppercase tracking-wider border-b-2 text-center transition-all",
|
||||
activeTab === tab
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{tab === "maintenance" ? "System-Wartung" : tab === "logs" ? "System-Logs" : "Einstellungen"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{activeTab === "maintenance" && (
|
||||
<>
|
||||
{/* Updates */}
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Updates</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
{pendingAll > 0 && (
|
||||
<button onClick={triggerUpdateAll} disabled={!!maintenanceJob}
|
||||
className="flex items-center gap-1 rounded-md border border-primary/40 bg-primary/10 px-2 py-1 text-[10px] font-bold text-primary transition-colors hover:bg-primary/20 disabled:opacity-50">
|
||||
<DownloadCloud className="h-3 w-3" /> Alle aktualisieren ({pendingAll})
|
||||
</button>
|
||||
)}
|
||||
<button onClick={triggerCheckUpdates} disabled={checkingUpdates || !!maintenanceJob}
|
||||
className="flex items-center gap-1 text-[10px] font-semibold text-primary hover:text-primary/80 transition-colors disabled:opacity-50">
|
||||
<RefreshCw className={cn("h-3 w-3", checkingUpdates && "animate-spin")} /> Nach Updates suchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{updates?.last_check && (
|
||||
<div className="text-[9px] text-muted-foreground -mt-1">Zuletzt gesucht: {formatLastCheck(updates.last_check)}</div>
|
||||
)}
|
||||
{maintenanceJob && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-300">
|
||||
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
<span>Update läuft: <span className="font-semibold">{maintenanceJob.label}</span> — bitte warten. Weitere Updates sind solange gesperrt.</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<UpdateRow icon={Shield} iconClass="text-cyan-400" name="OS-Pakete (apt)" available={!!updates?.os} status={updates?.os ? `${updates.os} verfügbar` : "aktuell"} actionLabel="Anzeigen" onAction={() => openUpdateDetails("os")} />
|
||||
<UpdateRow icon={Server} iconClass="text-violet-400" name="Inferenz-Engine (llama.cpp)" available={!!updates?.engine} status={updates?.engine ? "Update verfügbar" : "aktuell"} actionLabel="Anzeigen" onAction={() => openUpdateDetails("engine")} />
|
||||
<UpdateRow icon={Shuffle} iconClass="text-fuchsia-400" name="Router (llama-swap)" available={!!updates?.swap} status={updates?.swap ? "Update verfügbar" : "aktuell"} actionLabel="Anzeigen" onAction={() => openUpdateDetails("swap")} />
|
||||
{(() => {
|
||||
const h = updates?.components?.find((c) => c.key === "hermes_agent")
|
||||
return (
|
||||
<UpdateRow icon={Bot} iconClass="text-amber-400" name="Hermes-Agent" available={h?.update === true} busy={hermesUpdating} status={h?.update === true ? `Update: ${h.latest}` : h?.reachable === false ? "offline" : "aktuell"} actionLabel="Anzeigen" onAction={() => openUpdateDetails("hermes")} />
|
||||
)
|
||||
})()}
|
||||
{updates?.model_list?.map((m) => (
|
||||
<UpdateRow key={m.role} icon={Box} iconClass="text-emerald-400" name={`Modell · ${m.role}`} available={true} status={m.title} actionLabel="Upgrade" onAction={() => triggerModelUpgrade(m.repo, m.role)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dienste — 1:1 die Backend-Liste (inkl. LLM-Gateway + Wächter seit UMBAU v3) */}
|
||||
<div className="space-y-2.5">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Dienste</h3>
|
||||
<div className="space-y-1.5">
|
||||
{svcList.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border/50 p-4 text-center text-xs text-muted-foreground">
|
||||
Dienste-Liste wird geladen …
|
||||
</div>
|
||||
)}
|
||||
{svcList.map((s) => (
|
||||
<ServiceRow key={s.unit} label={s.name} system={s.scope === "system"} ok={s.ok}
|
||||
busy={restartingServices[s.unit]}
|
||||
onRestart={() => restartService(s.unit)}
|
||||
onLogs={() => { setSelectedService(s.unit); setActiveTab("logs") }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backup — Liste kommt aus derselben Quelle wie die Zeitmaschine (Chronik) */}
|
||||
<div className="space-y-2.5">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Backup</h3>
|
||||
<div className="rounded-lg border border-border/50 bg-background/20 px-3 py-2.5 flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-foreground truncate">{backups[0] ? `Letztes: ${backups[0].snapshot}` : "Noch kein Backup"}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{backups.length} Snapshots · Wiederherstellen: Chronik → Zeitmaschine</div>
|
||||
</div>
|
||||
<button onClick={doBackup} disabled={backupRunning} className="flex items-center gap-1.5 text-[11px] font-semibold rounded-lg px-2.5 py-1 bg-primary text-primary-foreground hover:opacity-90 transition-all cursor-pointer disabled:opacity-50 shrink-0">
|
||||
<Camera className={cn("h-3.5 w-3.5", backupRunning && "animate-pulse")} /> Snapshot
|
||||
</button>
|
||||
</div>
|
||||
{backupMsg && (
|
||||
<div className="text-[10px] font-mono text-primary bg-primary/5 p-2 rounded-lg border border-primary/20 break-all leading-normal">{backupMsg}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gefahrenzone */}
|
||||
<div className="space-y-2.5">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-red-400/80">Gefahrenzone</h3>
|
||||
<button onClick={triggerReboot} className="flex w-full items-center gap-3 p-3 rounded-lg border border-red-500/20 bg-red-500/5 hover:bg-red-500/10 text-red-400 transition-all text-left text-xs font-semibold">
|
||||
<Power className="h-4.5 w-4.5" />
|
||||
<div>
|
||||
<div>Host-System neu starten</div>
|
||||
<div className="text-[10px] text-red-400/80 font-normal">Startet die ganze Box neu</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Active Jobs */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Hintergrund-Aufgaben</h3>
|
||||
<span className="text-[10px] font-mono bg-primary/10 text-primary px-1.5 py-0.5 rounded-full">
|
||||
{jobs.filter(j => j.state === "running" || j.state === "queued").length} Aktiv
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{jobs.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-xl p-6 text-center">
|
||||
Aktuell keine aktiven Hintergrund-Jobs.
|
||||
</div>
|
||||
) : (
|
||||
jobs.map((job) => {
|
||||
const isActive = job.state === "running" || job.state === "queued"
|
||||
return (
|
||||
<div
|
||||
key={job.id}
|
||||
className={cn(
|
||||
"p-3 rounded-xl border transition-all duration-300",
|
||||
isActive
|
||||
? "border-primary/40 bg-primary/5 shadow-md shadow-primary/5"
|
||||
: "border-border/40 bg-background/20 opacity-80"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-semibold flex items-center gap-1.5">
|
||||
{isActive && (
|
||||
<span className="flex h-2 w-2 relative">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
||||
</span>
|
||||
)}
|
||||
{job.label}
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-muted-foreground uppercase flex items-center gap-2">
|
||||
<span>ID: {job.id}</span>
|
||||
<span>•</span>
|
||||
<span className={cn(
|
||||
job.state === "done" && "text-emerald-400",
|
||||
job.state === "failed" && "text-red-400",
|
||||
job.state === "running" && "text-primary",
|
||||
job.state === "queued" && "text-amber-400",
|
||||
job.state === "canceled" && "text-muted-foreground"
|
||||
)}>
|
||||
{job.state}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
<button
|
||||
onClick={() => cancelJob(job.id)}
|
||||
className="text-[10px] font-semibold text-red-400 hover:text-red-300 border border-red-500/20 bg-red-500/5 hover:bg-red-500/10 px-2 py-0.5 rounded transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{job.state === "running" && (
|
||||
<div className="mt-3 space-y-1">
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500"
|
||||
style={{ width: `${job.progress ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-[9px] font-mono text-muted-foreground">
|
||||
<span>{job.progress ?? 0}%</span>
|
||||
{job.done_bytes != null && job.total_bytes != null && (
|
||||
<span>
|
||||
{fmtBytes(job.done_bytes)} / {fmtBytes(job.total_bytes)}
|
||||
{job.rate_bps != null && ` (${fmtBytes(job.rate_bps)}/s)`}
|
||||
</span>
|
||||
)}
|
||||
{job.eta_s != null && <span>ETA: {job.eta_s}s</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "logs" && (
|
||||
// Logs View
|
||||
<div className="flex flex-col h-full space-y-4">
|
||||
{/* Service Select & Restart — dieselbe Backend-Liste wie die Wartungs-Ansicht */}
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedService}
|
||||
onChange={(e) => setSelectedService(e.target.value)}
|
||||
className="flex-1 h-9 px-3 text-xs bg-background/40 border border-border/60 rounded-lg outline-none text-foreground font-semibold"
|
||||
>
|
||||
{svcList.map((s) => (
|
||||
<option key={s.unit} value={s.unit}>
|
||||
{s.name} ({s.scope === "system" ? "systemd-root" : "user"})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => restartService(selectedService)}
|
||||
disabled={restartingServices[selectedService]}
|
||||
className="flex h-9 px-3 items-center gap-1.5 text-xs font-semibold rounded-lg border border-border/60 bg-background/20 hover:border-primary/50 transition-colors disabled:opacity-50"
|
||||
title="Dienst neu starten"
|
||||
>
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", restartingServices[selectedService] && "animate-spin")} />
|
||||
Restart
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<LogConsole
|
||||
service={selectedService}
|
||||
text={logs}
|
||||
loading={loadingLogs}
|
||||
logStatus={logStatus}
|
||||
onRefresh={() => loadServiceLogs(selectedService)}
|
||||
onOpenSettings={() => setActiveTab("settings")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "settings" && <SettingsTab open={open} showAlert={showAlert} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detail && (
|
||||
<UpdateDetailModal detail={detail} maintenanceJob={maintenanceJob}
|
||||
onClose={() => setDetail(null)} onApply={applyFromDetail} />
|
||||
)}
|
||||
|
||||
{dialogElement}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
/** Rückfrage vor Schritten, die die Box umbauen (alles aktualisieren, zurückspielen, neu starten). */
|
||||
export function Bestaetigen({ offen, titel, text, knopf, gefahr, onJa, onNein, children }: {
|
||||
offen: boolean
|
||||
titel: string
|
||||
text: string
|
||||
knopf: string
|
||||
gefahr?: boolean
|
||||
onJa: () => void
|
||||
onNein: () => void
|
||||
children?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={offen} onOpenChange={(o) => !o && onNein()}>
|
||||
<DialogContent className="border-linie bg-panel sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="schild text-xl">{titel}</DialogTitle>
|
||||
<DialogDescription className="text-[15px] leading-relaxed text-text-2">{text}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{children}
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<Button variant="outline" onClick={onNein}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button variant={gefahr ? "gefahr" : "default"} onClick={onJa}>
|
||||
{knopf}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { lazy, Suspense, useState } from "react"
|
||||
import { cn } from "cn"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useHinweisAktion } from "@/lib/abfragen"
|
||||
import { post } from "@/lib/api"
|
||||
import { melden } from "@/lib/meldungen"
|
||||
import type { AktionsErgebnis, Hinweis, VerlaufEintrag } from "@/lib/typen"
|
||||
import { seit, vor } from "@/lib/zeit"
|
||||
|
||||
// Erst laden, wenn jemand ein Protokoll öffnet (Radix-Dialog gehört nicht ins Start-Bündel).
|
||||
const Protokollfenster = lazy(() => import("./Protokollfenster").then((m) => ({ default: m.Protokollfenster })))
|
||||
|
||||
/** Ein offener Punkt: Titel · Leitpunkte · PRÜFEN/JETZT, darunter Text und Knöpfe. */
|
||||
function Punkt({ h }: { h: Hinweis }) {
|
||||
const aktion = useHinweisAktion()
|
||||
const [protokoll, setProtokoll] = useState<{ titel: string; text: string } | null>(null)
|
||||
const rot = h.stufe === "rot"
|
||||
|
||||
async function ausloesen(aid: string, label: string) {
|
||||
if (aid === "protokoll") {
|
||||
try {
|
||||
const e = await post<AktionsErgebnis>(`/api/hinweise/${encodeURIComponent(h.id)}/aktion/protokoll`)
|
||||
setProtokoll({ titel: h.titel, text: e.text || e.out || e.err || "Kein Protokoll vorhanden." })
|
||||
} catch (f) {
|
||||
melden("fehler", (f as Error).message)
|
||||
}
|
||||
return
|
||||
}
|
||||
aktion.mutate({ hinweis: h.id, aktion: aid, label })
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="flex flex-col gap-2.5 border-t border-linie pt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="min-w-0 font-sans text-[17px] leading-snug font-semibold">{h.titel}</h3>
|
||||
<span aria-hidden className="hidden h-0 flex-grow border-b-2 border-dotted border-linie-stark sm:block" />
|
||||
<span className={cn("schild shrink-0 text-base", rot ? "text-rot" : "text-bernstein")}>
|
||||
{rot ? "Jetzt" : "Prüfen"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="ziffern text-[13px] text-text-3">
|
||||
{seit(h.seit)} · zuletzt {vor(h.zuletzt)}
|
||||
</p>
|
||||
{h.text && <p className="text-[15px] leading-relaxed break-words text-text-2">{h.text}</p>}
|
||||
{h.aktionen.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2.5 pt-1">
|
||||
{h.aktionen.map((a, i) => (
|
||||
<Button
|
||||
key={a.id}
|
||||
variant={i === 0 && a.id !== "protokoll" ? "default" : "outline"}
|
||||
size="sm"
|
||||
disabled={aktion.isPending}
|
||||
onClick={() => ausloesen(a.id, a.label)}
|
||||
>
|
||||
{a.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{protokoll && (
|
||||
<Suspense fallback={null}>
|
||||
<Protokollfenster offen titel={protokoll.titel} text={protokoll.text} onSchliessen={() => setProtokoll(null)} />
|
||||
</Suspense>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function Checkliste({ hinweise, verlauf }: { hinweise: Hinweis[]; verlauf: VerlaufEintrag[] }) {
|
||||
const heute = new Date().toDateString()
|
||||
const erledigtHeute = verlauf.filter(
|
||||
(v) => (v.art === "erledigt" || v.art === "auto") && new Date(v.ts * 1000).toDateString() === heute,
|
||||
)
|
||||
if (hinweise.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 border-t border-linie pt-4">
|
||||
<p className="text-[15px] text-text-2">Keine offenen Punkte. Der Wächter prüft jede Minute.</p>
|
||||
{erledigtHeute.length > 0 && (
|
||||
<ul className="flex flex-col gap-1 text-sm text-text-3">
|
||||
{erledigtHeute.slice(0, 4).map((v) => (
|
||||
<li key={`${v.id}-${v.ts}`}>
|
||||
<span className="ziffern">{vor(v.ts)}</span> · {v.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{hinweise.map((h) => (
|
||||
<Punkt key={h.id} h={h} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
export type ChipArt = "gruen" | "bernstein" | "cyan" | "rot" | "grau"
|
||||
|
||||
const ART: Record<ChipArt, string> = {
|
||||
gruen: "border-gruen-rand bg-gruen-grund text-gruen",
|
||||
bernstein: "border-bernstein/60 bg-bernstein-grund text-bernstein",
|
||||
cyan: "border-cyan-rand bg-cyan-grund text-cyan",
|
||||
rot: "border-rot-rand bg-rot-grund text-rot-text",
|
||||
grau: "border-linie bg-erhaben text-text-2",
|
||||
}
|
||||
|
||||
/** Kleines Zustandsschild („GELADEN“, „PASST“, „FEHLGESCHLAGEN“). */
|
||||
export function Chip({ art = "grau", children }: { art?: ChipArt; children: ReactNode }) {
|
||||
return (
|
||||
<span className={cn("schild inline-flex h-7 items-center rounded-full border px-2.5 text-xs whitespace-nowrap", ART[art])}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Einheitlicher Platzhalter, solange Daten laden oder wenn die Box nicht antwortet. */
|
||||
export function Zustandsfeld({ fehler, text }: { fehler?: boolean; text: string }) {
|
||||
return (
|
||||
<div
|
||||
role={fehler ? "alert" : "status"}
|
||||
className={cn(
|
||||
"rounded-2xl border px-5 py-8 text-center text-[15px]",
|
||||
fehler ? "border-rot-rand bg-rot-grund text-rot-text" : "border-linie bg-panel text-text-2",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cn } from "cn"
|
||||
import type { FlugplanEintrag } from "@/lib/typen"
|
||||
import { flugplanZeit } from "@/lib/zeit"
|
||||
|
||||
const STATUS: Record<FlugplanEintrag["status"], { text: string; farbe: string }> = {
|
||||
erledigt: { text: "Erledigt", farbe: "text-gruen" },
|
||||
fehler: { text: "Fehler", farbe: "text-rot" },
|
||||
geplant: { text: "Geplant", farbe: "text-text-3" },
|
||||
}
|
||||
|
||||
function Zeile({ e }: { e: FlugplanEintrag }) {
|
||||
const s = STATUS[e.status] ?? STATUS.geplant
|
||||
// Updates sind das Wichtigste im Plan — sie stehen in Cyan.
|
||||
const farbe = e.status === "geplant" && /update/i.test(e.titel) ? "text-cyan" : s.farbe
|
||||
return (
|
||||
<li className="grid grid-cols-[112px_minmax(0,1fr)_auto] items-center gap-3 border-t border-linie py-2.5">
|
||||
<span className="ziffern text-sm text-text-3">{flugplanZeit(e.zeit)}</span>
|
||||
<span className="min-w-0 text-base">
|
||||
{e.titel}
|
||||
{e.text && <span className="text-text-3">, {e.text}</span>}
|
||||
</span>
|
||||
<span className={cn("schild text-[13px]", farbe)}>{s.text}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function Flugplan({ gelaufen, geplant }: { gelaufen: FlugplanEintrag[]; geplant: FlugplanEintrag[] }) {
|
||||
const alle = [...gelaufen, ...geplant.slice(0, 5)]
|
||||
if (alle.length === 0) return <p className="text-[15px] text-text-2">Heute ist noch nichts gelaufen, und nichts ist geplant.</p>
|
||||
return <ol className="m-0 flex list-none flex-col p-0">{alle.map((e) => <Zeile key={`${e.titel}-${e.zeit}`} e={e} />)}</ol>
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Rundinstrument: 240°-Bogen wie ein Zeigerinstrument, Wert als Leuchtbogen, Zahl in der Mitte.
|
||||
import { bogenPfad } from "@/lib/anzeige"
|
||||
|
||||
export interface Zone {
|
||||
von: number
|
||||
bis: number
|
||||
farbe: string
|
||||
}
|
||||
|
||||
export function Instrument({
|
||||
anteil,
|
||||
farbe = "var(--cyan)",
|
||||
zonen = [],
|
||||
mitte,
|
||||
unter,
|
||||
beschriftung,
|
||||
ariaText,
|
||||
}: {
|
||||
anteil: number | null
|
||||
farbe?: string
|
||||
zonen?: Zone[]
|
||||
mitte: string
|
||||
unter?: string
|
||||
beschriftung: string
|
||||
ariaText: string
|
||||
}) {
|
||||
const zeigen = anteil != null && anteil > 0.004
|
||||
return (
|
||||
<figure className="m-0 flex min-w-0 flex-col items-center gap-1">
|
||||
<svg viewBox="0 0 200 150" className="h-auto w-full max-w-[200px]" role="img" aria-label={ariaText}>
|
||||
<path d={bogenPfad(0, 1, 75)} fill="none" stroke="var(--linie)" strokeWidth={12} strokeLinecap="round" />
|
||||
{zonen.map((z) => (
|
||||
<path key={`${z.von}-${z.bis}`} d={bogenPfad(z.von, z.bis, 86)} fill="none" stroke={z.farbe} strokeWidth={4} strokeLinecap="round" />
|
||||
))}
|
||||
{zeigen && (
|
||||
<path d={bogenPfad(0, anteil, 75)} fill="none" stroke={farbe} strokeWidth={12} strokeLinecap="round" />
|
||||
)}
|
||||
<text x="100" y="100" textAnchor="middle" className="ziffern" style={{ fontSize: 34, fontWeight: 500, fill: "var(--foreground)" }}>
|
||||
{mitte}
|
||||
</text>
|
||||
{unter && (
|
||||
<text x="100" y="122" textAnchor="middle" style={{ fontFamily: "var(--font-anzeige)", fontSize: 14, fontWeight: 600, letterSpacing: "0.12em", fill: "var(--text-3)" }}>
|
||||
{unter.toUpperCase()}
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
<figcaption className="schild text-[15px] text-text-3">{beschriftung}</figcaption>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { cn } from "cn"
|
||||
import type { Lampe as LampenDaten, LampenZustand } from "@/lib/typen"
|
||||
|
||||
// Warnlampen wie im Flugzeug: Grün = läuft, dunkel = aus/auf Abruf, Bernstein = kümmern,
|
||||
// Cyan = Info, Rot = Störung. Bernstein und Rot leuchten, alles andere bleibt ruhig.
|
||||
const LAMPEN_STIL: Record<LampenZustand, { feld: string; name: string; wert: string }> = {
|
||||
ok: { feld: "border-gruen-rand bg-gruen-grund", name: "text-gruen", wert: "text-gruen-text" },
|
||||
aus: { feld: "border-aus-rand bg-aus-grund", name: "text-aus-text", wert: "text-text-3" },
|
||||
warn: { feld: "border-bernstein bg-bernstein-lampe lampe-atmet", name: "text-bernstein", wert: "text-[#f2c46b]" },
|
||||
info: { feld: "border-cyan-rand bg-cyan-grund", name: "text-cyan", wert: "text-cyan-text" },
|
||||
fehler: {
|
||||
feld: "border-rot bg-rot-grund shadow-[0_0_18px_rgba(255,90,79,0.35)]",
|
||||
name: "text-rot",
|
||||
wert: "text-rot-text",
|
||||
},
|
||||
}
|
||||
|
||||
const ZUSTAND_TEXT: Record<LampenZustand, string> = {
|
||||
ok: "in Ordnung",
|
||||
aus: "aus",
|
||||
warn: "braucht Aufmerksamkeit",
|
||||
info: "Info",
|
||||
fehler: "Störung",
|
||||
}
|
||||
|
||||
export function Lampe({ lampe }: { lampe: LampenDaten }) {
|
||||
const stil = LAMPEN_STIL[lampe.zustand] ?? LAMPEN_STIL.aus
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"flex min-h-[58px] flex-col items-center justify-center gap-0.5 rounded-lg border px-2 py-2 text-center",
|
||||
stil.feld,
|
||||
)}
|
||||
>
|
||||
<span className={cn("schild text-[17px] leading-none font-bold tracking-[0.18em]", stil.name)}>
|
||||
{lampe.label}
|
||||
</span>
|
||||
<span className={cn("ziffern text-xs uppercase", stil.wert)}>{lampe.wert}</span>
|
||||
<span className="sr-only">{ZUSTAND_TEXT[lampe.zustand]}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
/** Ein Feld des Instrumentenbretts: dunkle Platte, schmale Beschriftung oben links. */
|
||||
export function Panel({
|
||||
titel,
|
||||
rechts,
|
||||
children,
|
||||
className,
|
||||
id,
|
||||
}: {
|
||||
titel?: string
|
||||
rechts?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
id?: string
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
aria-label={titel}
|
||||
className={cn("flex min-w-0 flex-col gap-4 rounded-2xl border border-linie bg-panel p-5 sm:p-6", className)}
|
||||
>
|
||||
{(titel || rechts) && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{titel && <h2 className="schild text-lg font-bold tracking-[0.22em] text-foreground">{titel}</h2>}
|
||||
{rechts}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
|
||||
/** Seitenschublade mit Protokolltext (Journal oder Hermes-Fehlerzeilen), unten zuerst gelesen. */
|
||||
export function Protokollfenster({ offen, titel, text, onSchliessen }: {
|
||||
offen: boolean
|
||||
titel: string
|
||||
text: string
|
||||
onSchliessen: () => void
|
||||
}) {
|
||||
return (
|
||||
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
|
||||
<SheetContent side="right" className="w-full border-linie bg-panel sm:max-w-2xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="schild text-lg">Protokoll</SheetTitle>
|
||||
<SheetDescription className="text-text-2">{titel}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<pre className="ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2">
|
||||
{text}
|
||||
</pre>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { cn } from "cn"
|
||||
import type { Lampe as LampenDaten, Start } from "@/lib/typen"
|
||||
import { hauptleuchte } from "@/lib/anzeige"
|
||||
import { Lampe } from "./Lampe"
|
||||
|
||||
type Zustand = Start["zustand"]
|
||||
|
||||
const LEUCHTE: Record<ReturnType<typeof hauptleuchte>["art"], string> = {
|
||||
ok: "border-gruen-rand bg-gruen-grund text-gruen",
|
||||
info: "border-cyan-rand bg-cyan-grund text-cyan",
|
||||
stumm: "border-aus-rand bg-aus-grund text-aus-text",
|
||||
gelb: "border-2 border-bernstein bg-bernstein-grund text-bernstein-hell lampe-atmet",
|
||||
rot: "border-2 border-rot bg-rot-grund text-rot shadow-[0_0_32px_rgba(255,90,79,0.3)]",
|
||||
}
|
||||
|
||||
export function Warnpanel({ zustand, lampen, onAnsehen }: {
|
||||
zustand: Zustand
|
||||
lampen: LampenDaten[]
|
||||
onAnsehen: () => void
|
||||
}) {
|
||||
const h = hauptleuchte(zustand)
|
||||
const aktiv = h.art === "gelb" || h.art === "rot"
|
||||
return (
|
||||
<section aria-label="Warnpanel" className="grid gap-4 md:grid-cols-[260px_minmax(0,1fr)] md:gap-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAnsehen}
|
||||
disabled={!aktiv}
|
||||
aria-label={`${h.oben} ${h.mitte}`}
|
||||
className={cn(
|
||||
"flex min-h-[130px] flex-col items-center justify-center gap-1 rounded-2xl border px-4 py-4 font-anzeige transition-colors",
|
||||
LEUCHTE[h.art],
|
||||
aktiv ? "cursor-pointer" : "cursor-default",
|
||||
)}
|
||||
>
|
||||
<span className="schild text-sm tracking-[0.26em]">{h.oben}</span>
|
||||
<span className="text-[40px] leading-none font-bold tracking-[0.04em] sm:text-[44px]">{h.mitte}</span>
|
||||
<span className="font-sans text-sm opacity-80">{h.unten}</span>
|
||||
</button>
|
||||
<ul className="grid grid-cols-2 gap-2.5 rounded-2xl border border-linie bg-panel p-3 sm:grid-cols-4">
|
||||
{lampen.map((l) => (
|
||||
<Lampe key={l.id} lampe={l} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { laufzeit } from "@/lib/zeit"
|
||||
|
||||
function Ziffer({ z }: { z: string }) {
|
||||
return (
|
||||
<span className="ziffern inline-flex h-14 w-[38px] items-center justify-center rounded-md border border-[#2a3036] bg-[#0b0d0f] text-[32px] font-medium">
|
||||
{z}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Laufzeit als mechanisches Zählwerk: TT T SS H. */
|
||||
export function Zaehlwerk({ sekunden }: { sekunden: number | null | undefined }) {
|
||||
const lz = laufzeit(sekunden)
|
||||
const tage = String(Math.min(lz?.[0] ?? 0, 99)).padStart(2, "0")
|
||||
const std = String(lz?.[1] ?? 0).padStart(2, "0")
|
||||
const text = lz ? `Laufzeit ohne Neustart: ${lz[0]} Tage, ${lz[1]} Stunden` : "Laufzeit unbekannt"
|
||||
return (
|
||||
<figure className="m-0 flex min-w-0 flex-col items-center justify-end gap-1">
|
||||
<div aria-label={text} role="img" className="flex h-[150px] max-w-full items-center gap-1.5 pb-5">
|
||||
<Ziffer z={lz ? tage[0] : "–"} />
|
||||
<Ziffer z={lz ? tage[1] : "–"} />
|
||||
<span className="schild mr-2 ml-0.5 text-lg font-bold text-text-3">T</span>
|
||||
<Ziffer z={lz ? std[0] : "–"} />
|
||||
<Ziffer z={lz ? std[1] : "–"} />
|
||||
<span className="schild ml-0.5 text-lg font-bold text-text-3">H</span>
|
||||
</div>
|
||||
<figcaption className="schild text-[15px] text-text-3">Ohne Neustart</figcaption>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { bogenPfad, hauptleuchte } from "@/lib/anzeige"
|
||||
import { Warnpanel } from "./Warnpanel"
|
||||
import type { Start } from "@/lib/typen"
|
||||
|
||||
const ruhig: Start["zustand"] = { stufe: "ok", anzahl: 0, waechter_wach: true, stand: 1, update_laeuft: false }
|
||||
|
||||
describe("bogenPfad", () => {
|
||||
it("beginnt links unten und endet bei vollem Ausschlag rechts unten", () => {
|
||||
expect(bogenPfad(0, 1, 75)).toBe("M 35.05 132.5 A 75 75 0 1 1 164.95 132.5")
|
||||
})
|
||||
|
||||
it("zeichnet 45 % Speicher als kleinen Bogen bis kurz vor die Spitze", () => {
|
||||
// 45 % von 240° = 108° → Endwinkel 102°, also knapp links der Senkrechten.
|
||||
expect(bogenPfad(0, 0.45, 75)).toBe("M 35.05 132.5 A 75 75 0 0 1 84.41 21.64")
|
||||
})
|
||||
|
||||
it("klemmt Werte außerhalb von 0…1", () => {
|
||||
expect(bogenPfad(-1, 2, 75)).toBe(bogenPfad(0, 1, 75))
|
||||
})
|
||||
})
|
||||
|
||||
describe("hauptleuchte", () => {
|
||||
it("bleibt ruhig, wenn nichts offen ist", () => {
|
||||
expect(hauptleuchte(ruhig)).toMatchObject({ art: "ok", mitte: "IN ORDNUNG" })
|
||||
})
|
||||
|
||||
it("zeigt Bernstein bei gelben und Rot bei roten Hinweisen", () => {
|
||||
expect(hauptleuchte({ ...ruhig, stufe: "gelb", anzahl: 2 })).toMatchObject({ art: "gelb", mitte: "2 HINWEISE" })
|
||||
expect(hauptleuchte({ ...ruhig, stufe: "rot", anzahl: 1 })).toMatchObject({ art: "rot", mitte: "1 HINWEIS" })
|
||||
})
|
||||
|
||||
it("meldet einen schweigenden Wächter vor allem anderen", () => {
|
||||
expect(hauptleuchte({ ...ruhig, waechter_wach: false, anzahl: 3, stufe: "rot" }).art).toBe("stumm")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Warnpanel", () => {
|
||||
it("zeigt jede Lampe mit Name und Wert", () => {
|
||||
render(
|
||||
<Warnpanel
|
||||
zustand={{ ...ruhig, stufe: "gelb", anzahl: 2 }}
|
||||
lampen={[
|
||||
{ id: "motor", label: "Motor", zustand: "ok", wert: "b11057" },
|
||||
{ id: "jobs", label: "Jobs", zustand: "warn", wert: "2 Hinweise" },
|
||||
]}
|
||||
onAnsehen={() => {}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText("Motor")).toBeInTheDocument()
|
||||
expect(screen.getByText("b11057")).toBeInTheDocument()
|
||||
expect(screen.getByText("braucht Aufmerksamkeit")).toBeInTheDocument()
|
||||
expect(screen.getByRole("button", { name: /Achtung 2 HINWEISE/ })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
@@ -1,134 +0,0 @@
|
||||
import { Gauge } from "lucide-react"
|
||||
import { useVoiceTrace } from "@/lib/queries"
|
||||
import type { VoiceTurn } from "@/lib/api"
|
||||
|
||||
// Balken-Stufen in kanonischer Reihenfolge (zeitlich disjunkt). Der Gedächtnis-Abruf läuft
|
||||
// Hermes-intern und ist für MC2 nicht messbar — er steckt in „Hirn".
|
||||
const SEGMENTS = [
|
||||
{ key: "stt_ms", label: "STT", color: "#f59e0b" },
|
||||
{ key: "vision_ms", label: "Sehen", color: "#a78bfa" },
|
||||
{ key: "hirn_ms", label: "Hirn", color: "#2dd4bf" },
|
||||
{ key: "gen_ms", label: "Antwort", color: "#60a5fa" },
|
||||
] as const
|
||||
|
||||
const fmt = (ms: number | null | undefined): string =>
|
||||
ms == null ? "–" : `${(ms / 1000).toLocaleString("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 })} s`
|
||||
|
||||
function ago(ts: number): string {
|
||||
const s = Math.max(0, Date.now() / 1000 - ts)
|
||||
if (s < 60) return `vor ${Math.round(s)} s`
|
||||
if (s < 3600) return `vor ${Math.round(s / 60)} min`
|
||||
return `vor ${Math.round(s / 3600)} h`
|
||||
}
|
||||
|
||||
/** Summe der sichtbaren Balken-Stufen (STT gehört zur gefühlten Latenz dazu; total_ms des Backends
|
||||
* misst nur den Chat-Request ohne STT). So stimmen Balkenbreite und angezeigte Zahl immer überein. */
|
||||
function rowTotal(t: VoiceTurn): number {
|
||||
return SEGMENTS.reduce((sum, s) => sum + ((t[s.key] as number | null) ?? 0), 0)
|
||||
}
|
||||
|
||||
/** Dominante Stufe eines Turns = der „Täter". */
|
||||
function culprit(t: VoiceTurn): { label: string; color: string; ms: number } | null {
|
||||
let best: { label: string; color: string; ms: number } | null = null
|
||||
for (const s of SEGMENTS) {
|
||||
const ms = t[s.key] as number | null
|
||||
if (ms != null && (best == null || ms > best.ms)) best = { label: s.label, color: s.color, ms }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function TurnRow({ t, scale }: { t: VoiceTurn; scale: number }) {
|
||||
const title = SEGMENTS.map((s) => `${s.label} ${fmt(t[s.key] as number | null)}`).join(" · ")
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="w-14 shrink-0 text-right font-mono text-[10px] text-muted-foreground/60">{ago(t.ts)}</span>
|
||||
<div className="relative h-4 flex-1 overflow-hidden rounded-sm bg-muted/25" title={title}>
|
||||
<div className="absolute inset-0 flex">
|
||||
{SEGMENTS.map((s) => {
|
||||
const ms = t[s.key] as number | null
|
||||
if (!ms || ms <= 0) return null
|
||||
return (
|
||||
<div
|
||||
key={s.key}
|
||||
style={{ width: `${(ms / scale) * 100}%`, background: s.color }}
|
||||
className="h-full first:rounded-l-sm"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{t.error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-rose-500/15 text-[10px] font-semibold text-rose-300">
|
||||
Fehler
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="w-12 shrink-0 text-right font-mono text-[11px] font-semibold tabular-nums text-foreground">
|
||||
{t.error ? "–" : fmt(rowTotal(t))}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LatencyCard() {
|
||||
const { data: turns } = useVoiceTrace(12)
|
||||
const list = turns ?? []
|
||||
const scale = Math.max(1, ...list.map((t) => rowTotal(t)))
|
||||
const latest = list[0]
|
||||
const lead = latest ? culprit(latest) : null
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Gauge className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Latenz je Turn</h2>
|
||||
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live
|
||||
</span>
|
||||
</div>
|
||||
{latest ? (
|
||||
<div className="mt-2 flex items-baseline gap-2">
|
||||
<span className={`font-space text-3xl font-bold tracking-tight tabular-nums ${latest.error ? "text-rose-400" : "text-foreground"}`}>
|
||||
{latest.error ? "Fehler" : fmt(rowTotal(latest))}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
letzter Turn{!latest.error && lead && <> · Täter: <span style={{ color: lead.color }} className="font-semibold">{lead.label}</span> {fmt(lead.ms)}</>}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 text-xs text-muted-foreground">Noch kein Voice-Turn aufgezeichnet.</div>
|
||||
)}
|
||||
{latest && !latest.error && (
|
||||
<div className="mt-0.5 font-mono text-[11px] text-muted-foreground/70">
|
||||
{SEGMENTS.filter((s) => (latest[s.key] as number | null) != null).map((s) => `${s.label} ${fmt(latest[s.key] as number)}`).join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1 pt-1">
|
||||
{SEGMENTS.map((s) => (
|
||||
<div key={s.key} className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full" style={{ background: s.color }} />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{list.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{list.map((t) => <TurnRow key={t.id} t={t} scale={scale} />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-[120px] items-center justify-center px-6 text-center text-xs text-muted-foreground">
|
||||
Sprich einmal mit Lucy — dann erscheint hier der Zeit-Wasserfall pro Turn, damit man den einen Hänger sofort sieht.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 border-t border-border/30 pt-2 text-[10px] leading-relaxed text-muted-foreground/70">
|
||||
„Hirn" = Zeit bis zum ersten Wort (Agent + Gedächtnis-Suche + Modell); Tool-Runden stecken in „Antwort". Reine
|
||||
Telegram-Turns laufen an MC2 vorbei und erscheinen hier nicht.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import { LiveAreaChart } from "./LiveAreaChart"
|
||||
import type { ChartSeries } from "./chartTypes"
|
||||
|
||||
const SERIEN: ChartSeries[] = [{ key: "cpu", label: "CPU", color: "#2dd4bf" }]
|
||||
const DATEN = [
|
||||
{ t: 1_700_000_000_000, cpu: 12 },
|
||||
{ t: 1_700_000_003_000, cpu: 34 },
|
||||
]
|
||||
|
||||
// Die Lazy-Huelle ist der Grund, warum Recharts nicht mehr im Startbuendel liegt (P0).
|
||||
// Wichtig ist dabei nur eines: Der Platzhalter muss GENAU so hoch sein wie das spaetere
|
||||
// Diagramm — sonst springt die Karte beim Nachladen.
|
||||
describe("LiveAreaChart (Lazy-Huelle)", () => {
|
||||
it("haelt vor dem Nachladen die volle Diagrammhoehe frei", () => {
|
||||
const { container } = render(<LiveAreaChart data={DATEN} series={SERIEN} height={176} />)
|
||||
const platzhalter = container.querySelector<HTMLElement>('[aria-hidden="true"]')
|
||||
expect(platzhalter).not.toBeNull()
|
||||
expect(platzhalter!.style.height).toBe("176px")
|
||||
})
|
||||
|
||||
it("uebernimmt eine abweichende Hoehe", () => {
|
||||
const { container } = render(<LiveAreaChart data={DATEN} series={SERIEN} height={96} />)
|
||||
expect(container.querySelector<HTMLElement>('[aria-hidden="true"]')!.style.height).toBe("96px")
|
||||
})
|
||||
|
||||
it("laedt die Recharts-Umsetzung nach und zeigt sie an", async () => {
|
||||
const { container } = render(<LiveAreaChart data={DATEN} series={SERIEN} height={176} />)
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".recharts-responsive-container")).not.toBeNull()
|
||||
})
|
||||
// Platzhalter ist verschwunden, sobald das Diagramm da ist.
|
||||
expect(container.querySelector('[aria-hidden="true"]')).toBeNull()
|
||||
})
|
||||
|
||||
it("ist fuer Screenreader stumm — der Platzhalter ist reine Optik", () => {
|
||||
render(<LiveAreaChart data={DATEN} series={SERIEN} height={176} />)
|
||||
expect(screen.queryByText(/l(ä|ae)dt/i)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Suspense, lazy } from "react"
|
||||
import type { LiveAreaChartProps } from "./chartTypes"
|
||||
|
||||
export type { ChartSeries } from "./chartTypes"
|
||||
|
||||
// Lazy-Hülle um die Recharts-Umsetzung (v3-Umbau P0).
|
||||
//
|
||||
// WARUM: Das Cockpit ist die Startseite und wird deshalb NICHT lazy geladen. Über
|
||||
// SystemStatusCard / TokenPerformanceCard zog es Recharts samt d3-Abhängigkeiten in das
|
||||
// Start-Bündel — gemessen ~95 kB gzip von 220 kB, für vier Flächendiagramme, die man erst
|
||||
// sieht, wenn die Seite längst steht. Jetzt kommt Recharts als eigener Chunk nach.
|
||||
//
|
||||
// Der Platzhalter hat exakt die Höhe des Diagramms (Prop `height`), damit beim Nachladen
|
||||
// nichts springt — die Karte ist von der ersten Zeichnung an so hoch wie am Ende.
|
||||
const Impl = lazy(() => import("./LiveAreaChartImpl"))
|
||||
|
||||
export function LiveAreaChart(props: LiveAreaChartProps) {
|
||||
return (
|
||||
<Suspense fallback={<ChartPlatzhalter height={props.height ?? 176} />}>
|
||||
<Impl {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
// Ruhiges Skelett statt Spinner: im LAN ist der Chunk in Millisekunden da, ein blinkender
|
||||
// Lade-Kringel wäre nur Unruhe. Die Linie deutet die Grundachse des Diagramms an.
|
||||
function ChartPlatzhalter({ height }: { height: number }) {
|
||||
return (
|
||||
<div style={{ height }} className="flex w-full items-end" aria-hidden="true">
|
||||
<div className="h-px w-full bg-border/40" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
|
||||
import type { LiveAreaChartProps } from "./chartTypes"
|
||||
|
||||
// Recharts-Umsetzung des Verlaufsgraphen. Diese Datei ist der EINZIGE Ort im Projekt, der
|
||||
// Recharts importiert — sie wird ausschließlich per lazy() aus LiveAreaChart.tsx geladen und
|
||||
// landet dadurch in einem eigenen Chunk statt im Start-Bündel (v3-Umbau P0).
|
||||
|
||||
const GRID = "rgba(130,130,150,0.14)"
|
||||
const AXIS = "rgba(130,130,150,0.85)"
|
||||
|
||||
function niceCeil(v: number): number {
|
||||
if (v <= 0) return 10
|
||||
const mag = Math.pow(10, Math.floor(Math.log10(v)))
|
||||
const n = v / mag
|
||||
const step = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10
|
||||
return step * mag
|
||||
}
|
||||
|
||||
function fmt(v: number, unit: string): string {
|
||||
const n = unit === "%" ? Math.round(v) : v >= 1000 ? `${(v / 1000).toFixed(1)}k` : Math.round(v).toString()
|
||||
return `${n}${unit}`
|
||||
}
|
||||
|
||||
function ChartTooltip({ active, payload, unit, label }: any) {
|
||||
if (!active || !payload?.length) return null
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 bg-popover/95 px-3 py-2 shadow-xl backdrop-blur">
|
||||
{Number.isFinite(label) && (
|
||||
<div className="mb-1 border-b border-border/40 pb-1 font-mono text-[10px] text-muted-foreground/80">
|
||||
{new Date(label).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{payload.map((p: any) => (
|
||||
<div key={p.dataKey} className="flex items-center gap-2 text-[11px] font-mono">
|
||||
<span className="h-2 w-2 rounded-full" style={{ background: p.color }} />
|
||||
<span className="uppercase tracking-wider text-muted-foreground">{p.name}</span>
|
||||
<span className="ml-auto pl-3 font-bold tabular-nums text-foreground">{fmt(p.value, unit)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Wiederverwendbarer Live-Verlaufsgraph (Recharts Area, glatte Splines, Gradient-Fill,
|
||||
* Hover-Tooltip). yMode='percent' → 0..100 in 25er-Schritten; 'auto' → dynamisch (nice).
|
||||
* showTime=true blendet die Zeitachse ein (t in ms; Label-Format folgt der Spannweite). */
|
||||
export default function LiveAreaChartImpl({
|
||||
data, series, unit = "%", yMode = "percent", height = 176, showTime = false,
|
||||
}: LiveAreaChartProps) {
|
||||
const peak = data.reduce(
|
||||
(m, row) => series.reduce((mm, s) => Math.max(mm, Number(row[s.key]) || 0), m), 0
|
||||
)
|
||||
const yMax = yMode === "percent"
|
||||
? Math.min(100, Math.max(25, Math.ceil((peak * 1.2) / 25) * 25))
|
||||
: Math.max(niceCeil(peak * 1.15), 10)
|
||||
|
||||
// Kurze Fenster (Live ≈ 2 min) brauchen Sekunden, lange (1 h / 24 h) nur HH:MM.
|
||||
const spanMs = data.length > 1 ? Number(data[data.length - 1]?.t) - Number(data[0]?.t) : 0
|
||||
const fmtTime = (v: number) =>
|
||||
new Date(v).toLocaleTimeString("de-DE",
|
||||
spanMs <= 10 * 60_000 ? { hour: "2-digit", minute: "2-digit", second: "2-digit" }
|
||||
: { hour: "2-digit", minute: "2-digit" })
|
||||
|
||||
return (
|
||||
<div style={{ height }} className="w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 8, right: 6, bottom: 0, left: -12 }}>
|
||||
<defs>
|
||||
{series.map((s) => (
|
||||
<linearGradient key={s.key} id={`grad-${s.key}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={s.color} stopOpacity={0.22} />
|
||||
<stop offset="100%" stopColor={s.color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} stroke={GRID} />
|
||||
{showTime ? (
|
||||
<XAxis
|
||||
dataKey="t" type="number" domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={fmtTime} tickCount={5} minTickGap={40} interval="preserveStartEnd"
|
||||
axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: AXIS }} height={18}
|
||||
/>
|
||||
) : (
|
||||
<XAxis dataKey="t" hide />
|
||||
)}
|
||||
<YAxis
|
||||
domain={[0, yMax]} ticks={[0, yMax / 2, yMax]} tickFormatter={(v) => fmt(v, unit)}
|
||||
width={42} axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: AXIS }}
|
||||
/>
|
||||
<Tooltip content={<ChartTooltip unit={unit} />} cursor={{ stroke: AXIS, strokeOpacity: 0.4, strokeDasharray: "3 3" }} />
|
||||
{series.map((s) => (
|
||||
<Area
|
||||
key={s.key} type="monotone" dataKey={s.key} name={s.label}
|
||||
stroke={s.color} strokeWidth={2} fill={`url(#grad-${s.key})`}
|
||||
dot={false} activeDot={{ r: 3, strokeWidth: 0 }}
|
||||
isAnimationActive={false} connectNulls
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Bereichs-Schalter der Leistungs-Karten: Live (2-min-Browser-Store) vs. 1h/24h
|
||||
// (Backend-Ringpuffer, /api/system/history). Bewusst winzig — kein Chart-Gebirge.
|
||||
export type ChartRange = "live" | "1h" | "24h"
|
||||
export const RANGE_MINUTES: Record<Exclude<ChartRange, "live">, number> = { "1h": 60, "24h": 1440 }
|
||||
|
||||
const LABELS: Record<ChartRange, string> = { live: "Live", "1h": "1 h", "24h": "24 h" }
|
||||
|
||||
export function RangeSwitch({ value, onChange }: { value: ChartRange; onChange: (r: ChartRange) => void }) {
|
||||
return (
|
||||
<div className="flex rounded-lg border border-border/40 bg-background/30 p-0.5">
|
||||
{(Object.keys(LABELS) as ChartRange[]).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => onChange(r)}
|
||||
className={cn(
|
||||
"rounded-md px-2 py-0.5 text-[9px] font-bold uppercase tracking-wide transition-all cursor-pointer",
|
||||
value === r ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{LABELS[r]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { Cpu } from "lucide-react"
|
||||
import { useSystemHistory } from "@/lib/useSystemHistory"
|
||||
import { useMetricHistory } from "@/lib/queries"
|
||||
import { gb } from "@/lib/format"
|
||||
import { LiveAreaChart, type ChartSeries } from "./LiveAreaChart"
|
||||
import { RangeSwitch, RANGE_MINUTES, type ChartRange } from "./RangeSwitch"
|
||||
|
||||
const SERIES: ChartSeries[] = [
|
||||
{ key: "cpu", label: "CPU", color: "#2dd4bf" },
|
||||
{ key: "ram", label: "RAM", color: "#38bdf8" },
|
||||
{ key: "gpu", label: "GPU", color: "#a78bfa" },
|
||||
{ key: "disk", label: "Disk", color: "#fbbf24" },
|
||||
]
|
||||
|
||||
export function SystemStatusCard() {
|
||||
const { sys, hist } = useSystemHistory()
|
||||
const [range, setRange] = useState<ChartRange>("live")
|
||||
const histQ = useMetricHistory(range === "live" ? 60 : RANGE_MINUTES[range], range !== "live")
|
||||
|
||||
// Live = 3-s-Browser-Store (~2 min); 1h/24h = Backend-Ringpuffer mit echten Zeitstempeln.
|
||||
const chartData = useMemo(() => {
|
||||
if (range === "live") return hist
|
||||
return (histQ.data?.points ?? []).map((p) => ({ t: p.t * 1000, cpu: p.cpu, ram: p.ram, gpu: p.gpu, disk: p.disk }))
|
||||
}, [range, hist, histQ.data])
|
||||
|
||||
const hasGpu = !!(sys?.gpu && sys.gpu.busy_percent != null && sys.gpu.gtt_used != null && sys.gpu.gtt_total != null)
|
||||
const activeSeries = SERIES.filter((s) => s.key !== "gpu" || hasGpu)
|
||||
|
||||
const current: Record<string, number | null | undefined> = {
|
||||
cpu: sys?.cpu?.percent, ram: sys?.ram?.percent,
|
||||
gpu: hasGpu ? sys!.gpu!.busy_percent : null, disk: sys?.disk?.percent,
|
||||
}
|
||||
const detail: Record<string, string> = {
|
||||
cpu: sys?.cpu?.cores ? `${sys.cpu.cores} Cores` : "",
|
||||
ram: sys ? `${gb(sys.ram.used)}/${gb(sys.ram.total)} GB` : "",
|
||||
gpu: hasGpu ? `${gb(sys!.gpu!.gtt_used!)}/${gb(sys!.gpu!.gtt_total!)} GB` : "",
|
||||
disk: sys?.disk ? `${gb(sys.disk.used)}/${gb(sys.disk.total)} GB` : "",
|
||||
}
|
||||
|
||||
return (
|
||||
<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" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">System-Status</h2>
|
||||
{range === "live" && (
|
||||
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto"><RangeSwitch value={range} onChange={setRange} /></div>
|
||||
</div>
|
||||
|
||||
{sys ? (
|
||||
<>
|
||||
<div className="mb-2 grid grid-cols-2 gap-x-4 gap-y-1.5">
|
||||
{activeSeries.map((s) => (
|
||||
<div key={s.key} className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: s.color }} />
|
||||
<span className="shrink-0 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{s.label}</span>
|
||||
<span className="shrink-0 font-mono text-xs font-bold tabular-nums text-foreground">{Math.round(current[s.key] ?? 0)}%</span>
|
||||
{detail[s.key] && <span className="truncate font-mono text-[10px] text-muted-foreground/60">{detail[s.key]}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{range !== "live" && chartData.length === 0 && (
|
||||
<div className="flex h-44 items-center justify-center text-xs text-muted-foreground">
|
||||
{histQ.isLoading ? "Verlauf wird geladen …" : "Noch kein Verlauf — der Sammler baut die Historie gerade erst auf."}
|
||||
</div>
|
||||
)}
|
||||
{(range === "live" || chartData.length > 0) && (
|
||||
<LiveAreaChart data={chartData} series={activeSeries} unit="%" yMode="percent" height={176} showTime />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-44 items-center justify-center text-xs text-muted-foreground">Lade Systemdaten…</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sys?.temp && (sys.temp.cpu || sys.temp.gpu) && (
|
||||
<div className="mt-3 flex gap-4 border-t border-border/30 pt-3 font-mono text-xs text-muted-foreground/80">
|
||||
{sys.temp.cpu != null && <span>CPU Temp: {sys.temp.cpu} °C</span>}
|
||||
{sys.temp.gpu != null && <span>GPU Temp: {sys.temp.gpu} °C</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { Activity } from "lucide-react"
|
||||
import { useTokenStats, useMetricHistory } from "@/lib/queries"
|
||||
import { useTokHistory } from "@/lib/metricsStore"
|
||||
import { LiveAreaChart, type ChartSeries } from "./LiveAreaChart"
|
||||
import { RangeSwitch, RANGE_MINUTES, type ChartRange } from "./RangeSwitch"
|
||||
|
||||
const SERIES: ChartSeries[] = [
|
||||
{ key: "prompt", label: "Prompt", color: "#f59e0b" },
|
||||
{ key: "completion", label: "Antwort", color: "#2dd4bf" },
|
||||
]
|
||||
|
||||
export function TokenPerformanceCard() {
|
||||
const { data: ts } = useTokenStats()
|
||||
const hist = useTokHistory() // Live-Durchsatz aus dem modul-globalen Store (~2 min)
|
||||
const [range, setRange] = useState<ChartRange>("live")
|
||||
const histQ = useMetricHistory(range === "live" ? 60 : RANGE_MINUTES[range], range !== "live")
|
||||
|
||||
// 1h/24h: Raten aus den Deltas der Gesamtzähler (tok/s zwischen zwei Sample-Punkten).
|
||||
const chartData = useMemo(() => {
|
||||
if (range === "live") return hist
|
||||
const pts = histQ.data?.points ?? []
|
||||
const out: { t: number; prompt: number; completion: number }[] = []
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const a = pts[i - 1], b = pts[i]
|
||||
if (a.tp == null || b.tp == null || a.tc == null || b.tc == null) continue
|
||||
const dt = Math.max(b.t - a.t, 1)
|
||||
out.push({
|
||||
t: b.t * 1000,
|
||||
prompt: Math.max(0, (b.tp - a.tp) / dt),
|
||||
completion: Math.max(0, (b.tc - a.tc) / dt),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}, [range, hist, histQ.data])
|
||||
|
||||
const last = hist[hist.length - 1]
|
||||
const curRate = last ? Math.round(last.prompt + last.completion) : 0
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Activity className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Token-Durchsatz</h2>
|
||||
{range === "live" && (
|
||||
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{ts && (
|
||||
<div className="mt-2 flex items-baseline gap-2">
|
||||
<span className="font-space text-3xl font-bold tracking-tight text-foreground tabular-nums">
|
||||
{curRate.toLocaleString("de-DE")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">tok/s aktuell</span>
|
||||
</div>
|
||||
)}
|
||||
{ts && (
|
||||
<div className="mt-0.5 space-y-0.5 font-mono text-[11px] text-muted-foreground/70">
|
||||
<div>
|
||||
{ts.total_tokens.toLocaleString("de-DE")} Tokens gesamt · <span className="text-emerald-400/90">{ts.saved_eur.toLocaleString("de-DE", { minimumFractionDigits: 2 })} € gespart</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground/55">
|
||||
Input {ts.prompt_tokens.toLocaleString("de-DE")} · Output {ts.completion_tokens.toLocaleString("de-DE")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-2 pt-1">
|
||||
<RangeSwitch value={range} onChange={setRange} />
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
{SERIES.map((s) => (
|
||||
<div key={s.key} className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full" style={{ background: s.color }} />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{s.label}</span>
|
||||
<span className="font-mono text-xs font-bold tabular-nums text-foreground">
|
||||
{Math.round((last?.[s.key as "prompt" | "completion"]) ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{range !== "live" && chartData.length === 0 ? (
|
||||
<div className="flex h-[150px] items-center justify-center text-xs text-muted-foreground">
|
||||
{histQ.isLoading ? "Verlauf wird geladen …" : "Noch kein Verlauf — der Sammler baut die Historie gerade erst auf."}
|
||||
</div>
|
||||
) : ts ? (
|
||||
<LiveAreaChart data={chartData} series={SERIES} unit=" tok/s" yMode="auto" height={150} showTime />
|
||||
) : (
|
||||
<div className="flex h-[150px] items-center justify-center text-xs text-muted-foreground">Lade Durchsatz…</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 border-t border-border/30 pt-2 text-[10px] text-muted-foreground/70">
|
||||
Durchsatz aus dem Gateway abgeleitet (Prefill vs. Generierung). Flach bei Leerlauf.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// Typen der Verlaufs-Diagramme. Bewusst eine eigene, winzige Datei: sie wird sowohl vom
|
||||
// Lazy-Wrapper (LiveAreaChart.tsx) als auch von der Recharts-Umsetzung (LiveAreaChartImpl.tsx)
|
||||
// gebraucht. Läge sie in einer der beiden, würde ein Typ-Import die Datei aneinander binden —
|
||||
// und genau das soll die Aufteilung ja verhindern.
|
||||
|
||||
export type ChartSeries = { key: string; label: string; color: string }
|
||||
|
||||
export interface LiveAreaChartProps {
|
||||
data: any[]
|
||||
series: ChartSeries[]
|
||||
unit?: string
|
||||
yMode?: "percent" | "auto"
|
||||
height?: number
|
||||
showTime?: boolean
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { api } from "@/lib/api"
|
||||
import { useJobs, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { fmtBytes, fmtEta } from "@/lib/format"
|
||||
|
||||
export function JobsBar() {
|
||||
const qc = useQueryClient()
|
||||
const { data: jobs = [] } = useJobs()
|
||||
const { showAlert, dialogElement } = useDialog()
|
||||
|
||||
async function cancelJob(jobId: string) {
|
||||
try {
|
||||
await api(`/api/jobs/${jobId}/cancel`, { method: "POST" })
|
||||
qc.invalidateQueries({ queryKey: qk.jobs })
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const active = jobs.filter((j) => j.state === "running" || j.state === "queued")
|
||||
const recent = jobs.filter((j) => j.state !== "running" && j.state !== "queued").slice(-3)
|
||||
|
||||
if (active.length === 0 && recent.length === 0) return null
|
||||
|
||||
return (
|
||||
<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) => (
|
||||
<div key={j.id} className="space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="font-semibold truncate max-w-[250px]">{j.label}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-muted-foreground font-mono">
|
||||
{j.progress ?? 0}% • {fmtBytes(j.done_bytes)}/{fmtBytes(j.total_bytes)}
|
||||
{j.eta_s ? ` • ETA ${fmtEta(j.eta_s)}` : ""}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => cancelJob(j.id)}
|
||||
className="text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all cursor-pointer"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary transition-all duration-500" style={{ width: `${j.progress ?? 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{recent.map((j) => (
|
||||
<div key={j.id} className="flex justify-between items-center text-xs text-muted-foreground px-1">
|
||||
<span className="truncate">{j.label}</span>
|
||||
<span className={cn(
|
||||
"font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono",
|
||||
j.state === "done" ? "bg-emerald-500/10 text-emerald-400" : "bg-amber-500/10 text-amber-400"
|
||||
)}>
|
||||
{j.state}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Sliders, RotateCcw, Check, Loader2, MessageSquare, Code2 } from "lucide-react"
|
||||
import { updateRoutingPolicy, type RoutingPolicy } from "@/lib/api"
|
||||
import { useRoutingPolicy, useQueryClient, qk } from "@/lib/queries"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Welche Policy-Felder zu welcher Lane gehören (Rest ist global).
|
||||
const CHAT_FIELDS: (keyof RoutingPolicy)[] = ["fast", "heavy", "heavy_chars"]
|
||||
const CODING_FIELDS: (keyof RoutingPolicy)[] = ["coder_lite", "coder", "coding_escalate_chars"]
|
||||
|
||||
export function LaneEditor() {
|
||||
const qc = useQueryClient()
|
||||
const { data: meta, isLoading } = useRoutingPolicy()
|
||||
const [draft, setDraft] = useState<RoutingPolicy | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [err, setErr] = useState("")
|
||||
const [savedAt, setSavedAt] = useState(0)
|
||||
|
||||
// Draft initialisieren, sobald die Policy geladen ist (und nicht überschreiben, wenn schon editiert).
|
||||
useEffect(() => {
|
||||
if (meta?.policy && !draft) setDraft({ ...meta.policy })
|
||||
}, [meta, draft])
|
||||
|
||||
if (isLoading || !meta || !draft) {
|
||||
return (
|
||||
<div className="mc-card p-5 text-xs text-muted-foreground">
|
||||
Lade Routing-Policy…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldSpec = (key: keyof RoutingPolicy) => meta.fields.find((f) => f.key === key)!
|
||||
const dirty = (Object.keys(draft) as (keyof RoutingPolicy)[]).some((k) => draft[k] !== meta.policy[k])
|
||||
|
||||
const set = <K extends keyof RoutingPolicy>(key: K, value: RoutingPolicy[K]) => {
|
||||
setDraft((d) => (d ? { ...d, [key]: value } : d))
|
||||
setErr("")
|
||||
}
|
||||
const resetField = (key: keyof RoutingPolicy) => set(key, meta.defaults[key])
|
||||
|
||||
async function save() {
|
||||
if (!draft) return
|
||||
const patch: Partial<RoutingPolicy> = {}
|
||||
for (const k of Object.keys(draft) as (keyof RoutingPolicy)[]) {
|
||||
if (draft[k] !== meta!.policy[k]) (patch as any)[k] = draft[k]
|
||||
}
|
||||
if (Object.keys(patch).length === 0) return
|
||||
setSaving(true)
|
||||
setErr("")
|
||||
try {
|
||||
const { policy } = await updateRoutingPolicy(patch)
|
||||
setDraft({ ...policy })
|
||||
qc.invalidateQueries({ queryKey: qk.routingPolicy })
|
||||
qc.invalidateQueries({ queryKey: qk.routing })
|
||||
setSavedAt(Date.now())
|
||||
setTimeout(() => setSavedAt(0), 2000)
|
||||
} catch (e: any) {
|
||||
setErr(e.message || String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function Field({ k }: { k: keyof RoutingPolicy }) {
|
||||
const spec = fieldSpec(k)
|
||||
const val = draft![k]
|
||||
const isDefault = draft![k] === meta!.defaults[k]
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground">{spec.label}</label>
|
||||
{!isDefault && (
|
||||
<button
|
||||
onClick={() => resetField(k)}
|
||||
className="flex items-center gap-0.5 text-[9px] text-muted-foreground/60 hover:text-foreground transition-colors cursor-pointer"
|
||||
title={`Auf Default zurücksetzen (${String(meta!.defaults[k]) || "leer"})`}
|
||||
>
|
||||
<RotateCcw className="h-2.5 w-2.5" /> Default
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{spec.type === "bool" ? (
|
||||
<button
|
||||
onClick={() => set(k, !val as any)}
|
||||
className={cn(
|
||||
"flex h-8 w-full items-center justify-between rounded-lg border px-3 text-[11px] font-semibold transition-all cursor-pointer",
|
||||
val ? "border-emerald-500/40 bg-emerald-500/10 text-emerald-300" : "border-border/40 bg-background/40 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span>{val ? "An" : "Aus"}</span>
|
||||
<span className={cn("h-3.5 w-3.5 rounded-full transition-colors", val ? "bg-emerald-400" : "bg-muted-foreground/40")} />
|
||||
</button>
|
||||
) : spec.type === "int" ? (
|
||||
<input
|
||||
type="number"
|
||||
value={val as number}
|
||||
min={spec.min}
|
||||
max={spec.max}
|
||||
aria-label={spec.label}
|
||||
onChange={(e) => set(k, Number(e.target.value) as any)}
|
||||
className="h-8 w-full rounded-lg border border-border/40 bg-background/40 px-3 font-mono text-[11px] text-foreground outline-none focus:border-primary/50"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={val as string}
|
||||
aria-label={spec.label}
|
||||
spellCheck={false}
|
||||
placeholder={k === "coder_lite" ? "(leer = aus)" : ""}
|
||||
onChange={(e) => set(k, e.target.value as any)}
|
||||
className="h-8 w-full rounded-lg border border-border/40 bg-background/40 px-3 font-mono text-[11px] text-foreground outline-none focus:border-primary/50"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<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" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground">Lane-Routing & Policy</span>
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-300 border border-emerald-500/20">hot-reload</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{err && <span className="text-[10px] text-red-400 max-w-[280px] truncate" title={err}>{err}</span>}
|
||||
{savedAt > 0 && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-emerald-400"><Check className="h-3.5 w-3.5" /> gespeichert</span>
|
||||
)}
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={!dirty || saving}
|
||||
className={cn(
|
||||
"h-8 px-4 rounded-lg text-[10px] font-bold uppercase tracking-wide transition-all flex items-center gap-1.5",
|
||||
dirty && !saving
|
||||
? "bg-primary text-primary-foreground hover:opacity-90 cursor-pointer shadow-md shadow-primary/10"
|
||||
: "bg-background/40 text-muted-foreground/50 border border-border/40 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground/70 leading-relaxed -mt-1">
|
||||
Welches echte Modell hinter den virtuellen Lanes <code className="text-cyan-300">chat</code> und{" "}
|
||||
<code className="text-cyan-300">coding</code> steckt. Änderungen greifen sofort (kein Neustart). Die
|
||||
Keyword-Heuristiken bleiben im Code.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* chat-Lane */}
|
||||
<div className="rounded-xl border border-border/40 bg-background/25 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 border-b border-border/30 pb-2">
|
||||
<MessageSquare className="h-4 w-4 text-teal-400" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-foreground">chat</span>
|
||||
<span className="text-[9px] text-muted-foreground/60 font-mono">(= auto)</span>
|
||||
</div>
|
||||
{CHAT_FIELDS.map((k) => <Field key={k} k={k} />)}
|
||||
</div>
|
||||
|
||||
{/* coding-Lane */}
|
||||
<div className="rounded-xl border border-border/40 bg-background/25 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 border-b border-border/30 pb-2">
|
||||
<Code2 className="h-4 w-4 text-indigo-400" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-foreground">coding</span>
|
||||
<span className="text-[9px] text-muted-foreground/60 font-mono">(agentisch → immer Coder)</span>
|
||||
</div>
|
||||
{CODING_FIELDS.map((k) => <Field key={k} k={k} />)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Globale Schalter */}
|
||||
<div className="rounded-xl border border-border/40 bg-background/25 p-4">
|
||||
<div className="max-w-xs"><Field k="fast_no_think" /></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { type Fit } from "@/lib/api"
|
||||
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, Workbench, …) stabil.
|
||||
export { ROLES, roleTone, roleMeta }
|
||||
|
||||
// Rollen-Badge in Klartext: Icon + Zweck-Name, technisches Kürzel als Tooltip.
|
||||
// `dense` = nur Kurzform (für enge Badges).
|
||||
export function RoleLabel({
|
||||
role,
|
||||
dense = false,
|
||||
className,
|
||||
}: {
|
||||
role?: string | null
|
||||
dense?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const meta = roleMeta(role)
|
||||
const Icon = meta.icon
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold border tracking-wide",
|
||||
meta.tone,
|
||||
className,
|
||||
)}
|
||||
title={`${meta.desc} (${meta.role})`}
|
||||
>
|
||||
<Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
{dense ? meta.short : meta.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function FitBadge({ fit }: { fit: Fit }) {
|
||||
const tone = {
|
||||
perfect: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/20",
|
||||
marginal: "bg-amber-500/15 text-amber-400 border border-amber-500/20",
|
||||
too_tight: "bg-red-500/15 text-red-400 border border-red-500/20",
|
||||
}[fit.level]
|
||||
return (
|
||||
<span className={cn("rounded px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider font-mono", tone)}>
|
||||
{fit.text} • {fit.req_gb} GB RAM
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function getBrandInfo(name: string) {
|
||||
const low = name.toLowerCase()
|
||||
if (low.includes("qwen")) return { name: "Qwen", color: "bg-purple-500/20 text-purple-300 border-purple-500/30", initial: "Q" }
|
||||
if (low.includes("gemma")) return { name: "Gemma", color: "bg-blue-500/20 text-blue-300 border-blue-500/30", initial: "G" }
|
||||
if (low.includes("llama")) return { name: "Llama", color: "bg-red-500/20 text-red-300 border-red-500/30", initial: "🦙" }
|
||||
if (low.includes("mistral") || low.includes("mixtral")) return { name: "Mistral", color: "bg-orange-500/20 text-orange-300 border-orange-500/30", initial: "M" }
|
||||
if (low.includes("deepseek")) return { name: "DeepSeek", color: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30", initial: "D" }
|
||||
if (low.includes("hermes") || low.includes("nous")) return { name: "Hermes", color: "bg-amber-500/20 text-amber-300 border-amber-500/30", initial: "H" }
|
||||
if (low.includes("phi")) return { name: "Phi", color: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30", initial: "Φ" }
|
||||
return { name: "Other", color: "bg-slate-500/20 text-slate-300 border-slate-500/30", initial: "AI" }
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
import { useState } from "react"
|
||||
import { X, Check, Zap, AlertTriangle } from "lucide-react"
|
||||
import { api, type ModelInfo, type DraftInfo } from "@/lib/api"
|
||||
import { useDrafts } from "@/lib/queries"
|
||||
import { fmtSize } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* Idiotensichere Speculative-Decoding-Konfiguration für EIN Modell.
|
||||
* Zeigt nur VOCAB-KOMPATIBLE Drafts als wählbar; inkompatible werden gesperrt
|
||||
* und mit Begründung angezeigt. So kann nie ein kaputter Draft gesetzt werden
|
||||
* (der das Modell beim Laden scheitern ließe).
|
||||
*/
|
||||
export function SpecDraftModal({
|
||||
model, onClose, onChanged,
|
||||
}: { model: ModelInfo; onClose: () => void; onChanged: () => void }) {
|
||||
const { data, isLoading } = useDrafts(model.gguf_path)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [err, setErr] = useState("")
|
||||
|
||||
const tv = data?.target_vocab
|
||||
const drafts = data?.drafts ?? []
|
||||
const compatibles = drafts.filter((d) => d.compatible === true)
|
||||
const currentFile = model.spec_draft_model
|
||||
|
||||
async function apply(draftPath: string | null) {
|
||||
setBusy(draftPath ?? "__clear__")
|
||||
setErr("")
|
||||
try {
|
||||
await api(`/api/models/${encodeURIComponent(model.name)}/draft`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ draft_path: draftPath }),
|
||||
})
|
||||
onChanged()
|
||||
onClose()
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message || e))
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const vocabLabel = (v?: { pre: string | null; n_vocab: number | null } | null) =>
|
||||
v ? `${v.pre ?? "?"} · ${v.n_vocab?.toLocaleString() ?? "?"} Tokens` : "—"
|
||||
|
||||
return (
|
||||
<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-lg 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-2">
|
||||
<Zap className="h-4 w-4" /> Speculative Draft
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground leading-relaxed">
|
||||
Beschleunigt die Token-Generierung mit einem kleinen "Draft"-Modell. Voraussetzung:
|
||||
der Draft muss den <strong className="text-foreground">exakt gleichen Tokenizer (Vocab)</strong> haben
|
||||
wie das Modell — sonst lehnt llama.cpp es ab.
|
||||
</div>
|
||||
|
||||
{/* Ziel-Vocab */}
|
||||
<div className="rounded-lg border border-border/40 bg-background/30 px-3 py-2 text-[11px] font-mono flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{model.name.split("/").pop()?.replace(/\.gguf$/i, "")}</span>
|
||||
<span className="text-foreground">Vocab: {vocabLabel(tv)}</span>
|
||||
</div>
|
||||
|
||||
{/* Aktueller Zustand */}
|
||||
{model.spec_active && currentFile && (
|
||||
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2 flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-emerald-400 font-mono flex items-center gap-1.5 truncate">
|
||||
<Check className="h-3.5 w-3.5 shrink-0" /> Aktiv: {currentFile}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => apply(null)}
|
||||
disabled={busy !== null}
|
||||
className="h-6 px-2.5 rounded border border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/10 text-amber-400 text-[9px] font-bold uppercase shrink-0 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
Deaktivieren
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!data?.target_exists && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-[11px] text-amber-400 flex items-center gap-2">
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||
Modell-GGUF noch nicht vorhanden — Spec-Draft ist nach dem Download konfigurierbar.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Draft-Liste */}
|
||||
<div className="space-y-1.5 max-h-64 overflow-y-auto pr-1">
|
||||
{isLoading ? (
|
||||
<div className="text-xs text-muted-foreground py-6 text-center">Prüfe Vocab-Kompatibilität…</div>
|
||||
) : drafts.length === 0 ? (
|
||||
<div className="text-[11px] text-muted-foreground border border-dashed border-border/50 rounded-lg p-4 text-center">
|
||||
Keine Draft-Modelle in <code className="font-mono">/srv/models/drafts</code>.
|
||||
Lade ein kleines same-family-Modell (z.B. Qwen3-0.6B) und lege es dort ab.
|
||||
</div>
|
||||
) : (
|
||||
drafts.map((d: DraftInfo) => {
|
||||
const isCurrent = d.filename === currentFile
|
||||
const ok = d.compatible === true
|
||||
return (
|
||||
<div
|
||||
key={d.path}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 flex items-center justify-between gap-2 text-left",
|
||||
ok ? "border-border/40 bg-background/20" : "border-border/20 bg-background/10 opacity-60",
|
||||
isCurrent && "border-primary/40 bg-primary/10",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-mono font-semibold text-foreground truncate flex items-center gap-1.5">
|
||||
{d.filename}
|
||||
{d.mtp && (
|
||||
<span
|
||||
className="shrink-0 rounded bg-indigo-500/15 px-1.5 py-0.5 text-[8px] font-bold uppercase tracking-wider text-indigo-400 border border-indigo-500/25"
|
||||
title="MTP-Kopf (Multi-Token-Prediction) — by-construction vocab-identisch, höchste Akzeptanzrate"
|
||||
>
|
||||
MTP
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[9px] text-muted-foreground font-mono">
|
||||
{fmtSize(d.size_bytes)} · Vocab: {vocabLabel(d.vocab)}
|
||||
</div>
|
||||
</div>
|
||||
{ok ? (
|
||||
isCurrent ? (
|
||||
<span className="text-[9px] font-bold uppercase text-primary flex items-center gap-1 shrink-0"><Check className="h-3.5 w-3.5" /> Aktiv</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => apply(d.path)}
|
||||
disabled={busy !== null}
|
||||
className="h-7 px-3 rounded-lg border border-emerald-500/30 bg-emerald-500/5 hover:bg-emerald-500/15 text-emerald-400 text-[9px] font-bold uppercase shrink-0 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
Aktivieren
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<span
|
||||
className="text-[9px] font-bold uppercase text-amber-400/80 flex items-center gap-1 shrink-0"
|
||||
title={d.compatible === false
|
||||
? `Inkompatibel: Tokenizer/Vocab weicht ab (Draft ${d.vocab?.pre}/${d.vocab?.n_vocab} ≠ Modell ${tv?.pre}/${tv?.n_vocab}).`
|
||||
: "Kompatibilität nicht prüfbar (Datei fehlt)."}
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5" /> {d.compatible === false ? "Vocab ≠" : "n/a"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hinweis, wenn Drafts da sind aber keiner kompatibel */}
|
||||
{!isLoading && data?.target_exists && drafts.length > 0 && compatibles.length === 0 && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-[11px] text-amber-400 leading-relaxed">
|
||||
Kein vocab-kompatibler Draft verfügbar — Speculative Decoding ist für dieses Modell nicht möglich.
|
||||
Es braucht einen Draft mit identischem Tokenizer (pre=<span className="font-mono">{tv?.pre}</span>,
|
||||
n_vocab=<span className="font-mono">{tv?.n_vocab?.toLocaleString()}</span>).
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && <div className="text-[10px] text-red-400 font-mono">{err}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import { useState } from "react"
|
||||
import { Zap, Plus, X, Check, HardDrive, AlertTriangle } from "lucide-react"
|
||||
import { setGroup, type ModelInfo } from "@/lib/api"
|
||||
import { useModels, useGroups, useSystemStatus, useHermesBrain, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { RoleLabel, roleMeta } from "@/components/models/ModelBadges"
|
||||
import { fmtSize } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// „Immer-bereit-Set" = die ko-residente llama-swap-Gruppe `brains`. Ihre Mitglieder
|
||||
// bleiben gemeinsam warm (verdrängen sich nicht). Hier in Klartext verwaltbar,
|
||||
// mit Speicher-Budget und Geländer (Hirn/Gedächtnis nicht entfernbar).
|
||||
export function WarmSetManager() {
|
||||
const qc = useQueryClient()
|
||||
const { data: modelsResp } = useModels()
|
||||
const { data: groupsResp } = useGroups()
|
||||
const { data: sysStatus } = useSystemStatus()
|
||||
const { data: brain } = useHermesBrain()
|
||||
const { showAlert, dialogElement } = useDialog()
|
||||
const [picking, setPicking] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const models = modelsResp?.models ?? []
|
||||
const running = modelsResp?.running ?? []
|
||||
const group = groupsResp?.groups?.brains
|
||||
const members = group?.members ?? []
|
||||
const byName = (name: string) => models.find((m) => m.name === name)
|
||||
const shortName = (name: string) => name.split("/").pop()?.replace(/\.gguf$/i, "") || name
|
||||
|
||||
const memberModels = members.map(byName).filter(Boolean) as ModelInfo[]
|
||||
const B = 1024 ** 3
|
||||
const bud = brain?.budget
|
||||
const weightsGb = memberModels.reduce((a, m) => a + (m.size_bytes || 0), 0) / B
|
||||
const gttTotal = sysStatus?.gpu?.gtt_total || sysStatus?.gpu?.vram_total || 0
|
||||
const totalGb = bud?.gtt_gb || (gttTotal ? gttTotal / B : 0)
|
||||
// Ehrlicher Fußabdruck inkl. Arbeitsspeicher (KV-Cache) aus dem Budget; sonst nur Gewichte.
|
||||
const reservedGb = bud?.warm_projected_gb ?? weightsGb
|
||||
const pct = totalGb > 0 ? Math.min(100, (reservedGb / totalGb) * 100) : 0
|
||||
|
||||
const eligible = models.filter((m) => !members.includes(m.name) && !m.incomplete)
|
||||
|
||||
async function save(next: string[]) {
|
||||
setBusy(true)
|
||||
try {
|
||||
await setGroup("brains", next, group?.swap ?? false, group?.persist ?? true)
|
||||
qc.invalidateQueries({ queryKey: qk.groups })
|
||||
qc.invalidateQueries({ queryKey: qk.models })
|
||||
qc.invalidateQueries({ queryKey: qk.hermesBrain })
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", `Immer-bereit-Set konnte nicht geändert werden: ${e?.message || e}`)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function remove(name: string) {
|
||||
const m = byName(name)
|
||||
if (m && roleMeta(m.role).protected) {
|
||||
showAlert("Geschützt", `„${roleMeta(m.role).label}" ist lebenswichtig und bleibt immer bereit.`)
|
||||
return
|
||||
}
|
||||
void save(members.filter((x) => x !== name))
|
||||
}
|
||||
|
||||
function add(name: string) {
|
||||
setPicking(false)
|
||||
void save([...members, name])
|
||||
}
|
||||
|
||||
if (!group) {
|
||||
return (
|
||||
<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.
|
||||
</p>
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mc-card p-5 space-y-4">
|
||||
<Header />
|
||||
|
||||
{/* Mitglieder */}
|
||||
<div className="space-y-1.5">
|
||||
{memberModels.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground">Noch keine Modelle im Set.</div>
|
||||
)}
|
||||
{memberModels.map((m) => {
|
||||
const isWarm = running.includes(m.name)
|
||||
const prot = roleMeta(m.role).protected
|
||||
return (
|
||||
<div key={m.name} className="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.5">
|
||||
<span className={cn("h-2 w-2 shrink-0 rounded-full ring-2 ring-black/40", isWarm ? "bg-emerald-500 animate-pulse" : "bg-muted-foreground/40")}
|
||||
title={isWarm ? "Gerade warm (bereit)" : "Reserviert — lädt bei Bedarf sofort"} />
|
||||
{m.role && <RoleLabel role={m.role} dense className="shrink-0" />}
|
||||
<span className="truncate font-mono text-xs font-semibold text-foreground" title={m.name}>{shortName(m.name)}</span>
|
||||
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/70">{fmtSize(m.size_bytes)}</span>
|
||||
</div>
|
||||
{prot ? (
|
||||
<span className="shrink-0 text-[10px] font-semibold text-muted-foreground" title="Lebenswichtig — bleibt immer im Set.">🔒</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => remove(m.name)}
|
||||
disabled={busy}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border/40 text-muted-foreground transition-colors hover:bg-red-500/5 hover:text-red-400 cursor-pointer disabled:opacity-50"
|
||||
title="Aus dem Immer-bereit-Set nehmen (lädt dann nur noch bei Bedarf)"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Hinzufügen */}
|
||||
<button
|
||||
onClick={() => setPicking(true)}
|
||||
disabled={busy || eligible.length === 0}
|
||||
className="flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-border/50 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:border-primary/40 hover:text-primary cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Modell dauerhaft bereithalten
|
||||
</button>
|
||||
|
||||
{/* Speicher-Budget */}
|
||||
<div className="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" title="Modellgewichte + KV-Cache, berechnet aus den echten Architektur-Daten jedes Modells (Layer × KV-Köpfe × Kontext) und seiner KV-Quantisierung. So viel legt llama.cpp beim Laden wirklich für den eingestellten Kontext an — kein Aufschlag mehr.">
|
||||
<HardDrive className="h-3.5 w-3.5" /> Reserviert fürs Set
|
||||
</span>
|
||||
<span className="font-mono font-bold text-foreground">
|
||||
{reservedGb.toFixed(1)}{totalGb > 0 ? ` / ${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", pct >= 85 ? "bg-red-500" : pct >= 65 ? "bg-amber-500" : "bg-teal-500")} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
|
||||
{bud && !bud.fits && (
|
||||
<p className="mt-2 flex items-start gap-1.5 text-[11px] text-amber-400">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
Zusammen mit dem größten Gelegenheits-Modell (~{bud.largest_ondemand_gb} GB) wird der Speicher knapp. Das Set
|
||||
bleibt dabei immer geladen — wird es wirklich eng, schlägt das Laden des großen Modells fehl (es wartet dann,
|
||||
statt das Set zu verdrängen).
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{bud && bud.fits && (
|
||||
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-emerald-400">
|
||||
<Check className="h-3.5 w-3.5 shrink-0" /> Passt auch neben dem größten Gelegenheits-Modell — nichts wird verdrängt.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auswahl-Modal */}
|
||||
{picking && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label="Modell zum Immer-bereit-Set hinzufügen">
|
||||
<div className="w-full max-w-md space-y-3 rounded-2xl border border-border/80 bg-card p-6 shadow-2xl 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">Dauerhaft bereithalten</h3>
|
||||
<button onClick={() => setPicking(false)} className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground cursor-pointer"><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Wähle ein Modell, das ohne Ladezeit bereitstehen soll:</p>
|
||||
<div className="max-h-60 space-y-1.5 overflow-y-auto pr-1">
|
||||
{eligible.map((m) => (
|
||||
<button
|
||||
key={m.name}
|
||||
onClick={() => add(m.name)}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-lg border border-border/30 bg-background/20 px-3 py-2.5 text-left transition-colors hover:bg-accent cursor-pointer"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{m.role && <RoleLabel role={m.role} dense className="shrink-0" />}
|
||||
<span className="truncate font-mono text-xs font-semibold text-foreground">{shortName(m.name)}</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/70">{fmtSize(m.size_bytes)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Header() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4.5 w-4.5 text-primary" />
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-wide text-foreground">Immer-bereit-Set</h2>
|
||||
<p className="text-[11px] text-muted-foreground">Diese Modelle hält Lucy immer bereit — sie antworten ohne Ladezeit.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { useMemo, useRef, useState } from "react"
|
||||
import { AlertTriangle, RefreshCw, Search, Terminal } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// System-Log-Konsole (Paket D16c): färbt Fehler/Warnungen ein, filtert auf „nur Probleme"
|
||||
// und durchsucht die Zeilen. Ersetzt den früheren monochromen <pre>-Dump („zu dünn").
|
||||
|
||||
type Level = "error" | "warn" | "info"
|
||||
|
||||
// Wortgrenzen-Regex, damit „error" nicht in jeder URL/jedem Pfad anschlägt. Deutsch + Englisch,
|
||||
// weil unsere Dienste beides loggen (Hermes englisch, MC2/Voice teils deutsch).
|
||||
const _ERR = /\b(error|fehler|fail(ed|ure)?|fatal|critical|crit|panic|traceback|exception|denied|refused|unable|emerg|alert|oom|killed)\b/i
|
||||
const _WARN = /\b(warn(ing)?|deprecat\w*|retry(ing)?|timeout|timed out|degraded|missing|not found|nicht gefunden)\b/i
|
||||
|
||||
function classify(line: string): Level {
|
||||
if (_ERR.test(line)) return "error"
|
||||
if (_WARN.test(line)) return "warn"
|
||||
return "info"
|
||||
}
|
||||
|
||||
const LEVEL_CLS: Record<Level, string> = {
|
||||
error: "text-red-400",
|
||||
warn: "text-amber-300",
|
||||
info: "text-cyan-300/80",
|
||||
}
|
||||
|
||||
export function LogConsole({ service, text, loading, logStatus, onRefresh, onOpenSettings }: {
|
||||
service: string
|
||||
text: string
|
||||
loading: boolean
|
||||
logStatus: string | null
|
||||
onRefresh: () => void
|
||||
onOpenSettings: () => void
|
||||
}) {
|
||||
const [problemsOnly, setProblemsOnly] = useState(false)
|
||||
const [query, setQuery] = useState("")
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const lines = useMemo(
|
||||
() => (text ? text.split("\n").map((t) => ({ t, level: classify(t) })) : []),
|
||||
[text],
|
||||
)
|
||||
const problemCount = useMemo(() => lines.filter((l) => l.level !== "info").length, [lines])
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
const shown = lines.filter(
|
||||
(l) => (!problemsOnly || l.level !== "info") && (!q || l.t.toLowerCase().includes(q)),
|
||||
)
|
||||
|
||||
const needsPw = logStatus === "password_required" || logStatus === "incorrect_password"
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-[300px] border border-border/60 bg-black/50 rounded-xl overflow-hidden shadow-inner">
|
||||
{/* Konsolen-Kopf: Titel + Problem-Filter + Suche + Neu laden */}
|
||||
<div className="flex h-10 items-center justify-between gap-2 px-3 border-b border-border/40 bg-black/30 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-mono text-muted-foreground shrink-0">
|
||||
<Terminal className="h-3 w-3 text-primary" />
|
||||
<span className="hidden sm:inline">stdout/stderr —</span> {service}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="filtern…"
|
||||
className="h-7 w-24 sm:w-36 rounded-md border border-border/50 bg-background/40 pl-6 pr-2 text-[10px] text-foreground outline-none focus:border-primary/50"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setProblemsOnly((v) => !v)}
|
||||
title="Nur Fehler und Warnungen zeigen"
|
||||
className={cn("flex h-7 items-center gap-1 rounded-md border px-2 text-[10px] font-semibold transition-colors",
|
||||
problemsOnly ? "border-amber-500/50 bg-amber-500/15 text-amber-300"
|
||||
: "border-border/50 bg-background/20 text-muted-foreground hover:text-foreground")}
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Nur Probleme{problemCount > 0 ? ` (${problemCount})` : ""}
|
||||
</button>
|
||||
<button onClick={onRefresh} disabled={loading} title="Neu laden"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md border border-border/50 bg-background/20 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50">
|
||||
<RefreshCw className={cn("h-3 w-3", loading && "animate-spin")} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zeilen-Bereich */}
|
||||
<div ref={containerRef} className="flex-1 overflow-y-auto p-3 text-[10px] font-mono leading-relaxed scrollbar-thin select-text">
|
||||
{needsPw ? (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-3 p-6 text-center">
|
||||
<AlertTriangle className="h-8 w-8 text-amber-400 animate-pulse animate-duration-1000" />
|
||||
<div className="text-xs font-semibold text-amber-300">
|
||||
{logStatus === "incorrect_password" ? "Falsches Sudo-Passwort hinterlegt." : "Sudo-Passwort für systemd-Dienste erforderlich."}
|
||||
</div>
|
||||
<p className="max-w-xs text-[10px] leading-normal text-muted-foreground">
|
||||
Für das Auslesen der systemd-Logs von {service} werden Root-Rechte benötigt. Hinterlege dein Passwort in den Einstellungen.
|
||||
</p>
|
||||
<button onClick={onOpenSettings}
|
||||
className="mt-2 rounded-md bg-primary px-3 py-1.5 text-[10px] font-semibold text-primary-foreground transition-colors hover:bg-primary/95">
|
||||
Sudo-Passwort eintragen
|
||||
</button>
|
||||
</div>
|
||||
) : loading && !text ? (
|
||||
<span className="text-muted-foreground">Lade Logs…</span>
|
||||
) : lines.length === 0 ? (
|
||||
<span className="text-muted-foreground">Keine Logeinträge vorhanden.</span>
|
||||
) : shown.length === 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
{problemsOnly ? "Keine Fehler oder Warnungen — der Dienst läuft sauber. ✓" : "Kein Treffer für den Filter."}
|
||||
</span>
|
||||
) : (
|
||||
shown.map((l, i) => (
|
||||
<div key={i} className={cn("whitespace-pre-wrap", LEVEL_CLS[l.level])}>{l.t || " "}</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Eye, EyeOff, Key, Loader2, ShieldCheck } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
|
||||
// Einstellungen-Tab des SystemDrawers.
|
||||
//
|
||||
// v3-Umbau P1 (28.08.2026): Hier standen zwei Geheimnisse, die im localStorage des
|
||||
// Browsers lagen und bei jedem mutierenden Request mitreisten.
|
||||
//
|
||||
// · Das Box-Sudo-Passwort ist ERSATZLOS weg. Auf der Box gemessen: `sudo -n true`
|
||||
// laeuft durch, weil /etc/sudoers den Dienst-Nutzer mit `NOPASSWD: ALL` fuehrt.
|
||||
// Das Feld hat also nie etwas bewirkt — es war reines Risiko (ein XSS in dieser
|
||||
// SPA haette Root bedeutet).
|
||||
// · Der HuggingFace-Token liegt jetzt auf der Box (services/geheimnisse.py, 0600).
|
||||
// Diese Ansicht erfaehrt nur noch, OB einer gesetzt ist — nie seinen Wert. Deshalb
|
||||
// gibt es kein "Anzeigen" fuer einen gespeicherten Token, sondern nur Ersetzen
|
||||
// oder Loeschen.
|
||||
|
||||
interface GeheimnisStatus {
|
||||
hf_token_gesetzt: boolean
|
||||
hf_token_aus_env: boolean
|
||||
schreibbar: boolean
|
||||
}
|
||||
|
||||
export function SettingsTab({ open, showAlert }: {
|
||||
open: boolean
|
||||
showAlert: (title: string, message: string) => void
|
||||
}) {
|
||||
const [status, setStatus] = useState<GeheimnisStatus | null>(null)
|
||||
const [eingabe, setEingabe] = useState("")
|
||||
const [sichtbar, setSichtbar] = useState(false)
|
||||
const [laedt, setLaedt] = useState(false)
|
||||
const [arbeitet, setArbeitet] = useState(false)
|
||||
|
||||
async function statusLaden() {
|
||||
setLaedt(true)
|
||||
try {
|
||||
setStatus(await api<GeheimnisStatus>("/api/maintenance/geheimnisse"))
|
||||
} catch (e: any) {
|
||||
showAlert("Nicht erreichbar", `Der Geheimnis-Zustand konnte nicht geladen werden: ${e?.message || e}`)
|
||||
} finally {
|
||||
setLaedt(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setEingabe("")
|
||||
setSichtbar(false)
|
||||
void statusLaden()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
async function setzen(wert: string | null) {
|
||||
setArbeitet(true)
|
||||
try {
|
||||
const r = await api<GeheimnisStatus>("/api/maintenance/geheimnisse", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ schluessel: "hf_token", wert }),
|
||||
})
|
||||
setStatus(r)
|
||||
setEingabe("")
|
||||
showAlert(wert ? "Gespeichert" : "Gelöscht",
|
||||
wert ? "Der Token liegt jetzt auf der Box — der Browser behält ihn nicht."
|
||||
: "Der Token wurde von der Box entfernt.")
|
||||
} catch (e: any) {
|
||||
showAlert("Fehlgeschlagen", e?.message || String(e))
|
||||
} finally {
|
||||
setArbeitet(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Zugangsdaten & Schlüssel</h3>
|
||||
<p className="text-[10px] leading-normal text-muted-foreground">
|
||||
Geheimnisse liegen auf der Box (Datei mit Rechten 0600), nicht im Browser. Diese Seite
|
||||
zeigt nur, ob etwas gesetzt ist — den Wert bekommt sie nie zurück.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sudo: bewusst nur noch eine Erklaerung, kein Eingabefeld. */}
|
||||
<div className="mc-card-sm space-y-1.5 p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold">
|
||||
<ShieldCheck className="h-4 w-4 text-emerald-400" aria-hidden="true" />
|
||||
Box-Sudo-Passwort — nicht mehr nötig
|
||||
</div>
|
||||
<p className="text-[10px] leading-normal text-muted-foreground">
|
||||
Systemdienste, OS-Update und Neustart laufen passwortlos (<code className="font-mono text-primary">NOPASSWD</code> in
|
||||
<code className="font-mono"> /etc/sudoers</code>). Das frühere Feld hat nie etwas bewirkt und lag dabei
|
||||
im Browser-Speicher — es ist am 28.08.2026 ersatzlos entfallen. Verlangt <code className="font-mono">sudo</code> auf
|
||||
der Box je doch ein Passwort, sagt die Wartung das im Klartext; das ist dann dort zu lösen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* HuggingFace-Token */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="hf-token" className="flex items-center gap-1.5 text-xs font-semibold">
|
||||
<Key className="h-4 w-4 text-violet-400" aria-hidden="true" />
|
||||
HuggingFace-Token
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
{laedt ? (
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" /> Zustand wird geladen …
|
||||
</span>
|
||||
) : status?.hf_token_gesetzt ? (
|
||||
<span className="flex items-center gap-1.5 font-semibold text-emerald-400">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500" aria-hidden="true" />
|
||||
Gesetzt{status.hf_token_aus_env ? " (aus der Dienst-Umgebung)" : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<span className="h-2 w-2 rounded-full bg-muted-foreground/50" aria-hidden="true" />
|
||||
Nicht gesetzt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status?.hf_token_aus_env && (
|
||||
<p className="text-[9px] leading-normal text-amber-400/90">
|
||||
Der Token kommt aus der Umgebung des Dienstes und hat Vorrang. Hier gespeicherte Werte
|
||||
bleiben wirkungslos, solange <code className="font-mono">HF_TOKEN</code> gesetzt ist.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status && !status.schreibbar && (
|
||||
<p className="text-[9px] leading-normal text-amber-400/90">
|
||||
Die Ablage ist nicht beschreibbar — auf einem Entwicklungsrechner ohne den Modell-Ordner
|
||||
der Box ist das normal. Speichern würde hier ins Leere laufen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
id="hf-token"
|
||||
type={sichtbar ? "text" : "password"}
|
||||
value={eingabe}
|
||||
onChange={(e) => setEingabe(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={status?.hf_token_gesetzt ? "Neuen Token eingeben, um zu ersetzen" : "hf_…"}
|
||||
className="h-10 w-full rounded-lg border border-border/60 bg-background/40 pl-3 pr-10 text-xs text-foreground outline-none transition-colors focus:border-primary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSichtbar(!sichtbar)}
|
||||
aria-label={sichtbar ? "Token verbergen" : "Token anzeigen"}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{sichtbar ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[9px] leading-normal text-muted-foreground">
|
||||
Nötig für Modelle mit Zugangsschranke. Wird beim Download als
|
||||
<code className="font-mono"> HF_TOKEN</code> an den Job gereicht und verlässt die Box nicht.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => setzen(eingabe.trim())}
|
||||
disabled={arbeitet || !eingabe.trim()}
|
||||
className="flex h-9 flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg bg-primary text-xs font-semibold text-primary-foreground shadow shadow-primary/10 transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{arbeitet && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
|
||||
Auf der Box speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setzen(null)}
|
||||
disabled={arbeitet || !status?.hf_token_gesetzt}
|
||||
className="flex h-9 cursor-pointer items-center justify-center rounded-lg border border-border/60 bg-background/20 px-4 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import { AlertTriangle, ArrowRight, Bot, CheckCircle2, Clock, ExternalLink, GitCommit, Package, RefreshCw, Server, Shield, ShieldCheck, Shuffle, X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { Job, UpdateDetails } from "@/lib/api"
|
||||
|
||||
export type UpdateDetailState = { kind: "os" | "engine" | "swap" | "hermes"; loading: boolean; data: UpdateDetails | null }
|
||||
|
||||
// Update-Detail-Fenster des SystemDrawers (Review P2-14: extrahiert): zeigt VOR dem
|
||||
// Anwenden, was genau aktualisiert wird (apt-Pakete, Engine-Builds, Hermes-Commits).
|
||||
export function UpdateDetailModal({ detail, maintenanceJob, onClose, onApply }: {
|
||||
detail: UpdateDetailState
|
||||
maintenanceJob?: Job
|
||||
onClose: () => void
|
||||
onApply: () => void
|
||||
}) {
|
||||
const d = detail.data
|
||||
const meta = {
|
||||
os: { icon: Shield, cls: "text-cyan-400", title: "OS-Pakete (apt)" },
|
||||
engine: { icon: Server, cls: "text-violet-400", title: "Inferenz-Engine (llama.cpp)" },
|
||||
swap: { icon: Shuffle, cls: "text-fuchsia-400", title: "Router (llama-swap)" },
|
||||
hermes: { icon: Bot, cls: "text-amber-400", title: "Hermes-Agent" },
|
||||
}[detail.kind]
|
||||
const Icon = meta.icon
|
||||
const nothing = !d ? true
|
||||
: detail.kind === "os" ? (d.count ?? 0) === 0
|
||||
: detail.kind === "hermes" ? (d.behind ?? 0) === 0
|
||||
: (d.installed_build != null && d.latest_build != null && d.latest_build <= d.installed_build)
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative w-full max-w-lg max-h-[80vh] flex flex-col rounded-2xl border border-border/60 bg-card shadow-2xl font-sans text-foreground">
|
||||
{/* Header */}
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={cn("h-4.5 w-4.5", meta.cls)} />
|
||||
<h3 className="text-sm font-semibold">{meta.title}</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="flex h-7 w-7 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-5 text-xs space-y-3 scrollbar-thin">
|
||||
{detail.loading ? (
|
||||
<div className="flex h-24 items-center justify-center gap-2 text-muted-foreground">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" /> Details werden geladen…
|
||||
</div>
|
||||
) : d?.error ? (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/5 p-3 text-red-400">{d.error}</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Aktions-Verdikt in Lucys Stimme — "Musst du etwas tun?" (engine/swap/hermes) */}
|
||||
{d?.action_needed != null && (
|
||||
<div className={cn("flex items-start gap-2 rounded-lg border p-3",
|
||||
d.action_needed ? "border-amber-500/40 bg-amber-500/10" : "border-emerald-500/40 bg-emerald-500/10")}>
|
||||
{d.action_needed
|
||||
? <AlertTriangle className="h-4 w-4 text-amber-400 shrink-0 mt-0.5" />
|
||||
: <CheckCircle2 className="h-4 w-4 text-emerald-400 shrink-0 mt-0.5" />}
|
||||
<div className="min-w-0">
|
||||
<div className={cn("text-xs font-semibold", d.action_needed ? "text-amber-300" : "text-emerald-300")}>
|
||||
Musst du etwas tun? {d.action_needed ? "Ja" : "Nein — die Box regelt das (Fangnetz)"}
|
||||
</div>
|
||||
{d.action_needed && d.action_text && (
|
||||
<div className="mt-0.5 text-[11px] text-foreground/90">{d.action_text}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Zusammenfassung der Box (Breaking Changes zuerst) */}
|
||||
{d?.summary && (
|
||||
<div className="rounded-lg border border-primary/25 bg-primary/5 p-3 space-y-1">
|
||||
<div className="text-[10px] font-bold uppercase tracking-wider text-primary">Was dieses Update bedeutet (Zusammenfassung der Box)</div>
|
||||
<pre className="whitespace-pre-wrap text-[11px] leading-relaxed text-foreground/90 font-sans">{d.summary}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.kind === "os" ? (
|
||||
<>
|
||||
{(d?.count ?? 0) === 0 ? (
|
||||
<div className="text-muted-foreground">Keine Pakete zu aktualisieren — System ist aktuell.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground">{d!.count} Paket(e) werden aktualisiert:</div>
|
||||
<div className="space-y-1">
|
||||
{d!.packages!.map((p) => (
|
||||
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
|
||||
<span className="flex items-center gap-1.5 font-mono text-[11px] truncate">
|
||||
<Package className="h-3 w-3 text-cyan-400 shrink-0" />{p.name}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 font-mono text-[10px] text-muted-foreground shrink-0">
|
||||
<span>{p.current}</span><ArrowRight className="h-3 w-3" /><span className="text-emerald-400">{p.candidate}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Ehrlich: vom System zurückgestellte Pakete (Phasen-Rollout / kept back) */}
|
||||
{(d?.held_back?.length ?? 0) > 0 && (
|
||||
<div className="space-y-1.5 rounded-lg border border-border/40 bg-background/20 p-3">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground">
|
||||
<Clock className="h-3.5 w-3.5" /> Vom Hersteller zurückgestellt — kein Handeln nötig
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
Diese Pakete gäbe es bereits, Ubuntu spielt sie aber gestaffelt aus (Phasen-Rollout)
|
||||
bzw. hält sie kurz zurück. Sie kommen bei einem der nächsten automatischen Läufe von
|
||||
selbst — das ist kein Fehler und nichts hängt fest.
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{d!.held_back!.map((p) => (
|
||||
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md border border-border/30 bg-background/30 px-2.5 py-1.5">
|
||||
<span className="flex items-center gap-1.5 font-mono text-[11px] truncate">
|
||||
<Package className="h-3 w-3 text-muted-foreground shrink-0" />{p.name}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground shrink-0">
|
||||
{p.reason === "phasing" ? "Phasen-Rollout" : "vorerst zurückgehalten"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : detail.kind === "engine" || detail.kind === "swap" ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 font-mono text-[11px]">
|
||||
<span className="rounded-md border border-border/40 bg-background/30 px-2 py-1">Build {d?.installed_build ?? "?"}</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-2 py-1 text-emerald-400">Build {d?.latest_build ?? "?"}</span>
|
||||
</div>
|
||||
{(d?.name || d?.latest_tag) && (
|
||||
<div className="text-muted-foreground">Release: <span className="text-foreground">{d?.name}</span>{d?.latest_tag ? ` (${d.latest_tag})` : ""}</div>
|
||||
)}
|
||||
{d?.url && (
|
||||
<a href={d.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-primary hover:underline">
|
||||
Original-Release-Notes auf GitHub <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
{d?.body && (
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-[10px] text-muted-foreground hover:text-foreground">Original-Notizen (englisch) anzeigen</summary>
|
||||
<pre className="mt-1.5 whitespace-pre-wrap rounded-lg border border-border/40 bg-background/30 p-3 text-[10px] leading-relaxed text-muted-foreground max-h-60 overflow-y-auto scrollbar-thin">{d.body}</pre>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// hermes
|
||||
(d?.commits?.length ?? 0) === 0 ? (
|
||||
<div className="text-muted-foreground">Keine neuen Commits — Hermes-Agent ist bereits aktuell.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground">{d!.behind} neue Commit(s) auf <span className="font-mono text-foreground">origin/{d!.branch}</span>:</div>
|
||||
<div className="space-y-1">
|
||||
{d!.commits!.map((c) => (
|
||||
<div key={c.hash} className="flex items-start gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
|
||||
<GitCommit className="h-3.5 w-3.5 text-primary shrink-0 mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] truncate">{c.subject}</div>
|
||||
<div className="font-mono text-[9px] text-muted-foreground">{c.hash} · {c.when}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Fangnetz-Hinweis: verheiratet Breaking-Change-Sorge mit dem Postcheck (engine/swap/hermes) */}
|
||||
{detail.kind !== "os" && !nothing && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-border/40 bg-background/20 p-2.5">
|
||||
<ShieldCheck className="h-3.5 w-3.5 text-emerald-400 shrink-0 mt-0.5" />
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
Vor dem Update sichert die Box automatisch den alten Stand. Danach prüft sie den ganzen
|
||||
Stack per echter Anfrage — läuft etwas nicht, rollt sie von selbst zurück{detail.kind === "hermes" ? " und startet den Gateway neu" : ""}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex gap-3 border-t border-border/40 p-4 shrink-0">
|
||||
<button onClick={onClose} className="h-9 flex-1 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold text-muted-foreground hover:bg-accent transition-colors cursor-pointer">
|
||||
Schließen
|
||||
</button>
|
||||
<button onClick={onApply} disabled={detail.loading || nothing || !!maintenanceJob}
|
||||
title={maintenanceJob ? `Update läuft bereits: ${maintenanceJob.label}` : undefined}
|
||||
className="h-9 flex-1 rounded-lg bg-primary text-xs font-semibold text-primary-foreground hover:opacity-90 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-default">
|
||||
{maintenanceJob ? "Update läuft…" : "Jetzt aktualisieren"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { RefreshCw, FileText } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Präsentations-Atome des SystemDrawers (Review P2-14: aus dem 934-Z-Monolithen extrahiert).
|
||||
// Die frühere SERVICES-Konstante (UI-Kopie der Dienste-Liste) ist weg: der Drawer rendert
|
||||
// jetzt direkt die Backend-Antwort von /api/system/services — EINE Quelle der Wahrheit.
|
||||
|
||||
export function UpdateRow({ icon: Icon, iconClass, name, status, available, busy, actionLabel, onAction }: {
|
||||
icon: any; iconClass: string; name: string; status: string; available: boolean; busy?: boolean; actionLabel: string; onAction: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-3 rounded-lg border px-3 py-2",
|
||||
available ? "border-amber-500/30 bg-amber-500/5" : "border-border/50 bg-background/20")}>
|
||||
<Icon className={cn("h-4 w-4 shrink-0", iconClass)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-foreground truncate">{name}</div>
|
||||
<div className={cn("text-[10px] truncate", available ? "text-amber-400/90" : "text-muted-foreground")}>{status}</div>
|
||||
</div>
|
||||
<button onClick={onAction} disabled={!available || busy}
|
||||
className={cn("text-[11px] font-semibold rounded-lg px-2.5 py-1 transition-colors shrink-0",
|
||||
available ? "bg-primary text-primary-foreground hover:opacity-90 cursor-pointer" : "border border-border/50 text-muted-foreground/40 cursor-default")}>
|
||||
{busy ? "…" : actionLabel}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ServiceRow({ label, ok, system, busy, onRestart, onLogs }: {
|
||||
label: string; ok?: boolean; system?: boolean; busy?: boolean; onRestart: () => void; onLogs: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-lg border border-border/50 bg-background/20 px-3 py-2">
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full shrink-0",
|
||||
ok === true ? "bg-emerald-500" : ok === false ? "bg-red-500" : "bg-muted-foreground/40")} />
|
||||
<span className="flex-1 text-xs text-foreground truncate">{label}{system && <span className="text-[9px] text-muted-foreground"> (root)</span>}</span>
|
||||
<button onClick={onRestart} disabled={busy} title="Neu starten" className="text-muted-foreground hover:text-primary transition-colors disabled:opacity-50">
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", busy && "animate-spin")} />
|
||||
</button>
|
||||
<button onClick={onLogs} title="Logs ansehen" className="text-muted-foreground hover:text-primary transition-colors">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
|
||||
// Kleine Abschnitts-Überschrift innerhalb einer Seite. Lag vorher in AuftragsbuchView;
|
||||
// seit der Ideen-Queue eine eigene Seite hat (25.07.2026) brauchen beide sie — also
|
||||
// einmal hier statt zweimal dupliziert.
|
||||
export function SectionLabel({ icon: Icon, children }: { icon: LucideIcon; children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||
<Icon className="h-3.5 w-3.5" /> {children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -1,38 +1,60 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
type Variant = "default" | "outline" | "ghost"
|
||||
type Size = "default" | "sm" | "icon"
|
||||
// shadcn-Knopf im Cockpit-Stil: Instrumenten-Schrift, mindestens 44 px hoch (Touch am Handy).
|
||||
// default = Bernstein (die eine Hauptaktion), outline = neutral, info = Cyan, gefahr = Rot.
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center gap-2 rounded-lg border border-transparent font-anzeige font-semibold uppercase tracking-[0.12em] whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-45 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-bernstein text-primary-foreground hover:bg-bernstein-hell",
|
||||
outline: "border-linie-stark bg-erhaben text-foreground hover:border-text-3 hover:bg-[#232931]",
|
||||
info: "border-cyan-rand bg-transparent text-cyan hover:bg-cyan-grund",
|
||||
gefahr: "border-rot-rand bg-rot-grund text-rot-text hover:bg-[#3a1512]",
|
||||
ghost: "text-text-2 hover:bg-erhaben hover:text-foreground",
|
||||
link: "px-0 text-cyan normal-case tracking-normal underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-11 px-5 text-[15px]",
|
||||
sm: "h-10 px-4 text-sm",
|
||||
lg: "h-12 px-6 text-base",
|
||||
icon: "size-11",
|
||||
// Schließen-Knopf von Dialog und Schublade (shadcn) — ebenfalls 44 px für Touch.
|
||||
"icon-sm": "size-11",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
default: "bg-primary text-primary-foreground hover:opacity-90",
|
||||
outline: "border border-border bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
}
|
||||
const sizes: Record<Size, string> = {
|
||||
default: "h-9 px-4 py-2 text-sm",
|
||||
sm: "h-8 px-3 text-xs",
|
||||
icon: "h-9 w-9",
|
||||
}
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant
|
||||
size?: Size
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = "default", size = "default", ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50",
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className,
|
||||
)}
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Button, buttonVariants }
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Schließen</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Schließen</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close data-slot="sheet-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Schließen</span>
|
||||
</Button>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import type { AktivitaetResp } from "@/lib/api"
|
||||
|
||||
// Die Query wird ersetzt, nicht das Netz: Der Test prüft die ANSICHT, nicht den Abruf.
|
||||
const useWerkzeugverlauf = vi.hoisted(() => vi.fn())
|
||||
vi.mock("@/lib/queries", () => ({ useWerkzeugverlauf }))
|
||||
|
||||
const { Werkzeugverlauf } = await import("./Werkzeugverlauf")
|
||||
|
||||
function zeigen(data: AktivitaetResp | undefined, isLoading = false) {
|
||||
useWerkzeugverlauf.mockReturnValue({ data, isLoading })
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Werkzeugverlauf />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
const ANTWORT: AktivitaetResp = {
|
||||
verfuegbar: true,
|
||||
pfad: "~/.hermes/logs/agent.log",
|
||||
rufe: [
|
||||
{ zeit: "2026-08-28T07:01:20", lauf: "cron_a", werkzeug: "web_extract", dauer_s: 0.17, zeichen: null, ok: false, fehler: "DuckDuckGo (ddgs) is a search-only backend." },
|
||||
{ zeit: "2026-08-28T07:02:36", lauf: null, werkzeug: "web_search", dauer_s: 2.61, zeichen: 2944, ok: true, fehler: null },
|
||||
{ zeit: "2026-08-28T07:03:18", lauf: "cron_a", werkzeug: "terminal", dauer_s: 1.4, zeichen: 53, ok: true, fehler: null },
|
||||
],
|
||||
werkzeuge: [
|
||||
{ werkzeug: "web_extract", rufe: 2, fehler: 2, dauer_schnitt_s: 0.17, letzter_fehler: "DuckDuckGo (ddgs) is a search-only backend." },
|
||||
{ werkzeug: "web_search", rufe: 20, fehler: 0, dauer_schnitt_s: 1.77, letzter_fehler: null },
|
||||
],
|
||||
laeufe: [{ lauf: "cron_a", von: "2026-08-28T07:01:05", bis: "2026-08-28T07:03:18", rufe: 3, fehler: 1 }],
|
||||
}
|
||||
|
||||
/** Die Bilanz-Karte eines Werkzeugs — der Name steht auch in der Zeitleiste, deshalb
|
||||
* wird hier gezielt die Karte gesucht und nicht irgendein Vorkommen des Namens. */
|
||||
function bilanzKarte(werkzeug: string): HTMLElement {
|
||||
const treffer = screen.getAllByText(werkzeug)
|
||||
.map((el) => el.closest("div.mc-card-sm"))
|
||||
.filter((el): el is HTMLElement => el instanceof HTMLElement && el.textContent!.includes("Rufe"))
|
||||
expect(treffer.length, `genau eine Bilanz-Karte fuer ${werkzeug}`).toBe(1)
|
||||
return treffer[0]
|
||||
}
|
||||
|
||||
describe("Werkzeugverlauf", () => {
|
||||
// Der eigentliche Zweck der Ansicht: Ein Werkzeug, das IMMER scheitert, verschwindet
|
||||
// in einer Ereignisliste. Genau so lief `web_extract` vier Tage lang jeden Morgen
|
||||
// ins Leere, ohne dass es jemandem auffiel.
|
||||
it("markiert ein durchgehend scheiterndes Werkzeug als „immer rot“", () => {
|
||||
zeigen(ANTWORT)
|
||||
const karte = bilanzKarte("web_extract")
|
||||
expect(karte.textContent).toMatch(/immer rot/i)
|
||||
expect(karte.textContent).toMatch(/2 Fehler/)
|
||||
})
|
||||
|
||||
it("markiert ein fehlerfreies Werkzeug NICHT", () => {
|
||||
zeigen(ANTWORT)
|
||||
const karte = bilanzKarte("web_search")
|
||||
expect(karte.textContent).not.toMatch(/immer rot/i)
|
||||
expect(karte.textContent).toMatch(/0 Fehler/)
|
||||
// Und die Markierung gibt es im ganzen Abschnitt genau einmal.
|
||||
expect(screen.getAllByText(/immer rot/i)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("zeigt den echten Grund des Fehlers, nicht nur „Fehler“", () => {
|
||||
zeigen(ANTWORT)
|
||||
expect(bilanzKarte("web_extract").textContent).toMatch(/search-only backend/)
|
||||
})
|
||||
|
||||
it("nennt Rufe und Fehler in der Abschnitts-Überschrift", () => {
|
||||
const { container } = zeigen(ANTWORT)
|
||||
// Die Überschrift setzt sich aus mehreren Textknoten zusammen (der Fehler-Teil
|
||||
// erscheint nur bedingt), deshalb gegen den zusammengesetzten Text prüfen.
|
||||
const kopf = container.querySelector("section")!.textContent!
|
||||
expect(kopf).toMatch(/3 Rufe/) // 3 Einträge in ANTWORT.rufe
|
||||
expect(kopf).toMatch(/2 Fehler/) // Summe aus der Werkzeug-Bilanz
|
||||
})
|
||||
|
||||
it("gruppiert nach Lauf und zeigt dessen Zeitspanne", () => {
|
||||
zeigen(ANTWORT)
|
||||
const zeile = screen.getByText("cron_a").closest("div")!
|
||||
expect(zeile.textContent).toMatch(/07:01:05–07:03:18/)
|
||||
expect(zeile.textContent).toMatch(/1 Fehler/)
|
||||
})
|
||||
|
||||
// Auf einem Entwicklungsrechner ohne Hermes gibt es das Log nicht. Dann still
|
||||
// weglassen — eine leere Karte waere eine Zusage ohne Inhalt.
|
||||
it("verschwindet ganz, wenn es kein Hermes-Log gibt", () => {
|
||||
const { container } = zeigen({ verfuegbar: false, pfad: "", rufe: [], werkzeuge: [], laeufe: [] })
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it("sagt beim Laden, was es tut", async () => {
|
||||
zeigen(undefined, true)
|
||||
await waitFor(() => expect(screen.getByText(/Lese Hermes/)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
@@ -1,138 +0,0 @@
|
||||
import { AlertTriangle, Activity, CheckCircle2, Clock, Wrench } from "lucide-react"
|
||||
import { useWerkzeugverlauf } from "@/lib/queries"
|
||||
import { SectionLabel } from "@/components/ui/SectionLabel"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Werkzeug-Verlauf des Agenten (v3-Umbau P6).
|
||||
//
|
||||
// Der Blueprint sah hier eine „Live Agent Matrix" mit Denkstrom vor (§4.4). Die ist so
|
||||
// nicht baubar: Lucys Denkschritte entstehen im Hermes-Prozess, und AGENTS.md verbietet
|
||||
// es, dessen Quellcode zu patchen. Was aber offen dalag: Hermes protokolliert JEDEN
|
||||
// Werkzeug-Ruf in sein Log. Zuschauen ist kein Patchen.
|
||||
//
|
||||
// Diese Ansicht zeigt deshalb nur, was wirklich da ist — welches Werkzeug, wie lange,
|
||||
// mit welchem Ergebnis, in welchem Lauf. Kein erfundener Denkstrom, keine leere Leitung.
|
||||
//
|
||||
// Die BILANZ steht oben, nicht die Zeitleiste. Grund: Ein Werkzeug, das immer scheitert,
|
||||
// verschwindet in einer Liste von Ereignissen — in der Zeile „web_extract · 2 Rufe ·
|
||||
// 2 Fehler" nicht. Genau so ist beim Bauen aufgefallen, dass `web_extract` seit
|
||||
// mindestens vier Tagen jeden Morgen um 07:00 scheitert.
|
||||
|
||||
const uhr = (iso: string) => iso.slice(11, 19)
|
||||
|
||||
/** Dauer lesbar: unter einer Sekunde in ms, sonst in Sekunden. */
|
||||
function dauer(s: number): string {
|
||||
return s < 1 ? `${Math.round(s * 1000)} ms` : `${s.toFixed(1)} s`
|
||||
}
|
||||
|
||||
export function Werkzeugverlauf() {
|
||||
const { data, isLoading } = useWerkzeugverlauf()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<SectionLabel icon={Activity}>Werkzeug-Verlauf</SectionLabel>
|
||||
<div className="mc-card-sm p-4 text-xs text-muted-foreground">Lese Hermes' Log …</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Auf einem Entwicklungsrechner ohne Hermes gibt es das Log nicht. Dann still
|
||||
// weglassen statt eine leere Karte zu zeigen — wie der Rest der Seite es hält.
|
||||
if (!data?.verfuegbar) return null
|
||||
|
||||
const { rufe, werkzeuge, laeufe } = data
|
||||
const fehlerhaft = werkzeuge.filter((w) => w.fehler > 0)
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<SectionLabel icon={Activity}>
|
||||
Werkzeug-Verlauf · {rufe.length} Rufe
|
||||
{fehlerhaft.length > 0 && ` · ${fehlerhaft.reduce((n, w) => n + w.fehler, 0)} Fehler`}
|
||||
</SectionLabel>
|
||||
|
||||
<p className="max-w-2xl text-[11px] leading-relaxed text-muted-foreground">
|
||||
Gelesen aus Hermes' eigenem Log — Mission Control schaut zu, greift aber nicht ein.
|
||||
Was dort <em>nicht</em> steht, steht auch hier nicht: Lucys Gedankengang und die
|
||||
Argumente eines Werkzeug-Rufs bleiben unsichtbar.
|
||||
</p>
|
||||
|
||||
{/* Bilanz zuerst — hier faellt ein Dauerfehler auf, den eine Zeitleiste verschluckt. */}
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{werkzeuge.map((w) => {
|
||||
const immerRot = w.fehler > 0 && w.fehler === w.rufe
|
||||
return (
|
||||
<div
|
||||
key={w.werkzeug}
|
||||
className={cn("mc-card-sm space-y-1.5 p-3",
|
||||
immerRot && "border-red-500/40 bg-red-500/5")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Wrench className={cn("h-3.5 w-3.5 shrink-0",
|
||||
immerRot ? "text-red-400" : w.fehler ? "text-amber-400" : "text-primary")} aria-hidden="true" />
|
||||
<span className="truncate font-mono text-xs font-semibold text-foreground">{w.werkzeug}</span>
|
||||
{immerRot && (
|
||||
<span className="ml-auto shrink-0 rounded bg-red-500/15 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-red-300">
|
||||
immer rot
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
<span className="tabular-nums">{w.rufe} Rufe</span>
|
||||
<span className={cn("tabular-nums", w.fehler > 0 && "font-semibold text-red-400")}>
|
||||
{w.fehler} Fehler
|
||||
</span>
|
||||
<span className="tabular-nums">Ø {dauer(w.dauer_schnitt_s)}</span>
|
||||
</div>
|
||||
{w.letzter_fehler && (
|
||||
<p className="border-t border-border/30 pt-1.5 text-[10px] leading-relaxed text-red-300/90">
|
||||
{w.letzter_fehler}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Laeufe: welcher Cron/welche Sitzung hat wann was getan. */}
|
||||
{laeufe.length > 0 && (
|
||||
<div className="mc-card-sm divide-y divide-border/30">
|
||||
{laeufe.map((l) => (
|
||||
<div key={l.lauf} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 font-mono text-[10px]">
|
||||
<Clock className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="truncate font-semibold text-foreground">{l.lauf}</span>
|
||||
<span className="tabular-nums text-muted-foreground">{uhr(l.von)}–{uhr(l.bis)}</span>
|
||||
<span className="ml-auto tabular-nums text-muted-foreground">{l.rufe} Rufe</span>
|
||||
{l.fehler > 0 && (
|
||||
<span className="tabular-nums font-semibold text-red-400">{l.fehler} Fehler</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zeitleiste, juengster Ruf oben. */}
|
||||
<details className="mc-card-sm">
|
||||
<summary className="cursor-pointer px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground">
|
||||
Einzelne Rufe ({rufe.length})
|
||||
</summary>
|
||||
<ul className="max-h-72 divide-y divide-border/20 overflow-y-auto scrollbar-thin border-t border-border/30">
|
||||
{[...rufe].reverse().map((r, i) => (
|
||||
<li key={`${r.zeit}-${r.werkzeug}-${i}`} className="flex items-start gap-2 px-3 py-1.5 font-mono text-[10px]">
|
||||
{r.ok
|
||||
? <CheckCircle2 className="mt-px h-3 w-3 shrink-0 text-emerald-500" aria-hidden="true" />
|
||||
: <AlertTriangle className="mt-px h-3 w-3 shrink-0 text-red-400" aria-hidden="true" />}
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">{uhr(r.zeit)}</span>
|
||||
<span className="shrink-0 font-semibold text-foreground">{r.werkzeug}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">{dauer(r.dauer_s)}</span>
|
||||
{r.zeichen != null && (
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground/60">{r.zeichen} Z.</span>
|
||||
)}
|
||||
{r.fehler && <span className="min-w-0 flex-1 truncate text-red-300/90" title={r.fehler}>{r.fehler}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import { Dichteleiste } from "./Dichteleiste"
|
||||
import type { ChronikItem } from "@/lib/api"
|
||||
|
||||
function eintrag(ts: number): ChronikItem {
|
||||
return { id: ts, ts, subject: "x", text: "y", source: "test", priority: "normal" }
|
||||
}
|
||||
|
||||
/** Die Balken der Leiste (ohne die Achsen-Beschriftung darunter). */
|
||||
function balken(container: HTMLElement): HTMLElement[] {
|
||||
return [...container.querySelectorAll('[role="img"] > div')] as HTMLElement[]
|
||||
}
|
||||
|
||||
describe("Dichteleiste", () => {
|
||||
it("zeigt nichts, wenn es nichts zu zeigen gibt", () => {
|
||||
const { container } = render(<Dichteleiste items={[]} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it("verteilt die Einträge über die volle Breite", () => {
|
||||
const basis = 1_800_000_000
|
||||
const items = [eintrag(basis), eintrag(basis + 43_200), eintrag(basis + 86_400)]
|
||||
const { container } = render(<Dichteleiste items={items} />)
|
||||
const b = balken(container)
|
||||
expect(b).toHaveLength(48)
|
||||
// Erster, mittlerer und letzter Eimer sind belegt — dazwischen ist es leer.
|
||||
expect(b[0].style.height).not.toBe("2px")
|
||||
expect(b[47].style.height).not.toBe("2px")
|
||||
expect(b[5].style.height).toBe("2px")
|
||||
})
|
||||
|
||||
// Ohne Mindesthöhe sähe ein ruhiger Tag aus wie gar keine Aktivität — genau die
|
||||
// Verwechslung, die die Leiste verhindern soll.
|
||||
it("macht auch einen einzelnen Eintrag sichtbar", () => {
|
||||
const { container } = render(<Dichteleiste items={[eintrag(1_800_000_000)]} />)
|
||||
const belegt = balken(container).filter((d) => d.style.height !== "2px")
|
||||
expect(belegt).toHaveLength(1)
|
||||
expect(parseFloat(belegt[0].style.height)).toBeGreaterThanOrEqual(12)
|
||||
})
|
||||
|
||||
it("beschriftet sich für Screenreader mit Anzahl und Zeitraum", () => {
|
||||
const basis = 1_800_000_000
|
||||
const { container } = render(<Dichteleiste items={[eintrag(basis), eintrag(basis + 86_400)]} />)
|
||||
const label = container.querySelector('[role="img"]')!.getAttribute("aria-label")!
|
||||
expect(label).toMatch(/2 Einträge/)
|
||||
expect(label).toMatch(/zwischen .* und /)
|
||||
})
|
||||
|
||||
it("hebt die Leiste hervor, solange gefiltert wird", () => {
|
||||
const items = [eintrag(1_800_000_000)]
|
||||
const normal = render(<Dichteleiste items={items} />).container
|
||||
const gefiltert = render(<Dichteleiste items={items} aktiv />).container
|
||||
const klasse = (c: HTMLElement) =>
|
||||
balken(c).find((d) => d.style.height !== "2px")!.className
|
||||
expect(klasse(normal)).toMatch(/primary\/40/)
|
||||
expect(klasse(gefiltert)).toMatch(/primary\/70/)
|
||||
})
|
||||
})
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useMemo } from "react"
|
||||
import type { ChronikItem } from "@/lib/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Aktivitäts-Dichte über der Chronik (v3-Umbau P6, Spezifikation §4.5).
|
||||
//
|
||||
// Der eigentliche Zugewinn gegenüber einer reinen Liste: Sie zeigt, WANN die Box aktiv
|
||||
// war, bevor man liest, WAS sie getan hat. Ein stiller Tag und eine durchgearbeitete
|
||||
// Nacht sehen in einer Liste gleich aus — man muss scrollen, um es zu merken.
|
||||
//
|
||||
// Bewusst eigenes SVG statt einer Diagramm-Bibliothek: Es sind Rechtecke über einer
|
||||
// Zeitachse. Recharts dafür zu laden wäre 95 kB für etwas, das in 30 Zeilen passt —
|
||||
// und genau dieser Reflex hat in P0 das Startbündel aufgebläht.
|
||||
|
||||
const STUNDE = 3_600
|
||||
const EIMER = 48 // 48 Stufen über den gezeigten Zeitraum
|
||||
|
||||
export function Dichteleiste({ items, aktiv }: {
|
||||
items: ChronikItem[]
|
||||
/** Farbe für den Fall, dass gefiltert wird — dann zeigt die Leiste nur die Treffer. */
|
||||
aktiv?: boolean
|
||||
}) {
|
||||
const { balken, von, bis, max } = useMemo(() => {
|
||||
if (!items.length) return { balken: [] as number[], von: 0, bis: 0, max: 0 }
|
||||
// Die Chronik kommt neueste zuerst; für die Achse brauchen wir beide Enden.
|
||||
const zeiten = items.map((i) => i.ts)
|
||||
const von = Math.min(...zeiten)
|
||||
const bis = Math.max(...zeiten)
|
||||
const spanne = Math.max(bis - von, STUNDE)
|
||||
const balken = new Array(EIMER).fill(0)
|
||||
for (const t of zeiten) {
|
||||
const i = Math.min(EIMER - 1, Math.floor(((t - von) / spanne) * EIMER))
|
||||
balken[i]++
|
||||
}
|
||||
return { balken, von, bis, max: Math.max(...balken, 1) }
|
||||
}, [items])
|
||||
|
||||
if (balken.length === 0) return null
|
||||
|
||||
const zeitpunkt = (ts: number) =>
|
||||
new Date(ts * 1000).toLocaleDateString("de-DE", {
|
||||
day: "2-digit", month: "2-digit",
|
||||
...(bis - von < 2 * 86_400 ? { hour: "2-digit", minute: "2-digit" } : {}),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div
|
||||
className="flex h-8 items-end gap-px"
|
||||
role="img"
|
||||
aria-label={`Aktivitätsdichte: ${items.length} Einträge zwischen ${zeitpunkt(von)} und ${zeitpunkt(bis)}`}
|
||||
>
|
||||
{balken.map((n, i) => (
|
||||
<div
|
||||
key={i}
|
||||
title={n === 1 ? "1 Eintrag" : `${n} Einträge`}
|
||||
className={cn(
|
||||
"min-w-px flex-1 rounded-t-sm transition-colors",
|
||||
n === 0 ? "bg-border/25" : aktiv ? "bg-primary/70" : "bg-primary/40",
|
||||
)}
|
||||
// Auch ein einzelner Eintrag muss sichtbar sein — sonst sieht ein ruhiger
|
||||
// Tag aus wie gar nichts.
|
||||
style={{ height: n === 0 ? "2px" : `${Math.max(12, (n / max) * 100)}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between font-mono text-[9px] text-muted-foreground/60">
|
||||
<span>{zeitpunkt(von)}</span>
|
||||
<span className="tabular-nums">{items.length} Einträge</span>
|
||||
<span>{zeitpunkt(bis)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
// Ergebnis-Panel einer abgeschlossenen Idee: was die Box getan hat, in ihren Worten.
|
||||
//
|
||||
// Herausgeschnitten aus views/IdeenView.tsx (1031 Zeilen) beim v3-Umbau P5, Befund B-06.
|
||||
// Der Inhalt ist UNVERAENDERT — nur die Datei ist neu. So bleibt der Diff pruefbar.
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { ClipboardCheck } from "lucide-react"
|
||||
import { api, type IdeenErgebnis } from "@/lib/api"
|
||||
|
||||
export function ErgebnisPanel({ id }: { id: string }) {
|
||||
const [erg, setErg] = useState<IdeenErgebnis | null>(null)
|
||||
const [fehler, setFehler] = useState(false)
|
||||
// Fehler darf nicht kleben: fällt der erste Versuch in ein Deploy-Fenster
|
||||
// (Zentrale startet gerade neu), muss ein Klick neu laden können (Vorfall 16.07.).
|
||||
const [versuch, setVersuch] = useState(0)
|
||||
useEffect(() => {
|
||||
let weg = false
|
||||
setFehler(false)
|
||||
setErg(null)
|
||||
api<IdeenErgebnis>(`/api/ideen/${id}/ergebnis`)
|
||||
.then((r) => { if (!weg) setErg(r) })
|
||||
.catch(() => { if (!weg) setFehler(true) })
|
||||
return () => { weg = true }
|
||||
}, [id, versuch])
|
||||
const text = erg ? (erg.summary || erg.result) : ""
|
||||
return (
|
||||
<div className="ml-2 -mt-1">
|
||||
<div className="rounded-lg border border-emerald-500/25 bg-emerald-500/5 p-2.5">
|
||||
<p className="mb-1 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wide text-emerald-300">
|
||||
<ClipboardCheck className="h-3 w-3" /> Ergebnis des Workers
|
||||
</p>
|
||||
{fehler ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersuch((v) => v + 1)}
|
||||
className="text-[10px] text-red-400 underline decoration-dotted underline-offset-2 hover:text-red-300"
|
||||
>
|
||||
Ergebnis nicht ladbar — nochmal versuchen
|
||||
</button>
|
||||
) : erg === null ? (
|
||||
<p className="text-[10px] text-muted-foreground animate-pulse">Wird geladen …</p>
|
||||
) : text ? (
|
||||
<p className="max-h-64 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-foreground/85">{text}</p>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-foreground italic">
|
||||
Der Worker hat keine Abschluss-Zusammenfassung hinterlassen (ältere Karte oder Protokollverstoß).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Die Seite selbst: Kopf + die Queue. Bewusst duenn — die Logik lebt unveraendert in
|
||||
// IdeenSection, damit dieser Umzug reines Verschieben bleibt und nichts still kippt.
|
||||
@@ -1,676 +0,0 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
import {
|
||||
Loader2, AlertTriangle, ChevronDown, ChevronRight, Clock, Hammer, Lightbulb, Send,
|
||||
HelpCircle, RotateCcw, Square, ArrowUp, ArrowDown, Code2, FileText, GitFork, X, Check,
|
||||
} from "lucide-react"
|
||||
import { api, type IdeenItem } from "@/lib/api"
|
||||
import { useIdeen, useIdeenLog, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { fmtWhen } from "@/lib/format"
|
||||
import { SectionLabel } from "@/components/ui/SectionLabel"
|
||||
import { AKTIV_RANG, ART_UI, FRAGE_HINT, IDEEN_STATE } from "./zustaende"
|
||||
import { quoteLesen, quoteZaehlen } from "./quote"
|
||||
import { ErgebnisPanel } from "./ErgebnisPanel"
|
||||
import { KonzeptPanel } from "./KonzeptPanel"
|
||||
import { ProjektGruppe } from "./ProjektGruppe"
|
||||
import { Wahl } from "./Wahl"
|
||||
|
||||
// Ideen-Queue (natives Hermes-Kanban) — eigene Seite seit 25.07.2026.
|
||||
// Vorher steckte sie als 703-Zeilen-Abschnitt in der Auftragsbuch-Ansicht und war damit
|
||||
// die MEHRHEIT einer Seite, die nach etwas anderem benannt war. Die beiden laufen in
|
||||
// entgegengesetzte Richtungen: Auftragsbuch = die Box schlaegt DIR vor (Eingang),
|
||||
// Ideen = DU beauftragst die Box (Ausgang). Seit dem Umbau ist dieser Weg der
|
||||
// Haupteingang des Systems (Idee -> Konzept -> Repo -> Agentic IDE) und gehoert nach vorn.
|
||||
//
|
||||
// v3-Umbau P5 (Befund B-06): Die Datei hatte 1031 Zeilen und 26 useState. Fuenf
|
||||
// eigenstaendige Komponenten (Wahl, ProjektGruppe, KonzeptPanel, WeiterLeiste,
|
||||
// ErgebnisPanel) und die Zustandstabellen liegen jetzt daneben — sie hatten ohnehin
|
||||
// klare Props und keine Verflechtung mit dem Rest.
|
||||
//
|
||||
// WAS BEWUSST NOCH HIER STEHT: der Zeilen-Renderer `zeile()`. Er greift auf 16
|
||||
// Werte des umgebenden Zustands zu (busy, openLog, openKonzept, antworten, logData,
|
||||
// steuer, archive, answer, …). Ihn herauszuloesen heisst nicht, Code zu verschieben,
|
||||
// sondern die Zustands-Zugehoerigkeit neu zu entwerfen — eine Props-Liste mit 16
|
||||
// Eintraegen waere nur eine andere Schreibweise desselben Knotens. Das ist eine eigene
|
||||
// Runde wert und keine Beifaenger-Aenderung.
|
||||
|
||||
function IdeenSection() {
|
||||
const { data, isLoading } = useIdeen()
|
||||
const qc = useQueryClient()
|
||||
const { showAlert, showConfirm, dialogElement } = useDialog()
|
||||
const [text, setText] = useState("")
|
||||
// GENAU EINE Auswahl aus drei, in zwei Bereichen (26.07.2026):
|
||||
// denken → du baust selbst, aber erst ein Konzept. Auf „gefällt mir" wird daraus ein Repo.
|
||||
// repo → du baust selbst, Konzept + Gitea-Repo sofort.
|
||||
// box → die Box baut, Ergebnis kommt als Patch-Vorschlag.
|
||||
//
|
||||
// Vorher (25.07.) waren das zwei angeblich unabhängige Dimensionen: „wer baut" plus ein
|
||||
// Haken „nur denken". Das war FALSCH modelliert — `weiterfuehren` macht aus einer
|
||||
// durchdachten Idee genau die IDE-Projekt-Karte, die `repo` direkt anlegt. Es sind also
|
||||
// zwei Stufen EINES Weges, nicht zwei Achsen. Und die Kombination box+denken schob einen
|
||||
// still in die IDE-Welt (submit setzte welt:"ide"), ohne das irgendwo zu sagen.
|
||||
const [modus, setModus] = useState<"denken" | "repo" | "box">("denken")
|
||||
const [tiefe, setTiefe] = useState<"schlank" | "gruendlich">("schlank")
|
||||
// Zugeklappt statt Assistent (26.07.2026): der Entscheidungsbaum EXISTIERT — Wer baut das?,
|
||||
// und nur im IDE-Ast: wie startest du? — aber du läufst ihn nur, wenn die Einordnung
|
||||
// danebenlag. Im Normalfall steht da eine Zeile und du drückst Enter.
|
||||
const [offen, setOffen] = useState(false)
|
||||
// Hat die Box den Text wirklich gelesen? Steuert nur das Etikett „gelesen" in der
|
||||
// zugeklappten Zeile. Ohne Einordnung (Text zu kurz, Aufruf gescheitert) steht dort die
|
||||
// Voreinstellung — und die darf sich nicht als Verständnis ausgeben.
|
||||
const [vorgeschlagen, setVorgeschlagen] = useState(false)
|
||||
const isIde = modus !== "box" // denken UND repo laufen über das projektstart-Profil
|
||||
const isProjekt = modus === "repo" // nur DANN sind Tiefe und Ziel jetzt schon nötig
|
||||
// Ziel-Plattform des Projekts (nur IDE-Welt): läuft es als LXC-Dienst oder in Docker?
|
||||
const [ziel, setZiel] = useState<"lxc" | "docker">("lxc")
|
||||
const [sending, setSending] = useState(false)
|
||||
const [busy, setBusy] = useState<Record<string, boolean>>({})
|
||||
// v3-Umbau P2 (Befund B-05): Die aufgeklappte Karte lag in useState. Wer waehrend
|
||||
// eines laufenden Workers neu lud — und genau dann schaut man hin — verlor seinen
|
||||
// Platz in der Liste. Jetzt steht sie in der URL (/ideen?offen=<id>) und ist teilbar.
|
||||
const navigate = useNavigate()
|
||||
const suche = useSearch({ from: "/ideen" })
|
||||
const openLog = suche.offen ?? null
|
||||
const setOpenLog = (id: string | null) =>
|
||||
navigate({ to: "/ideen", search: (alt) => ({ ...alt, offen: id ?? undefined }), replace: true })
|
||||
const [openKonzept, setOpenKonzept] = useState<string | null>(null)
|
||||
const [antworten, setAntworten] = useState<Record<string, string>>({})
|
||||
|
||||
const items = data?.items ?? []
|
||||
const reload = () => qc.invalidateQueries({ queryKey: qk.ideen })
|
||||
|
||||
// Die Box liest mit und wählt vor. Gemessen ~500 ms auf dem dauerwarmen Hirn, deshalb
|
||||
// reicht eine kurze Tipp-Pause. Bewusst ein VORSCHLAG: er setzt nur die Auswahl, die du
|
||||
// ohnehin siehst und mit einem Klick überschreibst. Scheitert er, passiert schlicht nichts
|
||||
// — kein Ladebalken, keine Fehlermeldung, das Tippen wird nie blockiert.
|
||||
useEffect(() => {
|
||||
const t = text.trim()
|
||||
if (t.length < 12) { setVorgeschlagen(false); return }
|
||||
let verworfen = false
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const r = await api<{ ok: boolean; wer?: "box" | "ide"; tiefe?: "schlank" | "gruendlich"; nur_denken?: boolean }>(
|
||||
"/api/ideen/einordnen", { method: "POST", body: JSON.stringify({ text: t }) })
|
||||
if (verworfen || !r?.ok) return
|
||||
// Die drei Karten aus der Einordnung ableiten: eine Frage („lohnt sich…?") wird zum
|
||||
// Durchdenken, ein eigenständiges Programm zum Repo, alles am Heimlabor zur Box.
|
||||
setModus(r.nur_denken ? "denken" : r.wer === "ide" ? "repo" : "box")
|
||||
if (r.tiefe) setTiefe(r.tiefe)
|
||||
setVorgeschlagen(true)
|
||||
} catch { /* stumm — die Voreinstellung bleibt einfach stehen */ }
|
||||
}, 700)
|
||||
return () => { verworfen = true; clearTimeout(timer) }
|
||||
}, [text])
|
||||
|
||||
// Polling-Log nur aktivieren, wenn Idee triage/running und geöffnet
|
||||
const logEnabled = openLog !== null && (data?.items.find((i) => i.id === openLog)?.status === "triage" || data?.items.find((i) => i.id === openLog)?.status === "running")
|
||||
const logData = useIdeenLog(openLog ?? "", logEnabled)
|
||||
|
||||
async function submit() {
|
||||
const t = text.trim()
|
||||
if (t.length < 3 || sending) return
|
||||
setSending(true)
|
||||
try {
|
||||
// Lange Eingaben: Kurzfassung als Titel, alles als Notiz — der Specifier der Box
|
||||
// macht daraus ohnehin einen sauberen Auftrag.
|
||||
const titel = t.length > 160 ? t.slice(0, 157) + "…" : t
|
||||
await api("/api/ideen", { method: "POST", body: JSON.stringify({
|
||||
titel, notiz: t.length > 160 ? t : "",
|
||||
// Abbildung auf die unveränderte API. Tiefe und Ziel gehen NUR beim Repo-Weg mit —
|
||||
// beim Durchdenken fragt die Weiter-Leiste sie später beim „gefällt mir", und dann
|
||||
// weiss man mehr als jetzt.
|
||||
welt: isIde ? "ide" : "box",
|
||||
tiefe: isProjekt ? (tiefe === "gruendlich" ? "gruendlich" : "simpel") : "",
|
||||
ziel: isProjekt ? ziel : "",
|
||||
art: modus === "denken" ? "idee" : "projekt",
|
||||
}) })
|
||||
quoteZaehlen("gesendet")
|
||||
setText("")
|
||||
setVorgeschlagen(false)
|
||||
setOffen(false)
|
||||
reload()
|
||||
} catch (e: any) {
|
||||
showAlert("Das hat nicht geklappt", String(e?.message || e))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Sammel-Aufräumen: fertige Karten wegräumen, ohne 18-mal auf das X zu klicken.
|
||||
// Fragt vorher nach — und sagt dabei ehrlich, dass nichts gelöscht wird, sondern
|
||||
// ins Kanban-Archiv wandert. Das Backend fasst ausschliesslich `done` an.
|
||||
function archiveAlle(projekt: string, anzahl: number, was: string) {
|
||||
showConfirm(
|
||||
`${anzahl} fertige Karte${anzahl === 1 ? "" : "n"} wegräumen?`,
|
||||
`${was} verschwindet aus der Liste. Gelöscht wird nichts — die Karten wandern ins ` +
|
||||
`Archiv der Queue und bleiben dort lesbar. Laufende, hängende und wartende Karten ` +
|
||||
`bleiben unberührt.`,
|
||||
async () => {
|
||||
const key = "arch:" + (projekt || "*")
|
||||
setBusy((b) => ({ ...b, [key]: true }))
|
||||
try {
|
||||
const r = await api<{ archiviert: number; fehlgeschlagen: { titel: string }[] }>(
|
||||
"/api/ideen/archivieren-alle", { method: "POST", body: JSON.stringify({ projekt }) })
|
||||
if (r.fehlgeschlagen?.length) {
|
||||
showAlert("Teilweise erledigt",
|
||||
`${r.archiviert} weggeräumt, ${r.fehlgeschlagen.length} nicht: ` +
|
||||
r.fehlgeschlagen.map((f) => f.titel).join(", "))
|
||||
}
|
||||
reload()
|
||||
} catch (e: any) {
|
||||
showAlert("Das hat nicht geklappt", String(e?.message || e))
|
||||
} finally {
|
||||
setBusy((b) => ({ ...b, [key]: false }))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function archive(id: string) {
|
||||
setBusy((b) => ({ ...b, [id]: true }))
|
||||
try {
|
||||
await api("/api/ideen/archivieren", { method: "POST", body: JSON.stringify({ id }) })
|
||||
reload()
|
||||
} catch (e: any) {
|
||||
showAlert("Das hat nicht geklappt", String(e?.message || e))
|
||||
} finally {
|
||||
setBusy((b) => ({ ...b, [id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
// Karten-Steuerung (Stopp / Neuer Versuch / Prio) — ein Helfer für alle drei Endpunkte.
|
||||
async function steuer(prefix: string, id: string, pfad: string, body: object) {
|
||||
setBusy((b) => ({ ...b, [prefix + id]: true }))
|
||||
try {
|
||||
await api(pfad, { method: "POST", body: JSON.stringify(body) })
|
||||
reload()
|
||||
} catch (e: any) {
|
||||
showAlert("Das hat nicht geklappt", String(e?.message || e))
|
||||
} finally {
|
||||
setBusy((b) => ({ ...b, [prefix + id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
// Hängende Aufgabe beantworten: die Antwort geht als Kommentar an die Karte, der Worker
|
||||
// macht damit weiter (Ausweg aus der Sackgasse „fragt, aber niemand kann antworten").
|
||||
async function answer(id: string) {
|
||||
const t = (antworten[id] || "").trim()
|
||||
if (t.length < 1 || busy["ans:" + id]) return
|
||||
setBusy((b) => ({ ...b, ["ans:" + id]: true }))
|
||||
try {
|
||||
await api("/api/ideen/antwort", { method: "POST", body: JSON.stringify({ id, antwort: t }) })
|
||||
setAntworten((a) => ({ ...a, [id]: "" }))
|
||||
reload()
|
||||
} catch (e: any) {
|
||||
showAlert("Das hat nicht geklappt", String(e?.message || e))
|
||||
} finally {
|
||||
setBusy((b) => ({ ...b, ["ans:" + id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
// EINE Zeilen-Darstellung für alle Sichten (Projekt-Gruppen + Einzelkarten) — damit
|
||||
// Antwortfeld, Live-Log und Ergebnis-Panel überall identisch funktionieren.
|
||||
const zeile = (it: IdeenItem) => {
|
||||
const ui = IDEEN_STATE[it.status] ?? { label: it.status, cls: "text-muted-foreground border-border/60 bg-background/30" }
|
||||
const isOpen = openLog === it.id
|
||||
// Aufklappbar: laufende Karten zeigen das Live-Log, FERTIGE das Ergebnis
|
||||
// (User-Wunsch 15.07.: „Dinge werden getestet, ich sehe das Ergebnis nicht").
|
||||
const canOpen = it.status === "triage" || it.status === "running" || it.status === "done"
|
||||
const blocked = it.status === "blocked"
|
||||
const answering = !!busy["ans:" + it.id]
|
||||
// Hänger-Verdacht: läuft, aber seit >10 min kein Lebenszeichen (Heartbeat) — der
|
||||
// Prozess kann trotzdem „leben" (wartet z. B. auf einen selbst gestarteten Server).
|
||||
const haengt = it.status === "running" && (it.puls_alter ?? 0) > 600
|
||||
const wartend = it.status === "todo" || it.status === "ready" || it.status === "scheduled"
|
||||
return (
|
||||
<li key={it.id} className={cn("flex flex-col gap-2 rounded-xl border bg-background/30 px-3 py-2", blocked ? "border-red-500/30" : haengt ? "border-amber-500/40" : "border-border/40")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("flex shrink-0 items-center gap-1.5 rounded-lg border px-2 py-0.5 text-[10px] font-bold", ui.cls)}>
|
||||
{ui.spin && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{ui.label}
|
||||
</span>
|
||||
{haengt && (
|
||||
<span className="flex shrink-0 items-center gap-1 rounded-lg border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-bold text-amber-300"
|
||||
title="Der Worker lebt, gibt aber kein Lebenszeichen mehr — vermutlich wartet er auf etwas, das nie kommt. „Neuer Versuch“ startet die Karte frisch.">
|
||||
<AlertTriangle className="h-3 w-3" /> {Math.round((it.puls_alter ?? 0) / 60)} min ohne Regung
|
||||
</span>
|
||||
)}
|
||||
{(it.prio ?? 0) !== 0 && (
|
||||
<span className="shrink-0 rounded bg-background/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
|
||||
title="Priorität — höher kommt zuerst dran (bei gleicher Prio: Erstellzeit)">P{it.prio}</span>
|
||||
)}
|
||||
{(() => {
|
||||
const a = ART_UI[it.art || (it.welt === "ide" ? "projekt" : "box")]
|
||||
if (!a) return null
|
||||
const Icon = a.icon
|
||||
return (
|
||||
<span className={cn("flex shrink-0 items-center gap-1 rounded-lg border px-2 py-0.5 text-[10px] font-bold", a.cls)} title={a.title}>
|
||||
<Icon className="h-3 w-3" /> {a.label}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
{/* Tooltip = der volle TITEL (der ist abgeschnitten), nicht der Auftragstext —
|
||||
der beginnt mit Vorspann-Textbausteinen und half beim Lesen nie. */}
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-foreground" title={it.titel}>{it.titel}</span>
|
||||
<span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(it.erstellt)}</span>
|
||||
{wartend && (
|
||||
<span className="flex shrink-0 items-center">
|
||||
<button
|
||||
disabled={!!busy["pr:" + it.id]}
|
||||
onClick={() => steuer("pr:", it.id, "/api/ideen/prio", { id: it.id, richtung: "hoch" })}
|
||||
title="Vorziehen (Prio +1)"
|
||||
className="rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
disabled={!!busy["pr:" + it.id]}
|
||||
onClick={() => steuer("pr:", it.id, "/api/ideen/prio", { id: it.id, richtung: "runter" })}
|
||||
title="Zurückstellen (Prio −1)"
|
||||
className="rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{(it.status === "running" || blocked) && (
|
||||
<button
|
||||
disabled={!!busy["rt:" + it.id]}
|
||||
onClick={() =>
|
||||
showConfirm("Neuer Versuch?",
|
||||
it.status === "running"
|
||||
? `Der laufende Worker von '${it.titel.slice(0, 60)}' wird beendet (samt gestarteter Test-Prozesse) und die Karte frisch gestartet. Bisheriger Zwischenstand im Workspace bleibt liegen.`
|
||||
: `Die Karte '${it.titel.slice(0, 60)}' wird entsperrt und läuft weiter, sobald ein Platz frei ist.`,
|
||||
() => steuer("rt:", it.id, "/api/ideen/retry", { id: it.id }))}
|
||||
title="Neuer Versuch — Karte frisch starten"
|
||||
className={cn("shrink-0 rounded-md p-1 transition-colors cursor-pointer disabled:opacity-50",
|
||||
haengt ? "text-amber-300 hover:text-amber-200" : "text-muted-foreground/60 hover:text-foreground")}>
|
||||
{busy["rt:" + it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RotateCcw className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
{(it.status === "running" || wartend || it.status === "triage") && (
|
||||
<button
|
||||
disabled={!!busy["st:" + it.id]}
|
||||
onClick={() =>
|
||||
showConfirm("Karte stoppen?",
|
||||
`'${it.titel.slice(0, 60)}' wird angehalten${it.status === "running" ? " und der laufende Worker beendet" : ""}. Sie bleibt als gestoppt im Board und kann jederzeit per „Neuer Versuch“ weiterlaufen.`,
|
||||
() => steuer("st:", it.id, "/api/ideen/stopp", { id: it.id }))}
|
||||
title="Stoppen — Karte anhalten"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-red-300 cursor-pointer disabled:opacity-50">
|
||||
{busy["st:" + it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Square className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
{(it.status === "done" || blocked) && (
|
||||
<button
|
||||
disabled={!!busy[it.id]}
|
||||
onClick={() => archive(it.id)}
|
||||
title={blocked ? "Aufgeben und aus der Liste räumen (wandert ins Kanban-Archiv)" : "Aus der Liste räumen (wandert ins Kanban-Archiv)"}
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
|
||||
{busy[it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
{it.welt === "ide" && (it.status === "running" || it.status === "done") && (
|
||||
<button
|
||||
onClick={() => setOpenKonzept(openKonzept === it.id ? null : it.id)}
|
||||
title="Konzept ansehen (das ausgearbeitete KONZEPT.md)"
|
||||
className={cn("flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[10px] font-bold transition-colors cursor-pointer",
|
||||
openKonzept === it.id ? "text-violet-300" : "text-violet-300/70 hover:text-violet-200")}>
|
||||
<FileText className="h-3.5 w-3.5" /> Konzept
|
||||
</button>
|
||||
)}
|
||||
{canOpen && (
|
||||
<button
|
||||
onClick={() => setOpenLog(isOpen ? null : it.id)}
|
||||
title={it.status === "done" ? "Ergebnis anzeigen" : "Worker-Log anzeigen"}
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer">
|
||||
{isOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{openKonzept === it.id && (
|
||||
<KonzeptPanel
|
||||
id={it.id}
|
||||
istIdee={it.art === "idee" && it.status === "done"}
|
||||
onWeiter={reload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{it.status === "running" && it.notiz && (
|
||||
<p className="-mt-1 ml-1 text-[11px] italic leading-relaxed text-sky-200/80" title="Jüngste Zwischenmeldung des Workers (Heartbeat)">
|
||||
„{it.notiz}“
|
||||
</p>
|
||||
)}
|
||||
{(it.status === "todo" || it.status === "scheduled" || it.status === "ready") && (it.wartet_auf?.length ?? 0) > 0 && (
|
||||
<p className="-mt-1 ml-1 text-[10px] text-muted-foreground/70">
|
||||
→ wartet auf: {it.wartet_auf!.join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{blocked && (
|
||||
<div className="space-y-2 border-t border-red-500/20 pt-2">
|
||||
<p className="flex items-start gap-1.5 text-[11px] leading-relaxed text-red-200/90">
|
||||
<HelpCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-300" />
|
||||
<span>
|
||||
<b>{FRAGE_HINT[it.frage_kind || ""] ?? "Die Aufgabe hängt und wartet auf dich:"}</b>{" "}
|
||||
{it.frage
|
||||
? <span className="text-foreground/90">{it.frage}</span>
|
||||
: <span className="text-muted-foreground italic">kein genauer Grund hinterlegt — antworte einfach mit einer Entscheidung, dann läuft sie weiter.</span>}
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={antworten[it.id] || ""}
|
||||
onChange={(e) => setAntworten((a) => ({ ...a, [it.id]: e.target.value }))}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") answer(it.id) }}
|
||||
placeholder="Deine Antwort — die Box macht damit weiter"
|
||||
maxLength={2000}
|
||||
className="h-8 min-w-0 flex-1 rounded-lg border border-border/60 bg-background/60 px-2.5 text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-red-400/50 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => answer(it.id)}
|
||||
disabled={answering || (antworten[it.id] || "").trim().length < 1}
|
||||
className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 text-[10px] font-bold uppercase tracking-wide text-emerald-300 transition-all hover:bg-emerald-500/20 cursor-pointer disabled:opacity-50">
|
||||
{answering ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />} Antworten & weiter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOpen && canOpen && it.status !== "done" && (
|
||||
<div className="ml-2 -mt-1">
|
||||
<div className="rounded-lg border border-border/50 bg-background/50 p-2">
|
||||
{logEnabled && logData.isLoading ? (
|
||||
<p className="text-[10px] text-muted-foreground animate-pulse">Log wird geladen …</p>
|
||||
) : logData.error ? (
|
||||
<p className="text-[10px] text-red-400">Log nicht ladbar</p>
|
||||
) : logData.data?.available && logData.data.lines.length > 0 ? (
|
||||
<pre className="max-h-64 overflow-auto font-mono text-[10px] leading-relaxed text-foreground/80 whitespace-pre-wrap">
|
||||
{logData.data.lines.join("\n")}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-foreground">Kein Log verfügbar</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOpen && it.status === "done" && <ErgebnisPanel id={it.id} />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// Windows-Dev / Queue nicht verfügbar → Sektion still weglassen (wie der Rest der Seite).
|
||||
if (data && !data.available) return null
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
{/* Kopfzeile sagt in einem Blick, was DICH betrifft — vorher stand hier nur eine
|
||||
Gesamtzahl „offen", die nichts darüber verriet, ob etwas auf dich wartet.
|
||||
Rechts daneben das Sammel-Aufräumen, das nur erscheint, wenn es etwas zu räumen gibt. */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<SectionLabel icon={Lightbulb}>
|
||||
{(() => {
|
||||
if (!data) return "Ideen-Queue"
|
||||
const n = (s: string[]) => items.filter((i) => s.includes(i.status)).length
|
||||
const teile = [
|
||||
n(["blocked"]) > 0 ? `${n(["blocked"])} braucht dich` : "",
|
||||
n(["running", "triage", "review"]) > 0 ? `${n(["running", "triage", "review"])} in Arbeit` : "",
|
||||
n(["todo", "scheduled", "ready"]) > 0 ? `${n(["todo", "scheduled", "ready"])} wartet` : "",
|
||||
n(["done"]) > 0 ? `${n(["done"])} fertig` : "",
|
||||
].filter(Boolean)
|
||||
// Nur der Zustand, nicht nochmal der Seitenname: „Ideen" steht bereits als
|
||||
// Überschrift darüber und ein drittes Mal in der Kopfzeile der App.
|
||||
return teile.length ? teile.join(" · ") : "noch nichts in der Queue"
|
||||
})()}
|
||||
</SectionLabel>
|
||||
{(() => {
|
||||
const fertig = items.filter((i) => i.status === "done").length
|
||||
if (fertig === 0) return null
|
||||
return (
|
||||
<button
|
||||
disabled={!!busy["arch:*"]}
|
||||
onClick={() => archiveAlle("", fertig, `Alle ${fertig} fertigen Karten`)}
|
||||
title="Räumt ALLE fertigen Karten weg — auch die in abgeschlossenen Projekt-Ketten. Laufende, hängende und wartende bleiben unberührt."
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-border/60 px-2.5 py-1 text-[11px] font-semibold text-muted-foreground transition-all hover:border-primary/40 hover:text-foreground disabled:opacity-50 cursor-pointer">
|
||||
{busy["arch:*"] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
||||
{fertig} fertige wegräumen
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
<div className="mc-card p-4 space-y-4">
|
||||
{/* Schreiben zuerst. Alles darunter ist eine Entscheidung ZU dem, was hier steht —
|
||||
nicht davor. Der Knopf heisst immer gleich; was passiert, steht bei den Karten. */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={text}
|
||||
onChange={(e) => { setText(e.target.value); setVorgeschlagen(false) }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") submit() }}
|
||||
placeholder="Was soll entstehen?"
|
||||
className="h-10 min-w-0 flex-1 rounded-lg border border-border/60 bg-background/50 px-3 text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-primary/50 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={sending || text.trim().length < 3}
|
||||
className="flex h-10 shrink-0 items-center gap-1.5 rounded-lg border border-primary/40 bg-primary/10 px-4 text-xs font-semibold text-primary transition-all hover:bg-primary/20 disabled:opacity-40 cursor-pointer">
|
||||
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
Los
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ZUGEKLAPPT STATT ASSISTENT. Der Entscheidungsbaum ist derselbe, den ein Wizard
|
||||
abfragen wuerde — Wer baut das?, und NUR im IDE-Ast: wie startest du? Nur laeufst
|
||||
du ihn erst, wenn die Einordnung danebenlag. Ein Assistent wuerde dich fragen, was
|
||||
die Box in ~500 ms schon weiss; das hier fragt nur im Zweifelsfall.
|
||||
IMMER sichtbar (26.07., User-Befund): vorher hing der ganze Block an
|
||||
`text.length >= 3` — bei leerem Feld gab es also keinerlei Hinweis, dass es
|
||||
ueberhaupt eine Wahl gibt. Ein Mechanismus, den man nicht sieht, existiert fuer
|
||||
den Nutzer nicht. Ohne Text steht dort die Voreinstellung, klar als solche
|
||||
benannt, und laesst sich schon vorher aendern. */}
|
||||
{(
|
||||
offen ? (
|
||||
<div className="space-y-3 rounded-xl border border-border/40 bg-background/20 p-3">
|
||||
<div>
|
||||
<p className="mb-1.5 text-[11px] text-muted-foreground/70">Wer baut das?</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Wahl aktiv={modus !== "box"}
|
||||
onWahl={() => setModus(modus === "box" ? "denken" : modus)}
|
||||
k={{ id: "ide", icon: Code2, titel: "Ich, in der Agentic IDE",
|
||||
text: "Du bekommst Konzept und Repo — den Code schreibst du selbst." }} />
|
||||
<Wahl aktiv={modus === "box"}
|
||||
onWahl={() => setModus("box")}
|
||||
k={{ id: "box", icon: Hammer, titel: "Die Box, allein",
|
||||
text: "Für dein Heimlabor. Das Ergebnis kommt als Branch nach Gitea — mergen tust du." }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Die zweite Frage gibt es NUR im IDE-Ast — eingerückt, damit die Verschachtelung
|
||||
sichtbar ist. Bei „die Box" folgt keine Rückfrage: dann geht es direkt los. */}
|
||||
{modus !== "box" && (
|
||||
<div className="border-l-2 border-primary/40 pl-3">
|
||||
<p className="mb-1.5 text-[11px] text-muted-foreground/70">Und wie startest du?</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Wahl aktiv={modus === "denken"} onWahl={() => setModus("denken")}
|
||||
k={{ id: "denken", icon: Lightbulb, titel: "Erst durchdenken",
|
||||
text: "Konzept zuerst — auf dein Ja wird ein Repo daraus." }} />
|
||||
<Wahl aktiv={modus === "repo"} onWahl={() => setModus("repo")}
|
||||
k={{ id: "repo", icon: GitFork, titel: "Direkt ein Repo",
|
||||
text: "Konzept und Gitea-Repo in einem Zug." }} />
|
||||
</div>
|
||||
{modus === "repo" ? (
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<span className="text-[11px] text-muted-foreground/70">Wie gründlich?</span>
|
||||
<div className="flex rounded-lg border border-border/60 p-0.5">
|
||||
{([["schlank", "schlank"], ["gruendlich", "gründlich"]] as const).map(([id, label]) => (
|
||||
<button key={id} onClick={() => setTiefe(id)}
|
||||
className={cn("rounded px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer",
|
||||
tiefe === id ? "bg-primary/15 text-primary" : "text-muted-foreground/60 hover:text-foreground")}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
<span className="text-[11px] text-muted-foreground/70">Läuft als</span>
|
||||
<div className="flex rounded-lg border border-border/40 p-0.5">
|
||||
{([["lxc", "LXC"], ["docker", "Docker"]] as const).map(([id, label]) => (
|
||||
<button key={id} onClick={() => setZiel(id)}
|
||||
title={id === "lxc"
|
||||
? "Dienst in einem eigenen Proxmox-LXC (systemd, kein Docker). Wird vom Backup-Server mitgesichert."
|
||||
: "Natives Docker: Dockerfile + compose, portabel und pipeline-tauglich."}
|
||||
className={cn("rounded px-2 py-0.5 text-[10px] font-semibold transition-all cursor-pointer",
|
||||
ziel === id ? "bg-foreground/10 text-foreground" : "text-muted-foreground/50 hover:text-foreground/80")}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 flex items-start gap-1.5 text-[10px] leading-relaxed text-muted-foreground/60">
|
||||
<HelpCircle className="mt-px h-3 w-3 shrink-0" />
|
||||
Wie gründlich und wo es läuft, fragt die Box erst beim „gefällt mir" — dann weißt du mehr.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Beweisbar statt behauptet: die Quote, an der dieser ganze Entwurf haengt. */}
|
||||
{(() => {
|
||||
const q = quoteLesen()
|
||||
if (q.gesendet < 3) return null
|
||||
return (
|
||||
<p className="border-t border-border/30 pt-2 text-[10px] text-muted-foreground/50">
|
||||
Du hast {q.geaendert} von {q.gesendet} Vorschlägen geändert. Wird das die Regel,
|
||||
bauen wir hier einen richtigen Schritt-für-Schritt-Assistenten.
|
||||
</p>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-border/40 bg-background/20 px-3 py-2.5 text-xs">
|
||||
{modus === "box" ? <Hammer className="h-4 w-4 shrink-0 text-primary" />
|
||||
: modus === "repo" ? <GitFork className="h-4 w-4 shrink-0 text-primary" />
|
||||
: <Lightbulb className="h-4 w-4 shrink-0 text-primary" />}
|
||||
{/* Ohne Einordnung ist der Satz eine EINSTELLUNG, keine Aussage über deinen
|
||||
Text — das muss dranstehen, sonst behauptet die Zeile ein Verständnis,
|
||||
das es (noch) nicht gibt. */}
|
||||
{!vorgeschlagen && (
|
||||
<span className="shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground/50">
|
||||
Voreingestellt
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground">
|
||||
{modus === "box" ? "Ich baue das selbst und lege dir das Ergebnis als Vorschlag hin."
|
||||
: modus === "repo" ? "Du baust selbst — ich lege dir Konzept und Repo an."
|
||||
: "Du baust selbst — ich arbeite erst ein Konzept aus."}
|
||||
</span>
|
||||
{vorgeschlagen && (
|
||||
<span className="shrink-0 rounded border border-primary/40 bg-primary/10 px-1.5 py-0.5 text-[10px] font-bold text-primary">
|
||||
gelesen
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setOffen(true); quoteZaehlen("geaendert") }}
|
||||
className="ml-auto shrink-0 text-[11px] text-muted-foreground/60 underline underline-offset-2 transition-colors hover:text-foreground cursor-pointer">
|
||||
anders
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{isLoading && (
|
||||
<p className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin" /> Queue wird geladen …</p>
|
||||
)}
|
||||
{data?.fehler && (
|
||||
<p className="text-xs text-amber-300">Queue gerade nicht lesbar — neuer Versuch läuft.</p>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{(data?.projekte ?? []).map((p) => (
|
||||
<ProjektGruppe key={p.key} projekt={p} karten={items.filter((i) => i.projekt === p.key)}
|
||||
zeile={zeile} onArchivAlle={archiveAlle} raeumt={!!busy["arch:" + p.key]} />
|
||||
))}
|
||||
{(() => {
|
||||
// Einzelkarten (keine Kette). Bis 25.07. eine flache Erstellzeit-Liste — bei
|
||||
// 19 Karten, davon 16 fertig, gingen die drei relevanten darin unter. Jetzt
|
||||
// dieselbe Ordnung, die ProjektGruppe für Ketten längst benutzt: aktiv zuerst
|
||||
// nach AKTIV_RANG, Wartende darunter, Fertige eingeklappt.
|
||||
const keys = new Set((data?.projekte ?? []).map((p) => p.key))
|
||||
const einzeln = items.filter((i) => !i.projekt || !keys.has(i.projekt))
|
||||
if (einzeln.length === 0) return null
|
||||
const aktiv = einzeln
|
||||
.filter((i) => AKTIV_RANG[i.status] !== undefined)
|
||||
.sort((a, b) => (AKTIV_RANG[a.status] - AKTIV_RANG[b.status]) || ((a.erstellt ?? 0) - (b.erstellt ?? 0)))
|
||||
const wartend = einzeln.filter((i) => ["todo", "scheduled"].includes(i.status))
|
||||
const fertig = einzeln.filter((i) => i.status === "done")
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{aktiv.length > 0 && <ul className="space-y-1.5">{aktiv.map((it) => zeile(it))}</ul>}
|
||||
{wartend.length > 0 && (
|
||||
<details className="group">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-1.5 py-1 text-[11px] text-muted-foreground/70 hover:text-foreground">
|
||||
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
|
||||
<Clock className="h-3 w-3" /> Wartet ({wartend.length})
|
||||
</summary>
|
||||
<ul className="mt-1.5 space-y-1.5">{wartend.map((it) => zeile(it))}</ul>
|
||||
</details>
|
||||
)}
|
||||
{fertig.length > 0 && (
|
||||
<details className="group">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-1.5 py-1 text-[11px] text-muted-foreground/70 hover:text-foreground">
|
||||
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
|
||||
<Check className="h-3 w-3 text-emerald-400/70" /> Fertig ({fertig.length})
|
||||
</summary>
|
||||
<ul className="mt-1.5 space-y-1.5">{fertig.map((it) => zeile(it))}</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{data?.available && !isLoading && items.length === 0 && (
|
||||
// Leerer Zustand als Einladung statt als Feststellung: genau jetzt möchte man
|
||||
// wissen, was man überhaupt hineinschreiben kann. Ein Klick füllt das Feld —
|
||||
// die Box ordnet den Beispieltext dann selbst ein.
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] text-muted-foreground/70">Noch nichts hier. Zum Beispiel:</p>
|
||||
{[
|
||||
"Ein kleines Werkzeug, das meine Fotos nach Aufnahmedatum in Ordner sortiert",
|
||||
"Rippy soll die Untertitel-Spur automatisch auswählen statt zu fragen",
|
||||
"Lohnt sich ein Dashboard für die Stromzähler, oder reicht Home Assistant?",
|
||||
].map((b) => (
|
||||
<button key={b} onClick={() => setText(b)}
|
||||
className="flex w-full items-center gap-2 rounded-lg border border-border/40 bg-background/20 px-3 py-2 text-left text-xs text-muted-foreground transition-all hover:border-primary/40 hover:text-foreground cursor-pointer">
|
||||
<Lightbulb className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
|
||||
{b}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{dialogElement}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Eine der drei Auswahl-Karten. Der Text steht AN der Option, nicht in einer Legende
|
||||
// darunter — man liest nur, was zu dem gehört, was man gerade anschaut.
|
||||
|
||||
export function IdeenView() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent">
|
||||
Ideen
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Schreib hin, was entstehen soll. Die Box liest mit und schlägt vor, wie es weitergeht —
|
||||
ändern kannst du das immer.
|
||||
</p>
|
||||
</div>
|
||||
<IdeenSection />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// Konzept-Panel einer Idee: zeigt den Entwurf, den Lucy geschrieben hat, und bietet
|
||||
// den naechsten Schritt an (nachschaerfen oder als Projekt bauen).
|
||||
//
|
||||
// Herausgeschnitten aus views/IdeenView.tsx (1031 Zeilen) beim v3-Umbau P5, Befund B-06.
|
||||
// Der Inhalt ist UNVERAENDERT — nur die Datei ist neu. So bleibt der Diff pruefbar.
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { FileText, GitFork, RefreshCw } from "lucide-react"
|
||||
import { api, type IdeenKonzept } from "@/lib/api"
|
||||
import { Markdown } from "@/lib/markdown"
|
||||
import { WeiterLeiste } from "./WeiterLeiste"
|
||||
|
||||
export function KonzeptPanel({ id, istIdee, onWeiter }: {
|
||||
id: string
|
||||
istIdee?: boolean
|
||||
onWeiter?: () => void
|
||||
}) {
|
||||
const [data, setData] = useState<IdeenKonzept | null>(null)
|
||||
const [fehler, setFehler] = useState(false)
|
||||
const [versuch, setVersuch] = useState(0)
|
||||
useEffect(() => {
|
||||
let weg = false
|
||||
setData(null); setFehler(false)
|
||||
api<IdeenKonzept>(`/api/ideen/${id}/konzept`)
|
||||
.then((r) => { if (!weg) setData(r) })
|
||||
.catch(() => { if (!weg) setFehler(true) })
|
||||
return () => { weg = true }
|
||||
}, [id, versuch])
|
||||
return (
|
||||
<div className="ml-2 -mt-1">
|
||||
<div className="rounded-lg border border-violet-500/25 bg-violet-500/[0.04] p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<p className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wide text-violet-300">
|
||||
<FileText className="h-3 w-3" /> Konzept
|
||||
</p>
|
||||
{data?.repo && (
|
||||
<a href={`https://git.tobisniceshomelab.ddnsfree.com/${data.repo}`}
|
||||
className="flex items-center gap-1 text-[10px] text-violet-300/70 hover:text-violet-200">
|
||||
<GitFork className="h-3 w-3" /> {data.repo}
|
||||
</a>
|
||||
)}
|
||||
{!data?.repo && data?.quelle === "datei" && (
|
||||
<span className="truncate font-mono text-[10px] text-muted-foreground/70"
|
||||
title="Der Worker hat kein Repo angelegt — das Konzept liegt als Datei auf der Box.">
|
||||
{data.pfad}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={() => setVersuch((v) => v + 1)} title="Neu laden"
|
||||
className="ml-auto rounded p-0.5 text-muted-foreground/50 hover:text-foreground cursor-pointer">
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{fehler ? (
|
||||
<p className="text-[11px] text-red-400">Konzept nicht ladbar — später nochmal versuchen.</p>
|
||||
) : data === null ? (
|
||||
<p className="text-[10px] text-muted-foreground animate-pulse">Konzept wird geladen …</p>
|
||||
) : data.konzept ? (
|
||||
<div className="max-h-[28rem] overflow-y-auto pr-1">
|
||||
<Markdown text={data.konzept} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] text-muted-foreground italic">
|
||||
{data.error || "Noch kein Konzept — das Projekt wird gerade vorbereitet."}
|
||||
</p>
|
||||
)}
|
||||
{istIdee && data?.konzept && <WeiterLeiste id={id} onFertig={onWeiter} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Was nach dem Lesen passieren kann. Zwei Wege, bewusst nebeneinander statt hintereinander:
|
||||
// nachschärfen (ein Punkt passt nicht — häufiger Fall, darum oben und offen) oder annehmen.
|
||||
@@ -1,86 +0,0 @@
|
||||
// Karten-Familie (verkettete Karten = ein Projekt) als Block: Fortschritt oben, dann die
|
||||
// AKTIVEN Karten in Ausfuehrungs-Reihenfolge, Wartende und Fertige zusammengeklappt.
|
||||
// Ersetzt fuer Ketten die flache Erstellzeit-Liste (User: „Reihenfolge komplett durcheinander").
|
||||
//
|
||||
// Herausgeschnitten aus views/IdeenView.tsx (1031 Zeilen) beim v3-Umbau P5, Befund B-06.
|
||||
// Der Inhalt ist UNVERAENDERT — nur die Datei ist neu. So bleibt der Diff pruefbar.
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronDown, ChevronRight, Layers, Loader2, X } from "lucide-react"
|
||||
import type { IdeenItem, IdeenProjekt } from "@/lib/api"
|
||||
import { AKTIV_RANG } from "./zustaende"
|
||||
|
||||
export function ProjektGruppe({ projekt, karten, zeile, onArchivAlle, raeumt }: {
|
||||
projekt: IdeenProjekt
|
||||
karten: IdeenItem[]
|
||||
zeile: (it: IdeenItem) => React.ReactNode
|
||||
onArchivAlle?: (projekt: string, anzahl: number, was: string) => void
|
||||
raeumt?: boolean
|
||||
}) {
|
||||
const [zeigWartende, setZeigWartende] = useState(false)
|
||||
const [zeigFertige, setZeigFertige] = useState(false)
|
||||
const aktiv = karten
|
||||
.filter((i) => AKTIV_RANG[i.status] !== undefined)
|
||||
.sort((a, b) => (AKTIV_RANG[a.status] - AKTIV_RANG[b.status]) || ((a.erstellt ?? 0) - (b.erstellt ?? 0)))
|
||||
const wartend = karten
|
||||
.filter((i) => i.status === "todo" || i.status === "scheduled")
|
||||
.sort((a, b) => ((a.wartet_auf?.length ? 1 : 0) - (b.wartet_auf?.length ? 1 : 0)) || ((a.erstellt ?? 0) - (b.erstellt ?? 0)))
|
||||
const fertig = karten
|
||||
.filter((i) => i.status === "done")
|
||||
.sort((a, b) => (b.fertig ?? 0) - (a.fertig ?? 0))
|
||||
const anteil = projekt.gesamt > 0 ? Math.round((projekt.fertig / projekt.gesamt) * 100) : 0
|
||||
return (
|
||||
<div className="space-y-2 rounded-xl border border-border/50 bg-background/20 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 shrink-0 text-primary/70" />
|
||||
<p className="min-w-0 flex-1 truncate text-xs font-semibold text-foreground" title={projekt.titel}>{projekt.titel}</p>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">{projekt.fertig} von {projekt.gesamt} fertig</span>
|
||||
</div>
|
||||
<div className="h-1 overflow-hidden rounded-full bg-background/60" title={`${anteil}%`}>
|
||||
<div className="h-1 rounded-full bg-primary/70 transition-all" style={{ width: `${anteil}%` }} />
|
||||
</div>
|
||||
{aktiv.length > 0 && <ul className="space-y-1.5">{aktiv.map((it) => zeile(it))}</ul>}
|
||||
{wartend.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setZeigWartende((v) => !v)}
|
||||
className="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
|
||||
{zeigWartende ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
{wartend.length} Karte{wartend.length === 1 ? "" : "n"} in der Kette — laufen automatisch der Reihe nach
|
||||
</button>
|
||||
{zeigWartende && <ul className="mt-1.5 space-y-1.5">{wartend.map((it) => zeile(it))}</ul>}
|
||||
</div>
|
||||
)}
|
||||
{fertig.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setZeigFertige((v) => !v)}
|
||||
className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-300/70 hover:text-emerald-300 transition-colors cursor-pointer">
|
||||
{zeigFertige ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
{fertig.length} fertig
|
||||
</button>
|
||||
{/* Ganze abgeschlossene Kette in einem Zug wegräumen — bei fünf fertigen
|
||||
Projekten waren das sonst ~18 Einzelklicks auf das X. */}
|
||||
{onArchivAlle && (
|
||||
<button
|
||||
disabled={raeumt}
|
||||
onClick={() => onArchivAlle(projekt.key, fertig.length, `„${projekt.titel}"`)}
|
||||
title="Räumt die fertigen Karten dieser Kette weg (Archiv, nichts wird gelöscht)."
|
||||
className="flex items-center gap-1 text-[11px] text-muted-foreground/60 transition-colors hover:text-foreground disabled:opacity-50 cursor-pointer">
|
||||
{raeumt ? <Loader2 className="h-3 w-3 animate-spin" /> : <X className="h-3 w-3" />}
|
||||
wegräumen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{zeigFertige && <ul className="mt-1.5 space-y-1.5">{fertig.map((it) => zeile(it))}</ul>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Konzept einer IDE-Karte: holt es aus dem Gitea-Repo — oder, wenn der Worker keins
|
||||
// angelegt hat, aus seiner Datei in ~/konzepte (Bug-Fund 21.07.: dann stand hier vorher
|
||||
// „noch kein Repo hinterlegt", obwohl das Konzept längst fertig war).
|
||||
// Bei einer fertig durchdachten IDEE hängt unten der Weg nach vorn: „Gefällt mir".
|
||||
@@ -1,27 +0,0 @@
|
||||
// Eine der drei Auswahlkacheln im Ideen-Verfasser (Wer baut das? / Wie startest du?).
|
||||
//
|
||||
// Herausgeschnitten aus views/IdeenView.tsx (1031 Zeilen) beim v3-Umbau P5, Befund B-06.
|
||||
// Der Inhalt ist UNVERAENDERT — nur die Datei ist neu. So bleibt der Diff pruefbar.
|
||||
|
||||
import { Hammer } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function Wahl({ k, aktiv, onWahl }: {
|
||||
k: { id: string; icon: typeof Hammer; titel: string; text: string }
|
||||
aktiv: boolean
|
||||
onWahl: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onWahl}
|
||||
className={cn("rounded-xl border p-3 text-left transition-all cursor-pointer",
|
||||
aktiv ? "border-primary/60 bg-primary/5" : "border-border/50 hover:border-border")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<k.icon className={cn("h-4 w-4 shrink-0", aktiv ? "text-primary" : "text-muted-foreground")} />
|
||||
<span className="text-xs font-semibold text-foreground">{k.titel}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground">{k.text}</p>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
// Die Leiste unter einem fertigen Konzept: nachschaerfen oder als Projekt weiterfuehren.
|
||||
//
|
||||
// Herausgeschnitten aus views/IdeenView.tsx (1031 Zeilen) beim v3-Umbau P5, Befund B-06.
|
||||
// Der Inhalt ist UNVERAENDERT — nur die Datei ist neu. So bleibt der Diff pruefbar.
|
||||
|
||||
import { useState } from "react"
|
||||
import { Code2, Loader2, RefreshCw } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function WeiterLeiste({ id, onFertig }: { id: string; onFertig?: () => void }) {
|
||||
const [ziel, setZiel] = useState<"lxc" | "docker">("lxc")
|
||||
const [tiefe, setTiefe] = useState<"simpel" | "gruendlich">("simpel")
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [neu, setNeu] = useState<{ id: string; art: "projekt" | "runde" } | null>(null)
|
||||
const [fehler, setFehler] = useState("")
|
||||
const [hinweis, setHinweis] = useState("")
|
||||
const ruf = async (pfad: string, rumpf: object, art: "projekt" | "runde") => {
|
||||
setBusy(true); setFehler("")
|
||||
try {
|
||||
const r = await api<{ id: string }>(pfad, { method: "POST", body: JSON.stringify(rumpf) })
|
||||
setNeu({ id: r.id, art })
|
||||
onFertig?.()
|
||||
} catch (e: any) {
|
||||
setFehler(String(e?.message || e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
if (neu) {
|
||||
return (
|
||||
<p className="mt-3 border-t border-violet-500/20 pt-2 text-[11px] text-emerald-300">
|
||||
{neu.art === "projekt" ? (
|
||||
<>Projekt-Karte <span className="font-mono">{neu.id}</span> ist angelegt — die Box bereitet
|
||||
das Repo vor (Konzept übernimmt sie, sie denkt es nicht neu). Danach öffnest du es in der Agentic IDE.</>
|
||||
) : (
|
||||
<>Überarbeitung <span className="font-mono">{neu.id}</span> läuft — die Box arbeitet deine
|
||||
Rückmeldung ein und schreibt die neue Fassung an dieselbe Stelle. Danach hier wieder
|
||||
„Konzept" öffnen.</>
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="mt-3 space-y-3 border-t border-violet-500/20 pt-2.5">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<b className="text-sky-300/90">Passt etwas nicht?</b> Schreib hin, was anders werden soll —
|
||||
die Box arbeitet es ein und lässt den Rest stehen. Beliebig oft wiederholbar.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={hinweis}
|
||||
onChange={(e) => setHinweis(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && hinweis.trim().length >= 3 && !busy) ruf("/api/ideen/nachschaerfen", { id, hinweis }, "runde") }}
|
||||
placeholder="z. B. „Punkt 3: kein WebSocket, das ist mir zu fragil — nimm Polling“"
|
||||
maxLength={2000}
|
||||
className="h-8 min-w-0 flex-1 rounded-lg border border-border/60 bg-background/60 px-2.5 text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-sky-400/50 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => ruf("/api/ideen/nachschaerfen", { id, hinweis }, "runde")}
|
||||
disabled={busy || hinweis.trim().length < 3}
|
||||
className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 text-[10px] font-bold uppercase tracking-wide text-sky-300 transition-all hover:bg-sky-500/20 cursor-pointer disabled:opacity-50">
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
Nachschärfen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<b className="text-violet-300/90">Passt es so?</b> Dann macht die Box daraus ein startklares
|
||||
Repo — Konzept wird übernommen (nicht neu gedacht), dazu Roadmap, AGENTS.md & Co.
|
||||
Gebaut wird danach von dir in der Agentic IDE.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<span className="text-[11px] text-muted-foreground/70">Ziel</span>
|
||||
<div className="flex rounded-lg border border-border/40 p-0.5">
|
||||
{(["lxc", "docker"] as const).map((z) => (
|
||||
<button key={z} onClick={() => setZiel(z)}
|
||||
title={z === "lxc" ? "Dienst in einem eigenen Proxmox-LXC (systemd, kein Docker) — wird automatisch mitgesichert."
|
||||
: "Natives Docker: Dockerfile + compose, portabel und pipeline-tauglich."}
|
||||
className={cn("rounded px-2 py-0.5 text-[10px] font-semibold transition-all cursor-pointer",
|
||||
ziel === z ? "bg-foreground/10 text-foreground" : "text-muted-foreground/50 hover:text-foreground/80")}>
|
||||
{z === "lxc" ? "LXC" : "Docker"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground/70">Aufwand</span>
|
||||
<div className="flex rounded-lg border border-border/40 p-0.5">
|
||||
{(["simpel", "gruendlich"] as const).map((t) => (
|
||||
<button key={t} onClick={() => setTiefe(t)}
|
||||
title={t === "simpel" ? "Konzept ist schon gehärtet — nur kurz auf die Bau-Sicht nachschärfen (empfohlen)."
|
||||
: "Nochmal volle Kaskade über die Bau-Sicht: mehrere Rollen, Härtetests. Dauert entsprechend."}
|
||||
className={cn("rounded px-2 py-0.5 text-[10px] font-semibold transition-all cursor-pointer",
|
||||
tiefe === t ? "bg-foreground/10 text-foreground" : "text-muted-foreground/50 hover:text-foreground/80")}>
|
||||
{t === "simpel" ? "schlank" : "gründlich"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => ruf("/api/ideen/weiterfuehren", { id, ziel, tiefe }, "projekt")}
|
||||
disabled={busy}
|
||||
className="ml-auto flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-violet-500/40 bg-violet-500/10 px-3 text-[10px] font-bold uppercase tracking-wide text-violet-300 transition-all hover:bg-violet-500/20 cursor-pointer disabled:opacity-50">
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Code2 className="h-3.5 w-3.5" />}
|
||||
Gefällt mir — Projekt daraus machen
|
||||
</button>
|
||||
</div>
|
||||
{fehler && <p className="text-[11px] text-red-400">{fehler}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Ergebnis einer FERTIGEN Queue-Aufgabe: die Abschluss-Zusammenfassung des Workers
|
||||
// (kanban_complete). Lazy — wird erst beim Aufklappen geladen, ändert sich nie mehr.
|
||||
@@ -1,22 +0,0 @@
|
||||
// Kleine Nutzungs-Quote der Ideen-Eingabe (v3-Umbau P5, unveraendert aus IdeenView).
|
||||
//
|
||||
// Zaehlt im localStorage mit, wie oft abgeschickt und wie oft die Einordnung korrigiert
|
||||
// wurde. Reine Bedienstatistik, kein Geheimnis — der Zaun aus P1 (ESLint-Regel gegen
|
||||
// Geheimnisse im localStorage) greift hier bewusst nicht.
|
||||
|
||||
const QUOTE_KEY = "mc_ideen_quote"
|
||||
|
||||
export function quoteLesen(): { gesendet: number; geaendert: number } {
|
||||
try {
|
||||
const d = JSON.parse(localStorage.getItem(QUOTE_KEY) || "{}")
|
||||
return { gesendet: Number(d.gesendet) || 0, geaendert: Number(d.geaendert) || 0 }
|
||||
} catch { return { gesendet: 0, geaendert: 0 } }
|
||||
}
|
||||
|
||||
export function quoteZaehlen(feld: "gesendet" | "geaendert"): void {
|
||||
try {
|
||||
const q = quoteLesen()
|
||||
q[feld] += 1
|
||||
localStorage.setItem(QUOTE_KEY, JSON.stringify(q))
|
||||
} catch { /* privater Modus o. ä. — die Zählung ist Beiwerk, nie ein Grund zu scheitern */ }
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
// Zustands- und Art-Tabellen der Ideen-Queue (v3-Umbau P5).
|
||||
//
|
||||
// Herausgeschnitten aus dem 1031-Zeilen-Monolithen views/IdeenView.tsx. Diese Tabellen
|
||||
// sind reine Daten und werden von Liste, Projektgruppe und Panels gebraucht — sie stehen
|
||||
// deshalb fuer sich, statt in einer der Komponenten zu wohnen.
|
||||
|
||||
import { Code2, Hammer, Lightbulb } from "lucide-react"
|
||||
|
||||
export const IDEEN_STATE: Record<string, { label: string; cls: string; spin?: boolean }> = {
|
||||
triage: { label: "wird ausgearbeitet …", cls: "text-violet-300 border-violet-500/40 bg-violet-500/10", spin: true },
|
||||
todo: { label: "wartet", cls: "text-muted-foreground border-border/60 bg-background/30" },
|
||||
scheduled: { label: "geplant", cls: "text-muted-foreground border-border/60 bg-background/30" },
|
||||
ready: { label: "gleich dran", cls: "text-sky-300/80 border-sky-500/30 bg-sky-500/5" },
|
||||
running: { label: "in Arbeit …", cls: "text-sky-300 border-sky-500/40 bg-sky-500/10", spin: true },
|
||||
review: { label: "im Review", cls: "text-amber-300 border-amber-500/40 bg-amber-500/10" },
|
||||
blocked: { label: "hängt — braucht dich", cls: "text-red-300 border-red-500/40 bg-red-500/10" },
|
||||
done: { label: "fertig ✓", cls: "text-emerald-300 border-emerald-500/40 bg-emerald-500/10" },
|
||||
}
|
||||
|
||||
// Welche Welt hat die Karte durchlaufen? Vorher stand auf Idee UND IDE-Projekt dasselbe
|
||||
// „IDE" — man sah der fertigen Karte nicht an, ob sie nur durchdacht oder als Repo
|
||||
// vorbereitet wurde (User 21.07.). Jetzt drei ehrliche Marken.
|
||||
export const ART_UI: Record<string, { label: string; cls: string; icon: typeof Hammer; title: string }> = {
|
||||
box: {
|
||||
label: "BOX", icon: Hammer,
|
||||
cls: "border-border/60 bg-background/40 text-muted-foreground",
|
||||
title: "Box-Welt: die Werkstatt baut das selbst. Das Ergebnis kommt als Patch-Vorschlag zurück — nichts geht ohne deinen Klick live.",
|
||||
},
|
||||
idee: {
|
||||
label: "IDEE", icon: Lightbulb,
|
||||
cls: "border-sky-500/40 bg-sky-500/10 text-sky-300",
|
||||
title: "Nur durchdacht: die Box hat die Produkt-Idee mit voller Denk-Kaskade zu einem Konzept ausgearbeitet. Kein Gerüst, kein Bau — du liest es und entscheidest, ob ein Projekt draus wird.",
|
||||
},
|
||||
projekt: {
|
||||
label: "IDE", icon: Code2,
|
||||
cls: "border-violet-500/40 bg-violet-500/10 text-violet-300",
|
||||
title: "IDE-Welt: die Box bereitet nur vor (Konzept + Repo + Doku, kein Code). Gebaut wird von dir in der Agentic IDE.",
|
||||
},
|
||||
}
|
||||
|
||||
// Wie die Frage einer hängenden Karte eingeleitet wird — je nach Block-Grund des Workers.
|
||||
export const FRAGE_HINT: Record<string, string> = {
|
||||
needs_input: "Die Box hat eine Frage:",
|
||||
capability: "Die Box kam hier nicht weiter:",
|
||||
transient: "Die Aufgabe hakt:",
|
||||
}
|
||||
|
||||
// Wie oft lag die Einordnung daneben? Der ganze zugeklappte Entwurf steht und fällt damit.
|
||||
// Bewusst im Browser statt im Backend: zwei Zahlen brauchen keine Tabelle, keinen Endpunkt
|
||||
// und keine Migration — und es geht niemanden ausser dem Nutzer etwas an. Sichtbar wird die
|
||||
// Quote nur beim Aufklappen, damit sie beweisbar ist statt behauptet.
|
||||
|
||||
// Sortier-Rang der AKTIVEN Karten innerhalb einer Projekt-Gruppe. Steht hier bei den
|
||||
// anderen Zustands-Tabellen, weil ihn Liste UND Projektgruppe brauchen.
|
||||
export const AKTIV_RANG: Record<string, number> = { blocked: 0, running: 1, review: 2, ready: 3, triage: 4 }
|
||||
+153
-238
@@ -1,256 +1,171 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource/barlow/400.css";
|
||||
@import "@fontsource/barlow/500.css";
|
||||
@import "@fontsource/barlow/600.css";
|
||||
@import "@fontsource/barlow-condensed/500.css";
|
||||
@import "@fontsource/barlow-condensed/600.css";
|
||||
@import "@fontsource/barlow-condensed/700.css";
|
||||
@import "@fontsource/jetbrains-mono/400.css";
|
||||
@import "@fontsource/jetbrains-mono/500.css";
|
||||
|
||||
/* Fonts lokal gebündelt (@fontsource) — die Box-UI braucht kein Internet für Schrift.
|
||||
Bewusst KEINE Sammel-Importe (`@fontsource/inter/400.css`): die ziehen alle Subsets
|
||||
(latin-ext, griechisch, kyrillisch) UND je eine WOFF-1-Fassung mit — 112 Dateien,
|
||||
1,58 MB, davon 57 % WOFF 1, das kein Browser dieser App je abruft. Hier stehen
|
||||
deshalb die @font-face-Regeln selbst: nur `latin`, nur WOFF 2, nur die Schnitte,
|
||||
die im Code wirklich vorkommen (geprüft per grep über die font-*-Klassen).
|
||||
Neuen Schnitt gebraucht? Zeile ergänzen — nicht auf den Sammel-Import zurückfallen. */
|
||||
@font-face { font-family: "Inter"; font-style: normal; font-display: swap; font-weight: 400;
|
||||
src: url("@fontsource/inter/files/inter-latin-400-normal.woff2") format("woff2"); }
|
||||
@font-face { font-family: "Inter"; font-style: normal; font-display: swap; font-weight: 500;
|
||||
src: url("@fontsource/inter/files/inter-latin-500-normal.woff2") format("woff2"); }
|
||||
@font-face { font-family: "Inter"; font-style: normal; font-display: swap; font-weight: 600;
|
||||
src: url("@fontsource/inter/files/inter-latin-600-normal.woff2") format("woff2"); }
|
||||
@font-face { font-family: "Inter"; font-style: normal; font-display: swap; font-weight: 700;
|
||||
src: url("@fontsource/inter/files/inter-latin-700-normal.woff2") format("woff2"); }
|
||||
/*
|
||||
* Cockpit (Box-Wart-Umbau 09/2026, Richtung A): dunkles Instrumentenbrett.
|
||||
* Bernstein = Achtung (Hinweise, Hauptknöpfe), Grün = läuft, Cyan = Info/Anzeige,
|
||||
* Rot = Störung. Farbe erscheint nur, wenn etwas ist — im Normalfall bleibt es ruhig.
|
||||
* Nur ein dunkles Design (User-Entscheid 23.09.), daher alle Werte direkt auf :root.
|
||||
*/
|
||||
|
||||
/* Space Grotesk trägt nur Überschriften (font-space): 400 als Grundschnitt, 600/700 benutzt. */
|
||||
@font-face { font-family: "Space Grotesk"; font-style: normal; font-display: swap; font-weight: 400;
|
||||
src: url("@fontsource/space-grotesk/files/space-grotesk-latin-400-normal.woff2") format("woff2"); }
|
||||
@font-face { font-family: "Space Grotesk"; font-style: normal; font-display: swap; font-weight: 600;
|
||||
src: url("@fontsource/space-grotesk/files/space-grotesk-latin-600-normal.woff2") format("woff2"); }
|
||||
@font-face { font-family: "Space Grotesk"; font-style: normal; font-display: swap; font-weight: 700;
|
||||
src: url("@fontsource/space-grotesk/files/space-grotesk-latin-700-normal.woff2") format("woff2"); }
|
||||
|
||||
/* JetBrains Mono: 600 und 700 gab es bisher NICHT — die 19 `font-mono font-bold/semibold`
|
||||
Stellen wurden vom Browser synthetisch fettgerechnet (verschmiert). Bei Monospace ist die
|
||||
Laufweite über alle Schnitte gleich, das Nachrüsten verschiebt also kein Layout. */
|
||||
@font-face { font-family: "JetBrains Mono"; font-style: normal; font-display: swap; font-weight: 400;
|
||||
src: url("@fontsource/jetbrains-mono/files/jetbrains-mono-latin-400-normal.woff2") format("woff2"); }
|
||||
@font-face { font-family: "JetBrains Mono"; font-style: normal; font-display: swap; font-weight: 500;
|
||||
src: url("@fontsource/jetbrains-mono/files/jetbrains-mono-latin-500-normal.woff2") format("woff2"); }
|
||||
@font-face { font-family: "JetBrains Mono"; font-style: normal; font-display: swap; font-weight: 600;
|
||||
src: url("@fontsource/jetbrains-mono/files/jetbrains-mono-latin-600-normal.woff2") format("woff2"); }
|
||||
@font-face { font-family: "JetBrains Mono"; font-style: normal; font-display: swap; font-weight: 700;
|
||||
src: url("@fontsource/jetbrains-mono/files/jetbrains-mono-latin-700-normal.woff2") format("woff2"); }
|
||||
|
||||
|
||||
/* 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%;
|
||||
--foreground: 210 20% 92%;
|
||||
--card: 224 25% 10%;
|
||||
--card-foreground: 210 20% 94%;
|
||||
--popover: 224 28% 9%;
|
||||
--popover-foreground: 210 20% 94%;
|
||||
--primary: 172 72% 50%;
|
||||
--primary-foreground: 224 47% 8%;
|
||||
--muted: 220 16% 14%;
|
||||
--muted-foreground: 215 14% 64%;
|
||||
--accent: 220 16% 16%;
|
||||
--accent-foreground: 210 20% 94%;
|
||||
--border: 222 18% 23%;
|
||||
--input: 222 18% 23%;
|
||||
--ring: 172 72% 50%;
|
||||
--radius: 0.75rem;
|
||||
color-scheme: dark;
|
||||
}
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
--color-primary: hsl(var(--primary));
|
||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||
--color-border: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-ring: hsl(var(--ring));
|
||||
--font-sans: "Barlow", "Segoe UI", system-ui, sans-serif;
|
||||
--font-heading: "Barlow Condensed", "Barlow", "Segoe UI", sans-serif;
|
||||
--font-anzeige: "Barlow Condensed", "Barlow", "Segoe UI", sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--color-panel: var(--panel);
|
||||
--color-erhaben: var(--erhaben);
|
||||
--color-linie: var(--linie);
|
||||
--color-linie-stark: var(--linie-stark);
|
||||
--color-text-2: var(--text-2);
|
||||
--color-text-3: var(--text-3);
|
||||
|
||||
--color-bernstein: var(--bernstein);
|
||||
--color-bernstein-hell: var(--bernstein-hell);
|
||||
--color-bernstein-grund: var(--bernstein-grund);
|
||||
--color-bernstein-lampe: var(--bernstein-lampe);
|
||||
--color-gruen: var(--gruen);
|
||||
--color-gruen-text: var(--gruen-text);
|
||||
--color-gruen-grund: var(--gruen-grund);
|
||||
--color-gruen-rand: var(--gruen-rand);
|
||||
--color-cyan: var(--cyan);
|
||||
--color-cyan-text: var(--cyan-text);
|
||||
--color-cyan-grund: var(--cyan-grund);
|
||||
--color-cyan-rand: var(--cyan-rand);
|
||||
--color-rot: var(--rot);
|
||||
--color-rot-text: var(--rot-text);
|
||||
--color-rot-grund: var(--rot-grund);
|
||||
--color-rot-rand: var(--rot-rand);
|
||||
--color-aus-grund: var(--aus-grund);
|
||||
--color-aus-rand: var(--aus-rand);
|
||||
--color-aus-text: var(--aus-text);
|
||||
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||
--font-space: "Space Grotesk", sans-serif;
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
}
|
||||
|
||||
/* Feste Viewport-Höhe: die App scrollt intern (main), nicht als Seite.
|
||||
100dvh statt 100% — auf Mobilbrowsern mit einfahrender Adressleiste ist 100 % nicht
|
||||
die sichtbare Höhe, der untere Rand (Statuszeile) verschwand darunter.
|
||||
Die 100%-Zeile bleibt als Rückfall für Browser ohne dvh-Einheit stehen. */
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--radius: 0.625rem;
|
||||
|
||||
--background: #0e1012;
|
||||
--foreground: #e6eaee;
|
||||
--card: #15181b;
|
||||
--card-foreground: #e6eaee;
|
||||
--popover: #15181b;
|
||||
--popover-foreground: #e6eaee;
|
||||
--primary: #ffb020;
|
||||
--primary-foreground: #1a1300;
|
||||
--secondary: #1c2126;
|
||||
--secondary-foreground: #e6eaee;
|
||||
--muted: #1c2126;
|
||||
--muted-foreground: #8b949e;
|
||||
--accent: #1c2126;
|
||||
--accent-foreground: #e6eaee;
|
||||
--destructive: #ff5a4f;
|
||||
--border: #262b30;
|
||||
--input: #3a4148;
|
||||
--ring: #5cc8ff;
|
||||
|
||||
--panel: #15181b;
|
||||
--erhaben: #1c2126;
|
||||
--linie: #262b30;
|
||||
--linie-stark: #3a4148;
|
||||
--text-2: #aeb6bf;
|
||||
--text-3: #8b949e;
|
||||
|
||||
--bernstein: #ffb020;
|
||||
--bernstein-hell: #ffc24d;
|
||||
--bernstein-grund: #2a1e05;
|
||||
--bernstein-lampe: #3a2a08;
|
||||
--gruen: #3ddc8a;
|
||||
--gruen-text: #7cc9a0;
|
||||
--gruen-grund: #0f2319;
|
||||
--gruen-rand: #1f5a3b;
|
||||
--cyan: #5cc8ff;
|
||||
--cyan-text: #93d5f8;
|
||||
--cyan-grund: #0c2230;
|
||||
--cyan-rand: #2f7fb0;
|
||||
--rot: #ff5a4f;
|
||||
--rot-text: #ff9a92;
|
||||
--rot-grund: #2e0f0d;
|
||||
--rot-rand: #a33a33;
|
||||
--aus-grund: #171b1f;
|
||||
--aus-rand: #2e343a;
|
||||
--aus-text: #9aa3ad;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: var(--font-sans);
|
||||
-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);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Karten-Optik (ehem. „HUD 3.0"-Layer, jetzt explizite Klassen ohne !important;
|
||||
ungeschichtete Regeln schlagen Tailwind-Utilities von selbst)
|
||||
========================================================================== */
|
||||
|
||||
/* 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;
|
||||
stroke-dashoffset: 18;
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 6 3;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-flow-cyan {
|
||||
animation: flow-cyan 1.2s linear infinite;
|
||||
}
|
||||
|
||||
/* 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) */
|
||||
@media (min-width: 1440px) {
|
||||
html {
|
||||
font-size: 16.5px;
|
||||
@apply font-sans;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
margin: 0;
|
||||
}
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--cyan);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1920px) {
|
||||
html {
|
||||
font-size: 17.5px;
|
||||
|
||||
@layer utilities {
|
||||
/* Beschriftung wie auf einem Instrumentenbrett: schmal, groß, gesperrt. */
|
||||
.schild {
|
||||
font-family: var(--font-anzeige);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
.ziffern {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
@media (min-width: 2560px) {
|
||||
html {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
/* Leuchtende Lampen: nur ein ruhiges Atmen, und nur wenn das System Bewegung erlaubt. */
|
||||
@keyframes lampe-atmen {
|
||||
0%, 100% { box-shadow: 0 0 10px color-mix(in srgb, var(--bernstein) 25%, transparent); }
|
||||
50% { box-shadow: 0 0 22px color-mix(in srgb, var(--bernstein) 45%, transparent); }
|
||||
}
|
||||
@media (min-width: 3440px) {
|
||||
html {
|
||||
font-size: 20px;
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.lampe-atmet { animation: lampe-atmen 3.2s ease-in-out infinite; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// Alle Abfragen und Aktionen der Oberfläche an einem Ort (TanStack Query).
|
||||
//
|
||||
// Takte: Steht der Live-Strom, meldet er Änderungen selbst — die Abfragen sind dann nur
|
||||
// Sicherheitsnetz und laufen fünfmal seltener.
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { api, post } from "./api"
|
||||
import { melden } from "./meldungen"
|
||||
import { stromVerbunden } from "./strom"
|
||||
import type {
|
||||
AktionsErgebnis, Job, Modell, Nutzung, Radar, Sicherung, Start,
|
||||
} from "./typen"
|
||||
|
||||
const takt = (ms: number) => () => (stromVerbunden() ? ms * 5 : ms)
|
||||
|
||||
export const useStart = () =>
|
||||
useQuery({ queryKey: ["start"], queryFn: () => api<Start>("/api/start"), refetchInterval: takt(20_000) })
|
||||
|
||||
export const useModelle = () =>
|
||||
useQuery({
|
||||
queryKey: ["models"],
|
||||
queryFn: () => api<{ models: Modell[]; running: string[] }>("/api/models"),
|
||||
refetchInterval: takt(30_000),
|
||||
})
|
||||
|
||||
export const useNutzung = () =>
|
||||
useQuery({ queryKey: ["nutzung"], queryFn: () => api<Nutzung>("/api/modelle/nutzung"), refetchInterval: 600_000 })
|
||||
|
||||
export const useRadar = () =>
|
||||
useQuery({ queryKey: ["radar"], queryFn: () => api<Radar>("/api/radar"), retry: false, refetchInterval: 300_000 })
|
||||
|
||||
export const useJobs = () =>
|
||||
useQuery({
|
||||
queryKey: ["jobs"],
|
||||
queryFn: () => api<{ jobs: Job[] }>("/api/jobs"),
|
||||
// Läuft etwas, wollen wir den Fortschritt sehen; sonst reicht der Strom.
|
||||
refetchInterval: (q) =>
|
||||
q.state.data?.jobs.some((j) => j.state === "running" || j.state === "queued") ? 2000 : takt(30_000)(),
|
||||
})
|
||||
|
||||
export const useSicherungen = () =>
|
||||
useQuery({
|
||||
queryKey: ["sicherungen"],
|
||||
queryFn: () => api<{ available: boolean; backups: Sicherung[] }>("/api/zeitmaschine"),
|
||||
})
|
||||
|
||||
export interface UpdateDetails {
|
||||
kind: string
|
||||
installed_build?: number | null
|
||||
latest_build?: number | null
|
||||
latest_tag?: string | null
|
||||
count?: number
|
||||
packages?: { name: string; current: string; candidate: string }[]
|
||||
summary?: string
|
||||
action_needed?: boolean | null
|
||||
action_text?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const useUpdateDetails = (art: string | null) =>
|
||||
useQuery({
|
||||
queryKey: ["update-details", art],
|
||||
queryFn: () => api<UpdateDetails>(`/api/maintenance/update-details?kind=${art}`),
|
||||
enabled: !!art,
|
||||
staleTime: 600_000,
|
||||
})
|
||||
|
||||
export interface UpdateLauf {
|
||||
start: string | null
|
||||
ende: string | null
|
||||
status: string
|
||||
fehler: string | null
|
||||
job: string
|
||||
}
|
||||
|
||||
export const useUpdateVerlauf = () =>
|
||||
useQuery({ queryKey: ["updates-verlauf"], queryFn: () => api<{ laeufe: UpdateLauf[] }>("/api/updates/verlauf") })
|
||||
|
||||
export const useProtokoll = (dienst: string | null) =>
|
||||
useQuery({
|
||||
queryKey: ["protokoll", dienst],
|
||||
queryFn: () => api<{ ok: boolean; text?: string; err?: string }>(
|
||||
`/api/maintenance/logs?service=${encodeURIComponent(dienst ?? "")}&lines=200`),
|
||||
enabled: !!dienst,
|
||||
})
|
||||
|
||||
export interface Dienst {
|
||||
name: string
|
||||
unit: string
|
||||
ok: boolean
|
||||
scope: string
|
||||
}
|
||||
|
||||
export const useDienste = () =>
|
||||
useQuery({ queryKey: ["dienste"], queryFn: () => api<{ services: Dienst[] }>("/api/system/services") })
|
||||
|
||||
// --- Aktionen ----------------------------------------------------------------
|
||||
|
||||
function useAktion<V, E extends AktionsErgebnis | Record<string, unknown>>(
|
||||
fn: (v: V) => Promise<E>,
|
||||
opt: { erfolg: (v: V, e: E) => string; neuLaden: string[] },
|
||||
) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: fn,
|
||||
onSuccess: (e, v) => {
|
||||
const ergebnis = e as AktionsErgebnis
|
||||
if (ergebnis && ergebnis.ok === false) {
|
||||
melden("fehler", ergebnis.detail || ergebnis.err || "Das hat nicht geklappt.")
|
||||
} else {
|
||||
melden("erfolg", opt.erfolg(v, e))
|
||||
}
|
||||
for (const k of opt.neuLaden) qc.invalidateQueries({ queryKey: [k] })
|
||||
},
|
||||
onError: (f: Error) => melden("fehler", f.message),
|
||||
})
|
||||
}
|
||||
|
||||
export const useHinweisAktion = () =>
|
||||
useAktion(
|
||||
({ hinweis, aktion }: { hinweis: string; aktion: string; label: string }) =>
|
||||
post<AktionsErgebnis>(`/api/hinweise/${encodeURIComponent(hinweis)}/aktion/${aktion}`),
|
||||
{ erfolg: (v) => `${v.label}: erledigt. Der Wächter prüft in einer Minute nach.`, neuLaden: ["start"] },
|
||||
)
|
||||
|
||||
const UPDATE_PFADE: Record<string, string> = {
|
||||
alle: "/api/maintenance/update-all",
|
||||
pruefen: "/api/maintenance/check-updates",
|
||||
os: "/api/maintenance/os-update",
|
||||
engine: "/api/maintenance/engine-update",
|
||||
swap: "/api/maintenance/swap-update",
|
||||
hermes: "/api/maintenance/hermes-update",
|
||||
}
|
||||
|
||||
export const useUpdateStarten = () =>
|
||||
useAktion((art: string) => post<AktionsErgebnis>(UPDATE_PFADE[art]), {
|
||||
erfolg: (art) => (art === "pruefen" ? "Suche nach Neuem läuft." : "Update läuft. Den Fortschritt siehst du unten."),
|
||||
neuLaden: ["jobs", "start"],
|
||||
})
|
||||
|
||||
export const useJetztSichern = () =>
|
||||
useAktion(() => post<AktionsErgebnis>("/api/system/backup"), {
|
||||
erfolg: () => "Sicherung erstellt.",
|
||||
neuLaden: ["sicherungen", "start"],
|
||||
})
|
||||
|
||||
export const useZurueckspielen = () =>
|
||||
useAktion((datei: string) => post<AktionsErgebnis>("/api/zeitmaschine/restore", { file: datei }), {
|
||||
erfolg: () => "Zurückspielen läuft. Die Dienste starten gleich neu.",
|
||||
neuLaden: ["sicherungen"],
|
||||
})
|
||||
|
||||
export const useModellLaden = () =>
|
||||
useAktion((name: string) => post<AktionsErgebnis>(`/api/models/${encodeURIComponent(name)}/load`), {
|
||||
erfolg: (name) => `${name} wird geladen.`,
|
||||
neuLaden: ["models", "start"],
|
||||
})
|
||||
|
||||
export const useRadarAktion = () =>
|
||||
useAktion(
|
||||
({ id, aktion }: { id: string; aktion: "uebernehmen" | "verwerfen"; name: string }) =>
|
||||
post<AktionsErgebnis>(`/api/radar/${encodeURIComponent(id)}/${aktion}`),
|
||||
{
|
||||
erfolg: (v) => (v.aktion === "uebernehmen" ? `${v.name} übernommen.` : `${v.name} verworfen.`),
|
||||
neuLaden: ["radar", "models", "start"],
|
||||
},
|
||||
)
|
||||
|
||||
export const useRadarSuche = () =>
|
||||
useAktion(() => post<AktionsErgebnis>("/api/radar/suche"), {
|
||||
erfolg: () => "Radar sucht nach neuen Modellen.",
|
||||
neuLaden: ["radar"],
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
// Reine Anzeige-Logik der Cockpit-Seiten — getrennt von den Komponenten, damit sie ohne
|
||||
// DOM testbar bleibt und Vites Hot-Reload die Komponenten-Dateien sauber tauschen kann.
|
||||
|
||||
import type { UpdateDetails } from "./abfragen"
|
||||
import type { Modell, Start } from "./typen"
|
||||
|
||||
// --- Rundinstrument -----------------------------------------------------------------
|
||||
// 240°-Bogen wie ein Zeigerinstrument, Mittelpunkt (100|95) in einer 200×150-Zeichnung.
|
||||
const MX = 100
|
||||
const MY = 95
|
||||
const START = 210 // Grad, links unten
|
||||
const SPANNE = 240
|
||||
|
||||
function punkt(grad: number, r: number): [number, number] {
|
||||
const rad = (grad * Math.PI) / 180
|
||||
return [MX + r * Math.cos(rad), MY - r * Math.sin(rad)]
|
||||
}
|
||||
|
||||
const r2 = (n: number) => Math.round(n * 100) / 100
|
||||
|
||||
/** SVG-Pfad eines Bogens von Anteil `von` bis `bis` (0…1) auf Radius `r`. */
|
||||
export function bogenPfad(von: number, bis: number, r: number): string {
|
||||
const a = Math.max(0, Math.min(1, von))
|
||||
const b = Math.max(a, Math.min(1, bis))
|
||||
const [x0, y0] = punkt(START - SPANNE * a, r)
|
||||
const [x1, y1] = punkt(START - SPANNE * b, r)
|
||||
const gross = (b - a) * SPANNE > 180 ? 1 : 0
|
||||
return `M ${r2(x0)} ${r2(y0)} A ${r} ${r} 0 ${gross} 1 ${r2(x1)} ${r2(y1)}`
|
||||
}
|
||||
|
||||
/** Farbe der Temperatur: grün bis 70 °C, dann Bernstein, ab 85 °C rot. */
|
||||
export function temperaturFarbe(grad: number | null | undefined) {
|
||||
if (grad == null) return "var(--text-3)"
|
||||
if (grad >= 85) return "var(--rot)"
|
||||
if (grad >= 70) return "var(--bernstein)"
|
||||
return "var(--gruen)"
|
||||
}
|
||||
|
||||
// --- Hauptwarnleuchte ---------------------------------------------------------------
|
||||
|
||||
/** Was die Hauptwarnleuchte sagt. Der schweigende Wächter geht allem anderen vor. */
|
||||
export function hauptleuchte(z: Start["zustand"]) {
|
||||
if (!z.waechter_wach) {
|
||||
return { art: "stumm" as const, oben: "Wächter", mitte: "SCHWEIGT", unten: "Seit über 5 Minuten kein Prüflauf" }
|
||||
}
|
||||
if (z.update_laeuft) {
|
||||
return { art: "info" as const, oben: "Wartung", mitte: "UPDATE LÄUFT", unten: "Hinweise ruhen bis zum Ende" }
|
||||
}
|
||||
if (z.anzahl === 0) {
|
||||
return { art: "ok" as const, oben: "Alles", mitte: "IN ORDNUNG", unten: "Keine offenen Hinweise" }
|
||||
}
|
||||
const mitte = `${z.anzahl} HINWEIS${z.anzahl === 1 ? "" : "E"}`
|
||||
return z.stufe === "rot"
|
||||
? { art: "rot" as const, oben: "Störung", mitte, unten: "Drücken zum Ansehen" }
|
||||
: { art: "gelb" as const, oben: "Achtung", mitte, unten: "Drücken zum Ansehen" }
|
||||
}
|
||||
|
||||
// --- Updates ------------------------------------------------------------------------
|
||||
|
||||
/** „Läuft“ und „Neu“ aus den Update-Details lesbar machen. */
|
||||
export function versionen(id: string, d: UpdateDetails | undefined): { laeuft: string; neu: string | null } {
|
||||
if (!d) return { laeuft: "…", neu: null }
|
||||
if (id === "os") return { laeuft: "Ubuntu", neu: d.count ? `${d.count} Pakete` : null }
|
||||
const pre = id === "engine" ? "b" : "v"
|
||||
const laeuft = d.installed_build != null ? `${pre}${d.installed_build}` : "–"
|
||||
const neuer = d.latest_build != null && d.installed_build != null && d.latest_build > d.installed_build
|
||||
return { laeuft, neu: neuer ? `${pre}${d.latest_build}` : null }
|
||||
}
|
||||
|
||||
// --- Modelle ------------------------------------------------------------------------
|
||||
|
||||
/** Rolle eines Modells aus seinen llama-swap-Aliasen. */
|
||||
export function rolleVon(m: Modell): "hirn" | "coder" | null {
|
||||
const a = m.aliases.map((x) => x.toLowerCase())
|
||||
if (a.includes("hermes") || a.includes("fast")) return "hirn"
|
||||
if (a.includes("coder")) return "coder"
|
||||
return null
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { ApiError, api } from "./api"
|
||||
|
||||
/** Ruft api() auf und gibt den erwarteten ApiError typisiert zurueck.
|
||||
* Kommt kein Fehler, scheitert der Test hier statt spaeter an einer Zusicherung. */
|
||||
async function fehlerVon(pfad: string, init?: RequestInit): Promise<ApiError> {
|
||||
try {
|
||||
await api(pfad, init)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) return e
|
||||
throw e
|
||||
}
|
||||
throw new Error(`api(${pfad}) hat keinen Fehler geworfen`)
|
||||
}
|
||||
|
||||
// Antwort-Attrappe: `text()` liefert den Rumpf, den fehlerText() lesen soll.
|
||||
function antwort(status: number, rumpf: string, ok = status < 400): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
statusText: status === 500 ? "Internal Server Error" : "Bad Request",
|
||||
text: async () => rumpf,
|
||||
json: async () => JSON.parse(rumpf),
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function fetchAttrappe(res: Response) {
|
||||
const spion = vi.fn(async () => res)
|
||||
vi.stubGlobal("fetch", spion)
|
||||
return spion
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe("api()", () => {
|
||||
it("gibt den JSON-Rumpf zurueck, wenn alles gut geht", async () => {
|
||||
fetchAttrappe(antwort(200, '{"ok":true,"wert":42}'))
|
||||
await expect(api<{ wert: number }>("/api/test")).resolves.toEqual({ ok: true, wert: 42 })
|
||||
})
|
||||
|
||||
// Der Kern von B-14: frueher warf der Helfer "500 Internal Server Error" und verwarf
|
||||
// den Rumpf — der eigentliche Grund erreichte die Oberflaeche nie.
|
||||
it("hebt FastAPIs `detail` in die Fehlermeldung", async () => {
|
||||
fetchAttrappe(antwort(400, '{"detail":"Dienst \'llama-swap\' nicht erlaubt."}'))
|
||||
await expect(api("/api/maintenance/restart", { method: "POST" }))
|
||||
.rejects.toThrowError("Dienst 'llama-swap' nicht erlaubt.")
|
||||
})
|
||||
|
||||
it("fasst FastAPI-Validierungslisten zu einem Satz zusammen", async () => {
|
||||
fetchAttrappe(antwort(422, '{"detail":[{"msg":"repo fehlt"},{"msg":"quant ungueltig"}]}'))
|
||||
await expect(api("/api/models/install", { method: "POST" }))
|
||||
.rejects.toThrowError("repo fehlt; quant ungueltig")
|
||||
})
|
||||
|
||||
it("faellt auf Status + Statustext zurueck, wenn der Rumpf leer ist", async () => {
|
||||
fetchAttrappe(antwort(500, ""))
|
||||
await expect(api("/api/test")).rejects.toThrowError("500 Internal Server Error")
|
||||
})
|
||||
|
||||
it("laesst sich von einer HTML-Fehlerseite nicht aus dem Tritt bringen", async () => {
|
||||
fetchAttrappe(antwort(502, "<html><body>Bad Gateway</body></html>"))
|
||||
const fehler = await fehlerVon("/api/test")
|
||||
expect(fehler.status).toBe(502)
|
||||
// Kein zweiter Fehler beim Parsen — irgendeine brauchbare Zeile kommt heraus.
|
||||
expect(typeof fehler.message).toBe("string")
|
||||
expect(fehler.message.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("traegt status und detail am Fehlerobjekt", async () => {
|
||||
fetchAttrappe(antwort(400, '{"detail":"kaputt"}'))
|
||||
const fehler = await fehlerVon("/api/test")
|
||||
expect(fehler.status).toBe(400)
|
||||
expect(fehler.detail).toBe("kaputt")
|
||||
})
|
||||
})
|
||||
|
||||
// ── Sicherheits-Wache (B-01) ────────────────────────────────────────────────
|
||||
// Bis zum 28.08.2026 las api() das Box-Sudo-Passwort und den HF-Token aus dem
|
||||
// localStorage und haengte sie an jeden mutierenden Request — als Header UND in den
|
||||
// JSON-Rumpf. Da der Dienst-Nutzer auf der Box mit NOPASSWD: ALL laeuft, war ein XSS
|
||||
// in dieser SPA gleichbedeutend mit Root. Diese Tests sind der Zaun davor: Wer die
|
||||
// Bequemlichkeit je wieder einbaut, macht sie rot.
|
||||
describe("api() traegt keine Geheimnisse", () => {
|
||||
it("schickt keine Kopfzeilen und keinen Rumpf aus dem localStorage", async () => {
|
||||
localStorage.setItem("mc_sudo_password", "geheim123")
|
||||
localStorage.setItem("mc_hf_token", "hf_geheim")
|
||||
|
||||
const spion = fetchAttrappe(antwort(200, '{"ok":true}'))
|
||||
await api("/api/maintenance/restart", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ service: "llama-swap" }),
|
||||
})
|
||||
|
||||
const [, init] = spion.mock.calls[0] as unknown as [string, RequestInit]
|
||||
const kopfzeilen = JSON.stringify(init.headers ?? {})
|
||||
const rumpf = String(init.body ?? "")
|
||||
|
||||
expect(kopfzeilen.toLowerCase()).not.toContain("sudo")
|
||||
expect(kopfzeilen).not.toContain("geheim123")
|
||||
expect(rumpf).not.toContain("geheim123")
|
||||
expect(rumpf).not.toContain("hf_geheim")
|
||||
expect(rumpf).not.toContain("sudo_password")
|
||||
expect(rumpf).not.toContain("hf_token")
|
||||
// Der Rumpf des Aufrufers bleibt unveraendert — nichts wird heimlich angereichert.
|
||||
expect(JSON.parse(rumpf)).toEqual({ service: "llama-swap" })
|
||||
})
|
||||
|
||||
it("erfindet auch ohne Rumpf keinen", async () => {
|
||||
localStorage.setItem("mc_sudo_password", "geheim123")
|
||||
const spion = fetchAttrappe(antwort(200, '{"ok":true}'))
|
||||
await api("/api/maintenance/reboot", { method: "POST" })
|
||||
|
||||
const [, init] = spion.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(init.body ?? null).toBeNull()
|
||||
})
|
||||
})
|
||||
+40
-635
@@ -1,644 +1,49 @@
|
||||
// Schmaler Fetch-Helfer gegen das MC-2-Backend (/api/*).
|
||||
//
|
||||
// v3-Umbau P1 (28.08.2026): Hier wurden frueher zwei Geheimnisse aus dem localStorage
|
||||
// gelesen und an jeden mutierenden Request gehaengt — das Box-Sudo-Passwort (als Header
|
||||
// UND im JSON-Rumpf) und der HuggingFace-Token. Da der Dienst-Nutzer auf der Box mit
|
||||
// `NOPASSWD: ALL` laeuft, war ein XSS in dieser SPA gleichbedeutend mit Root.
|
||||
// Das Sudo-Passwort ist ersatzlos weg (auf der Box gemessen: `sudo -n true` laeuft durch,
|
||||
// es wurde nie gebraucht), der HF-Token liegt jetzt auf der Box (services/geheimnisse.py).
|
||||
// Diese Datei kennt kein Geheimnis mehr — und soll auch nie wieder eines kennen.
|
||||
// Dünner Zugriff auf das MC2-Backend. Bewusst ohne Anmelde- oder Geheimnis-Felder:
|
||||
// Nichts, was der Browser speichert, reist hier mit (Lehre aus v3-Umbau P1).
|
||||
|
||||
/** Fehler mit dem, was das Backend WIRKLICH gesagt hat.
|
||||
*
|
||||
* Vorher warf der Helfer `new Error("500 Internal Server Error")` und verwarf den Rumpf.
|
||||
* FastAPI legt den eigentlichen Grund aber in `detail` — der erreichte die Oberflaeche nie,
|
||||
* und der Nutzer las "500" statt "Dienst 'llama-swap' nicht erlaubt". */
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
readonly detail: string | null
|
||||
constructor(status: number, statusText: string, detail: string | null) {
|
||||
super(detail || `${status} ${statusText}`)
|
||||
this.name = "ApiError"
|
||||
this.status = status
|
||||
this.detail = detail
|
||||
export class ApiFehler extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public detail: string,
|
||||
) {
|
||||
super(detail)
|
||||
this.name = "ApiFehler"
|
||||
}
|
||||
}
|
||||
|
||||
/** Liest den Fehler-Rumpf, ohne selbst zu scheitern: Ein kaputter oder leerer Rumpf darf
|
||||
* die Fehlermeldung nicht in einen zweiten Fehler verwandeln. */
|
||||
async function fehlerText(res: Response): Promise<string | null> {
|
||||
/** FastAPI legt Fehlertexte in `detail` ab — manchmal als Text, manchmal als Liste. */
|
||||
function detailAus(body: unknown, status: number): string {
|
||||
if (body && typeof body === "object" && "detail" in body) {
|
||||
const d = (body as { detail: unknown }).detail
|
||||
if (typeof d === "string") return d
|
||||
if (Array.isArray(d)) return d.map((x) => (x as { msg?: string }).msg ?? String(x)).join("; ")
|
||||
}
|
||||
return `Die Box antwortet mit Fehler ${status}.`
|
||||
}
|
||||
|
||||
export async function api<T>(pfad: string, init: RequestInit = {}): Promise<T> {
|
||||
let antwort: Response
|
||||
try {
|
||||
const roh = await res.text()
|
||||
if (!roh) return null
|
||||
try {
|
||||
const d = JSON.parse(roh)
|
||||
const detail = d?.detail ?? d?.err ?? d?.message
|
||||
if (typeof detail === "string" && detail.trim()) return detail.trim()
|
||||
if (Array.isArray(detail) && detail.length) {
|
||||
// FastAPI-Validierungsfehler: Liste aus {loc, msg, type}
|
||||
return detail.map((e: any) => e?.msg).filter(Boolean).join("; ") || null
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
// Kein JSON (HTML-Fehlerseite eines Proxys o. AE.) — erste Zeile reicht als Hinweis.
|
||||
return roh.slice(0, 200).split("\n")[0].trim() || null
|
||||
}
|
||||
antwort = await fetch(pfad, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json", ...init.headers },
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
throw new ApiFehler(0, "MC2 ist nicht erreichbar. Läuft die Box?")
|
||||
}
|
||||
const text = await antwort.text()
|
||||
const body = text ? safeJson(text) : null
|
||||
if (!antwort.ok) throw new ApiFehler(antwort.status, detailAus(body, antwort.status))
|
||||
return body as T
|
||||
}
|
||||
|
||||
function safeJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
export async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...init?.headers },
|
||||
})
|
||||
if (!res.ok) throw new ApiError(res.status, res.statusText, await fehlerText(res))
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
// llama-swap-`groups`: Mitglieder mit swap=false dürfen GLEICHZEITIG resident sein (Ko-Residenz).
|
||||
// Die `brains`-Gruppe hält Hirn (fast) + Augen (vision) gemeinsam warm, statt sich zu verdrängen.
|
||||
export interface GroupSpec {
|
||||
swap: boolean
|
||||
persist: boolean
|
||||
members: string[]
|
||||
}
|
||||
export interface GroupsResp {
|
||||
groups: Record<string, GroupSpec>
|
||||
}
|
||||
export const getGroups = () => api<GroupsResp>("/api/groups")
|
||||
export const setGroup = (group: string, members: string[], swap = false, persist = true) =>
|
||||
api("/api/groups", { method: "PUT", body: JSON.stringify({ group, members, swap, persist }) })
|
||||
|
||||
export interface Capabilities {
|
||||
moe: boolean
|
||||
active_b: number | null
|
||||
tools: "yes" | "likely" | "no"
|
||||
vision: boolean
|
||||
coder: boolean
|
||||
reasoning: boolean
|
||||
embedding: boolean
|
||||
ctx: number | null
|
||||
params_b: number | null
|
||||
arch: string | null
|
||||
}
|
||||
|
||||
export interface Fit {
|
||||
level: "perfect" | "marginal" | "too_tight"
|
||||
text: string
|
||||
req_gb: number
|
||||
tps: number
|
||||
}
|
||||
|
||||
export interface RoleRecModel {
|
||||
name: string
|
||||
current_role: string | null
|
||||
params_b: number
|
||||
quant: string
|
||||
fit: Fit
|
||||
suitable: boolean
|
||||
incomplete: boolean
|
||||
score: number
|
||||
reason: string
|
||||
recommended: boolean
|
||||
}
|
||||
|
||||
export interface RoleRecResp {
|
||||
role: string
|
||||
recommended: string | null
|
||||
models: RoleRecModel[]
|
||||
}
|
||||
|
||||
export interface FitResp {
|
||||
params_b: number
|
||||
fit: Fit
|
||||
optimal_ctx: number
|
||||
assigned_ctx: number
|
||||
budget: { gtt_gb: number; reserved_gb: number; budget_gb: number; mode: string }
|
||||
sys_ram_gb: number
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
name: string
|
||||
role: string | null
|
||||
aliases: string[]
|
||||
api_ids: string[]
|
||||
ctx: number | null
|
||||
ttl: number | null
|
||||
cmd: string
|
||||
gguf_path: string
|
||||
filename: string
|
||||
quant: string
|
||||
size_bytes: number | null
|
||||
incomplete: boolean
|
||||
prompt_cache: boolean
|
||||
spec_draft_model: string | null
|
||||
spec_type: string | null
|
||||
spec_active: boolean
|
||||
parallel_slots: number
|
||||
capabilities: Capabilities
|
||||
}
|
||||
|
||||
export interface VocabFingerprint {
|
||||
model: string | null
|
||||
pre: string | null
|
||||
n_vocab: number | null
|
||||
arch: string | null
|
||||
}
|
||||
|
||||
export interface DraftInfo {
|
||||
path: string
|
||||
filename: string
|
||||
size_bytes: number | null
|
||||
vocab: VocabFingerprint | null
|
||||
compatible: boolean | null // null = nicht prüfbar (Ziel-GGUF fehlt)
|
||||
mtp?: boolean // MTP-Kopf (Multi-Token-Prediction) statt klassischem Draft
|
||||
}
|
||||
|
||||
export interface DraftsResp {
|
||||
target_path: string
|
||||
target_exists: boolean
|
||||
target_vocab: VocabFingerprint | null
|
||||
drafts: DraftInfo[]
|
||||
}
|
||||
|
||||
export interface DiscoverModel {
|
||||
name: string
|
||||
author: string
|
||||
repo: string
|
||||
role: string
|
||||
params_b: number
|
||||
quant: string
|
||||
tags: string[]
|
||||
downloads: number
|
||||
fit: Fit
|
||||
optimal_ctx: number
|
||||
caps: Capabilities
|
||||
}
|
||||
|
||||
export interface DiscoverCategory {
|
||||
role: string
|
||||
title: string
|
||||
icon: string
|
||||
models: DiscoverModel[]
|
||||
recommended: string | null
|
||||
}
|
||||
|
||||
export interface DiscoverResp {
|
||||
updated: number
|
||||
categories: DiscoverCategory[]
|
||||
sys_ram_gb: number
|
||||
}
|
||||
|
||||
export interface RoutingResp {
|
||||
mode?: string
|
||||
endpoint?: string
|
||||
heavy_threshold_chars?: number
|
||||
routes: { name: string; target: string }[]
|
||||
lanes?: { name: string; target: string; threshold_chars?: number; escalate_chars?: number; aka?: string }[]
|
||||
fallbacks: Record<string, string[]>[]
|
||||
context_window_fallbacks: Record<string, string[]>[]
|
||||
gateway_reachable: boolean
|
||||
}
|
||||
|
||||
// UI-editierbare Routing-Policy (GET/PUT /api/routing/policy). Aliase + Zeichen-Schwellen
|
||||
// hinter den Lanes; hot-reload im Backend (kein Restart).
|
||||
export interface RoutingPolicy {
|
||||
fast: string
|
||||
heavy: string
|
||||
coder: string
|
||||
coder_lite: string
|
||||
heavy_chars: number
|
||||
coding_escalate_chars: number
|
||||
fast_no_think: boolean
|
||||
}
|
||||
export interface RoutingPolicyField {
|
||||
key: keyof RoutingPolicy
|
||||
label: string
|
||||
type: "str" | "int" | "bool"
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
export interface RoutingPolicyMeta {
|
||||
policy: RoutingPolicy
|
||||
defaults: RoutingPolicy
|
||||
fields: RoutingPolicyField[]
|
||||
}
|
||||
export const getRoutingPolicy = () => api<RoutingPolicyMeta>("/api/routing/policy")
|
||||
export const updateRoutingPolicy = (patch: Partial<RoutingPolicy>) =>
|
||||
api<{ policy: RoutingPolicy }>("/api/routing/policy", { method: "PUT", body: JSON.stringify(patch) })
|
||||
|
||||
export interface GitInfo {
|
||||
hash: string
|
||||
date: string
|
||||
subject: string
|
||||
branch: string
|
||||
dirty: boolean
|
||||
path: string
|
||||
}
|
||||
|
||||
// Engine kann git-, binary- oder unbekannte Version sein — Felder je nach `type`.
|
||||
export interface ComponentVersion {
|
||||
type: "git" | "binary" | "unknown"
|
||||
hash?: string
|
||||
date?: string
|
||||
subject?: string
|
||||
branch?: string
|
||||
dirty?: boolean
|
||||
path?: string
|
||||
version_text?: string
|
||||
}
|
||||
|
||||
export interface Versions {
|
||||
mc2: GitInfo | null
|
||||
engine: ComponentVersion
|
||||
hermes_agent: GitInfo | null
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
cpu: { percent: number; cores: number | null }
|
||||
ram: { total: number; used: number; percent: number }
|
||||
gpu: {
|
||||
busy_percent: number | null
|
||||
vram_used: number | null
|
||||
vram_total: number | null
|
||||
gtt_used?: number | null
|
||||
gtt_total?: number | null
|
||||
} | null
|
||||
temp: { cpu?: number; gpu?: number } | null
|
||||
disk: { total: number; used: number; percent: number } | null
|
||||
/** Sekunden seit dem Systemstart (v3-Umbau P3). null, wenn die Box es nicht liefert —
|
||||
* die Statusleiste blendet das Feld dann aus, statt eine Zahl zu erfinden. */
|
||||
uptime_s?: number | null
|
||||
versions?: Versions
|
||||
}
|
||||
|
||||
export interface ServiceInfo {
|
||||
name: string
|
||||
unit: string // echter systemd-Name — Basis für Restart/Logs/Matching (kein Namens-Raten)
|
||||
url: string
|
||||
ok: boolean
|
||||
scope: "user" | "system"
|
||||
}
|
||||
export interface ServicesResp {
|
||||
services: ServiceInfo[]
|
||||
links: { engine_ui: string; gateway: string; hermes_ui: string }
|
||||
}
|
||||
|
||||
export interface ComponentUpdate {
|
||||
key: string // z.B. "hermes_agent"
|
||||
name: string
|
||||
current: string | null
|
||||
latest: string | null
|
||||
update: boolean | null // null = unbestimmbar (installierte Version remote nicht abfragbar)
|
||||
reachable: boolean | null
|
||||
}
|
||||
|
||||
export interface UpdatesResp {
|
||||
os: number
|
||||
engine: number
|
||||
swap: number
|
||||
models: number
|
||||
model_list: { role: string; title: string; repo: string }[]
|
||||
last_check?: number | null
|
||||
components?: ComponentUpdate[]
|
||||
}
|
||||
|
||||
export interface UpdateDetails {
|
||||
kind: "os" | "engine" | "swap" | "hermes"
|
||||
error?: string
|
||||
// LLM-Zusammenfassung in Lucys Stimme (hermes/engine/swap; Breaking Changes zuerst)
|
||||
summary?: string
|
||||
// Aktions-Verdikt: muss der Besitzer selbst etwas tun? (null = kein Verdikt/keine Summary)
|
||||
action_needed?: boolean | null
|
||||
action_text?: string
|
||||
// os
|
||||
count?: number
|
||||
packages?: { name: string; current: string; candidate: string }[]
|
||||
// os: vom System zurückgestellte Pakete (Phasen-Rollout / kept back) — ehrlich statt "hängt"
|
||||
held_back?: { name: string; reason: "phasing" | "kept_back" }[]
|
||||
// engine
|
||||
installed_build?: number | null
|
||||
latest_build?: number | null
|
||||
latest_tag?: string | null
|
||||
name?: string | null
|
||||
url?: string | null
|
||||
body?: string | null
|
||||
// hermes
|
||||
branch?: string | null
|
||||
behind?: number
|
||||
commits?: { hash: string; subject: string; when: string }[]
|
||||
}
|
||||
|
||||
export interface ConnectTool {
|
||||
label: string
|
||||
lang: string
|
||||
snippet: string
|
||||
note: string
|
||||
}
|
||||
export interface ConnectResp {
|
||||
host: string
|
||||
gateway_url: string
|
||||
mc_url: string
|
||||
tools: Record<string, ConnectTool> // Leitung 1 — Modell (IDEs/Agenten → Gateway)
|
||||
}
|
||||
|
||||
export interface ConnectLine {
|
||||
ok: boolean
|
||||
detail: string
|
||||
}
|
||||
export interface ConnectHealth {
|
||||
gateway: ConnectLine
|
||||
memory: ConnectLine // Leitung 2 — Hermes-natives Gedächtnis (nur Status, nichts zu konfigurieren)
|
||||
desktop_gateway: ConnectLine
|
||||
}
|
||||
|
||||
// Graph-Ansicht des Wissens-Vaults (GET /api/wissen/graph) — trägt aus historischen Gründen
|
||||
// noch „Memory" im Namen; die Daten kommen aus dem Vault, nicht aus einem Gedächtnis-Dienst.
|
||||
export interface MemoryGraphNode {
|
||||
id: string
|
||||
content: string
|
||||
category: string
|
||||
source: string
|
||||
}
|
||||
export interface MemoryGraph {
|
||||
nodes: MemoryGraphNode[]
|
||||
edges: { source: string; target: string; weight: number }[]
|
||||
}
|
||||
|
||||
export interface AgentStatus {
|
||||
gateway_url: string
|
||||
box_console_url?: string
|
||||
hermes_ui_url?: string
|
||||
gateway_reachable: boolean
|
||||
box_console_reachable?: boolean
|
||||
hermes_ui_reachable?: boolean
|
||||
home_exists: boolean
|
||||
brain_model?: string
|
||||
has_config: boolean
|
||||
has_skills: boolean
|
||||
has_memories: boolean
|
||||
telegram_enabled?: boolean
|
||||
mcp_server_count?: number
|
||||
pc_executor_reachable?: boolean
|
||||
pc_executor_url?: string
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
id: string
|
||||
label: string
|
||||
group?: string | null // "maintenance" = system-veränderndes Update (Wartungs-Riegel)
|
||||
state: "queued" | "running" | "done" | "failed" | "canceled"
|
||||
progress?: number
|
||||
total_bytes?: number
|
||||
done_bytes?: number
|
||||
rate_bps?: number
|
||||
eta_s?: number
|
||||
}
|
||||
|
||||
// Werkzeug-Verlauf des Agenten (GET /api/agent/aktivitaet, v3-Umbau P6).
|
||||
// Gelesen aus Hermes' Log — Denkstrom und Werkzeug-Argumente stehen dort NICHT und
|
||||
// tauchen darum auch hier nicht auf.
|
||||
export interface WerkzeugRuf {
|
||||
zeit: string
|
||||
lauf: string | null
|
||||
werkzeug: string
|
||||
dauer_s: number
|
||||
zeichen: number | null
|
||||
ok: boolean
|
||||
fehler: string | null
|
||||
}
|
||||
export interface WerkzeugBilanz {
|
||||
werkzeug: string
|
||||
rufe: number
|
||||
fehler: number
|
||||
dauer_schnitt_s: number
|
||||
letzter_fehler: string | null
|
||||
}
|
||||
export interface AgentLauf {
|
||||
lauf: string
|
||||
von: string
|
||||
bis: string
|
||||
rufe: number
|
||||
fehler: number
|
||||
}
|
||||
export interface AktivitaetResp {
|
||||
verfuegbar: boolean
|
||||
pfad: string
|
||||
rufe: WerkzeugRuf[]
|
||||
werkzeuge: WerkzeugBilanz[]
|
||||
laeufe: AgentLauf[]
|
||||
}
|
||||
|
||||
export interface Health {
|
||||
status: string
|
||||
version: string
|
||||
engine_reachable: boolean
|
||||
gateway_reachable: boolean
|
||||
brain?: { role: string; model: string | null; ready: boolean }
|
||||
}
|
||||
|
||||
// 24-h-Metrik-Verlauf (GET /api/system/history?minutes=N) — Basis der Cockpit-Zeitachse.
|
||||
// tp/tc sind Token-GESAMTZÄHLER; Raten rechnet das Frontend aus den Deltas.
|
||||
export interface HistoryPoint {
|
||||
t: number // Epoch-Sekunden
|
||||
cpu: number | null
|
||||
ram: number | null
|
||||
gpu: number | null
|
||||
disk: number | null
|
||||
tp: number | null
|
||||
tc: number | null
|
||||
}
|
||||
export interface HistoryResp {
|
||||
sample_s: number
|
||||
points: HistoryPoint[]
|
||||
}
|
||||
|
||||
export interface TokenStats {
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
total_tokens: number
|
||||
saved_usd: number
|
||||
saved_eur: number
|
||||
pricing?: Record<string, { in: number; out: number }>
|
||||
}
|
||||
|
||||
// Per-Turn-Latenz-Trace (GET /api/voice/trace). Balken-Stufen (zeitlich disjunkt):
|
||||
// stt · vision · hirn · gen. Der Gedächtnis-Abruf läuft seit der Mem0-Ablösung Hermes-intern
|
||||
// (kein Rückruf an MC2) und steckt daher unsichtbar in hirn_ms.
|
||||
export interface VoiceTurn {
|
||||
id: string
|
||||
ts: number // Epoch-Sekunden
|
||||
session: string
|
||||
kind: string
|
||||
had_images: boolean
|
||||
stt_ms: number | null
|
||||
vision_ms: number | null
|
||||
hirn_ms: number | null // Zeit bis erstes Inhalts-Token (Agent + Gedächtnis + LLM-TTFT)
|
||||
gen_ms: number | null // Generierung nach dem ersten Token bis Stream-Ende
|
||||
total_ms: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface VoiceTraceResp {
|
||||
turns: VoiceTurn[]
|
||||
}
|
||||
|
||||
export interface ModelsResp {
|
||||
models: ModelInfo[]
|
||||
running?: string[]
|
||||
}
|
||||
|
||||
// ── Ideen-Queue (natives Hermes-Kanban) ──────────────────────────────────────
|
||||
export interface IdeenItem {
|
||||
id: string
|
||||
titel: string
|
||||
body: string
|
||||
status: string // triage | todo | ready | running | blocked | review | done
|
||||
assignee: string | null
|
||||
welt?: string // "box" = Werkstatt baut · "ide" = für Zed vorbereitet
|
||||
art?: string // "box" | "idee" (nur durchdacht) | "projekt" (Repo vorbereitet)
|
||||
erstellt: number | null
|
||||
fertig: number | null
|
||||
von: string | null
|
||||
frage?: string | null // bei status=blocked: die Frage/der Grund des Workers
|
||||
frage_kind?: string | null // needs_input | capability | transient | ""
|
||||
eltern?: string[] // offene Vorgänger-Karten (task_links) — leer = startbereit
|
||||
wartet_auf?: string[] // Titel der offenen Vorgänger (max. 3, für die Anzeige)
|
||||
notiz?: string | null // bei running: jüngste Heartbeat-Notiz des Workers
|
||||
projekt?: string | null // Familien-Schlüssel (verkettete Karten) — null = Einzelkarte
|
||||
prio?: number // Dispatcher-Reihenfolge: priority DESC, created ASC
|
||||
puls_alter?: number | null // bei running: Sekunden seit letztem Lebenszeichen (Hänger-Warnung)
|
||||
gestartet?: number | null // bei running: started_at (Epoch)
|
||||
}
|
||||
// Karten-Familie (verkettete Karten = ein Projekt) für die gruppierte Queue-Sicht
|
||||
export interface IdeenProjekt {
|
||||
key: string
|
||||
titel: string
|
||||
gesamt: number
|
||||
fertig: number
|
||||
laeuft: number
|
||||
haengt: number
|
||||
}
|
||||
export interface IdeenLogResp {
|
||||
available: boolean
|
||||
lines: string[]
|
||||
}
|
||||
export interface IdeenResp {
|
||||
available: boolean
|
||||
items: IdeenItem[]
|
||||
offen: number
|
||||
fehler?: string
|
||||
projekte?: IdeenProjekt[]
|
||||
}
|
||||
|
||||
// Ergebnis einer fertigen Queue-Aufgabe (Abschluss-Zusammenfassung des Workers)
|
||||
export interface IdeenErgebnis {
|
||||
available: boolean
|
||||
summary: string
|
||||
result: string
|
||||
}
|
||||
|
||||
// Konzept einer IDE-Karte — aus dem Gitea-Repo oder aus ~/konzepte auf der Box
|
||||
export interface IdeenKonzept {
|
||||
available: boolean
|
||||
repo?: string | null
|
||||
clone_url?: string
|
||||
quelle?: "repo" | "datei"
|
||||
pfad?: string // nur quelle=datei: Pfad auf der Box
|
||||
datei?: string
|
||||
konzept?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// ── Chronik (Timeline aus dem Melde-Briefkasten) ─────────────────────────────
|
||||
export interface ChronikItem {
|
||||
id: number
|
||||
ts: number
|
||||
subject: string
|
||||
text: string
|
||||
source: string
|
||||
priority: "normal" | "silent"
|
||||
}
|
||||
export interface ChronikResp {
|
||||
items: ChronikItem[]
|
||||
}
|
||||
|
||||
// ── Wissens-Vault (Traum-Notizen, read-only) ─────────────────────────────────
|
||||
export interface WissenFile {
|
||||
path: string
|
||||
name: string
|
||||
dir: string
|
||||
title: string
|
||||
mtime: number
|
||||
neu: boolean
|
||||
}
|
||||
export interface WissenResp {
|
||||
available: boolean
|
||||
files: WissenFile[]
|
||||
}
|
||||
export interface WissenDatei {
|
||||
path: string
|
||||
content: string
|
||||
mtime: number
|
||||
}
|
||||
|
||||
// ── Zeitmaschine (Snapshots + Restore) ───────────────────────────────────────
|
||||
export interface Snapshot {
|
||||
snapshot: string
|
||||
file: string
|
||||
size_mb: number
|
||||
}
|
||||
export interface ZeitmaschineResp {
|
||||
available: boolean
|
||||
backups: Snapshot[]
|
||||
}
|
||||
|
||||
// ── Erinnerungen (Wecker/Routinen) ───────────────────────────────────────────
|
||||
export interface Reminder {
|
||||
id: number
|
||||
text: string
|
||||
next_ts: number
|
||||
repeat: string // '' einmalig | daily | weekdays | weekly
|
||||
created_ts: number
|
||||
}
|
||||
|
||||
export interface HermesBrainModel {
|
||||
name: string
|
||||
filename?: string
|
||||
params_b: number | null
|
||||
quant?: string
|
||||
size_bytes?: number | null
|
||||
version?: number | null
|
||||
gguf_path?: string
|
||||
incomplete?: boolean
|
||||
}
|
||||
|
||||
export interface HermesBrainCandidate {
|
||||
repo: string
|
||||
name: string
|
||||
version: number
|
||||
params_b: number
|
||||
downloads: number
|
||||
fit: Fit
|
||||
}
|
||||
|
||||
export interface HermesBrainBudget {
|
||||
gtt_gb: number
|
||||
brain_gb: number // Footprint des (empfohlenen) Brains, das immer resident bleibt
|
||||
warm_projected_gb: number // alle persist-Modelle warm (Info)
|
||||
free_after_gb: number // frei nach Brain + größtem on-demand
|
||||
largest_ondemand_gb: number // größtes on-demand-Modell (z.B. heavy/coder)
|
||||
fits: boolean // passt Brain + größtes on-demand zusammen ins Budget?
|
||||
}
|
||||
|
||||
export interface HermesBrainResp {
|
||||
current: HermesBrainModel | null
|
||||
recommended: HermesBrainCandidate | null
|
||||
update_available: boolean
|
||||
budget?: HermesBrainBudget | null
|
||||
}
|
||||
|
||||
// ── Ideen-Log (Live Worker Log) ──────────────────────────────────────────────
|
||||
export const getIdeenLog = (id: string) => api<IdeenLogResp>(`/api/ideen/${id}/log`)
|
||||
export const post = <T>(pfad: string, daten?: unknown) =>
|
||||
api<T>(pfad, { method: "POST", body: daten === undefined ? undefined : JSON.stringify(daten) })
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
// Ereignisstrom — Gegenstück zu backend/routers/events.py.
|
||||
//
|
||||
// EIN EventSource auf /api/stream. Zwei Ereignisarten:
|
||||
// · `invalidate` stößt gezielt die betroffenen React-Query-Caches an
|
||||
// · `metrik` schreibt jede Sekunde direkt in den Metrik-Speicher (v3-Umbau P4)
|
||||
//
|
||||
// Solange der Strom steht, sind die Poller nur noch Sicherheitsnetz (queries.ts entspannt
|
||||
// sie ×5 über sseVerbunden()). Reißt er, reconnectet EventSource von selbst — bis dahin
|
||||
// pollt die UI wie früher, und die Statusleiste sagt es ehrlich.
|
||||
//
|
||||
// v3-Umbau P4: Die Messpunkte kommen jetzt GEPUSHT statt gepollt. Vorher fragten
|
||||
// `system-status` und `token-stats` im 3-Sekunden-Takt nach — zwei Dauer-Anfragen,
|
||||
// unabhängig davon, ob sich etwas geändert hatte. Beide Poller sind ersatzlos weg,
|
||||
// solange der Strom steht.
|
||||
//
|
||||
// WICHTIG (der Grund, warum der Metrik-Speicher kein Store ist): `metrik` feuert jede
|
||||
// Sekunde. Der Speicher liegt bewusst AUSSERHALB von React (lib/metricsStore.ts,
|
||||
// useSyncExternalStore) — so rendern nur die Karten neu, die den Verlauf wirklich
|
||||
// abonnieren, nicht die ganze Shell.
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { melden, stromEreignis, stromLageSetzen } from "@/app/store"
|
||||
import { pushMetrik } from "@/lib/metricsStore"
|
||||
|
||||
let verbunden = false
|
||||
export const sseVerbunden = () => verbunden
|
||||
|
||||
/** Rohform eines `metrik`-Ereignisses (siehe services/system.py → metrik_punkt). */
|
||||
export interface MetrikPunkt {
|
||||
cpu: number
|
||||
ram: number
|
||||
ram_used: number
|
||||
ram_total: number
|
||||
gpu: number | null
|
||||
disk: number | null
|
||||
temp_cpu: number | null
|
||||
temp_gpu: number | null
|
||||
uptime_s: number | null
|
||||
/** GESAMTZÄHLER, keine Rate — die rechnet der Speicher aus zwei Punkten selbst. */
|
||||
tok_p: number
|
||||
tok_c: number
|
||||
}
|
||||
|
||||
export function useEventStream() {
|
||||
const qc = useQueryClient()
|
||||
useEffect(() => {
|
||||
const es = new EventSource("/api/stream")
|
||||
// Der erste Verbindungsaufbau ist der Normalfall und wird NICHT gemeldet — sonst
|
||||
// begrüßt jede Seite den Nutzer mit „Verbindung wieder da". Gemeldet wird nur der
|
||||
// Übergang: einmal beim Reißen, einmal beim Heilen.
|
||||
let warSchonMalVerbunden = false
|
||||
|
||||
es.onopen = () => {
|
||||
verbunden = true
|
||||
stromLageSetzen("live")
|
||||
if (warSchonMalVerbunden) melden("erfolg", "Verbindung zur Zentrale ist wieder da.")
|
||||
warSchonMalVerbunden = true
|
||||
// Beim (Wieder-)Verbinden einmal alles nachziehen: Was während der Trennung
|
||||
// passiert ist, hat der Strom nicht gemeldet.
|
||||
qc.invalidateQueries()
|
||||
}
|
||||
|
||||
es.onerror = () => {
|
||||
if (!verbunden) return
|
||||
verbunden = false
|
||||
// „nachlauf" statt „getrennt": EventSource reconnectet selbst, und bis dahin
|
||||
// pollen die Queries weiter — die Zahlen sind also älter, aber nicht tot.
|
||||
stromLageSetzen("nachlauf")
|
||||
melden("warnung", "Verbindung zur Zentrale verloren — die Anzeigen laufen im Nachlauf.")
|
||||
// Sofort auf den normalen Takt zurück, statt bis zum nächsten Refetch zu warten.
|
||||
qc.invalidateQueries()
|
||||
}
|
||||
|
||||
es.addEventListener("invalidate", (e) => {
|
||||
try {
|
||||
const keys: string[] = JSON.parse((e as MessageEvent).data)?.keys ?? []
|
||||
for (const k of keys) qc.invalidateQueries({ queryKey: [k] })
|
||||
stromEreignis()
|
||||
} catch { /* kaputtes Event bricht den Strom nicht */ }
|
||||
})
|
||||
|
||||
es.addEventListener("metrik", (e) => {
|
||||
try {
|
||||
pushMetrik(JSON.parse((e as MessageEvent).data) as MetrikPunkt)
|
||||
} catch { /* ein kaputter Messpunkt darf den Strom nicht reißen */ }
|
||||
})
|
||||
|
||||
return () => {
|
||||
verbunden = false
|
||||
stromLageSetzen("getrennt")
|
||||
es.close()
|
||||
}
|
||||
}, [qc])
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// Zentrale Formatierungs-Helfer (vorher in einzelnen Views dupliziert).
|
||||
|
||||
/** Bytes → GB als String mit einer Nachkommastelle (z.B. "14.1"). */
|
||||
export function gb(b: number): string {
|
||||
return (b / 1024 ** 3).toFixed(1)
|
||||
}
|
||||
|
||||
/** Bytes → "1.2 GB" / "512 MB"; leer bei 0/undefined. */
|
||||
export function fmtBytes(b?: number): string {
|
||||
if (!b) return ""
|
||||
if (b > 1024 ** 3) return `${(b / 1024 ** 3).toFixed(1)} GB`
|
||||
return `${(b / 1024 ** 2).toFixed(0)} MB`
|
||||
}
|
||||
|
||||
/** Bytes → "1.2 GB" / "512 MB"; "—" bei 0/undefined/null. */
|
||||
export function fmtSize(b?: number | null): string {
|
||||
if (!b) return "—"
|
||||
const g = b / 1024 ** 3
|
||||
return g >= 1 ? `${g.toFixed(1)} GB` : `${(b / 1024 ** 2).toFixed(0)} MB`
|
||||
}
|
||||
|
||||
/** Sekunden → "3 min" / "45 s"; leer bei 0/undefined. */
|
||||
export function fmtEta(s?: number): string {
|
||||
if (!s) return ""
|
||||
const m = Math.floor(s / 60)
|
||||
return m > 0 ? `${m} min` : `${s} s`
|
||||
}
|
||||
|
||||
/** Kontextlänge → "32k"; "—" bei null. */
|
||||
export function fmtCtx(c: number | null): string {
|
||||
return c ? `${Math.round(c / 1024)}k` : "—"
|
||||
}
|
||||
|
||||
/** Unix-Sekunden → "25.07., 21:04"; "—" bei 0/null/undefined. */
|
||||
export function fmtWhen(ts: number | null | undefined): string {
|
||||
if (!ts) return "—"
|
||||
return new Date(ts * 1000).toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
import { hashUmleiten } from "./hashUmleitung"
|
||||
import { NAV, navFuerPfad } from "@/nav"
|
||||
|
||||
function adresseSetzen(url: string) {
|
||||
window.history.replaceState(null, "", url)
|
||||
}
|
||||
|
||||
// Diese Brücke ist die einzige Verbindung zu Lesezeichen aus der Hash-Zeit. Sie sieht
|
||||
// nach totem Code aus und wäre damit ein naheliegender Kandidat zum Wegräumen — die
|
||||
// Tests machen sichtbar, was dabei kaputtginge.
|
||||
describe("hashUmleiten()", () => {
|
||||
beforeEach(() => adresseSetzen("/"))
|
||||
|
||||
it("biegt jedes alte Hash-Lesezeichen auf seinen neuen Pfad", () => {
|
||||
for (const eintrag of NAV) {
|
||||
adresseSetzen("/#" + eintrag.id)
|
||||
hashUmleiten()
|
||||
expect(window.location.pathname, `#${eintrag.id}`).toBe(eintrag.pfad)
|
||||
}
|
||||
})
|
||||
|
||||
it("verträgt die Schreibweise mit Schrägstrich (#/models)", () => {
|
||||
adresseSetzen("/#/models")
|
||||
hashUmleiten()
|
||||
expect(window.location.pathname).toBe("/modelle")
|
||||
})
|
||||
|
||||
it("behält vorhandene Suchparameter", () => {
|
||||
adresseSetzen("/?system=logs#models")
|
||||
hashUmleiten()
|
||||
expect(window.location.pathname).toBe("/modelle")
|
||||
expect(window.location.search).toBe("?system=logs")
|
||||
})
|
||||
|
||||
it("fasst einen unbekannten Hash nicht an", () => {
|
||||
adresseSetzen("/cockpit#irgendwas")
|
||||
hashUmleiten()
|
||||
expect(window.location.pathname).toBe("/cockpit")
|
||||
})
|
||||
|
||||
it("tut ohne Hash gar nichts", () => {
|
||||
adresseSetzen("/modelle?reiter=routing")
|
||||
hashUmleiten()
|
||||
expect(window.location.pathname + window.location.search).toBe("/modelle?reiter=routing")
|
||||
})
|
||||
})
|
||||
|
||||
describe("navFuerPfad()", () => {
|
||||
it("findet den Eintrag zum exakten Pfad", () => {
|
||||
expect(navFuerPfad("/modelle")?.id).toBe("models")
|
||||
expect(navFuerPfad("/cockpit")?.id).toBe("dashboard")
|
||||
})
|
||||
|
||||
// Damit /ideen/<id> die Sidebar weiterhin bei „Ideen" markiert und nicht ins Leere fällt.
|
||||
it("ordnet Unterpfade dem übergeordneten Eintrag zu", () => {
|
||||
expect(navFuerPfad("/ideen/id_9f3c")?.id).toBe("ideen")
|
||||
})
|
||||
|
||||
it("liefert nichts für einen unbekannten Pfad", () => {
|
||||
expect(navFuerPfad("/gibtsnicht")).toBeUndefined()
|
||||
})
|
||||
|
||||
// Der Grund für die Längen-Sortierung: ein kurzer Pfad darf einen längeren nicht
|
||||
// überstimmen, sonst gewinnt der erstbeste Präfix-Treffer.
|
||||
it("nimmt bei mehreren Treffern den längsten Pfad", () => {
|
||||
const treffer = navFuerPfad("/modelle")
|
||||
expect(treffer?.pfad).toBe("/modelle")
|
||||
})
|
||||
})
|
||||
@@ -1,26 +0,0 @@
|
||||
// Alte Hash-Lesezeichen auf die neuen Pfade umbiegen (v3-Umbau P2).
|
||||
//
|
||||
// Bis zum 28.08.2026 lebte die Ansicht im URL-Hash (`#models`). Das war teilbar und
|
||||
// reload-fest — aber nur auf View-Ebene; für den Zustand DARUNTER (offene Idee, Reiter,
|
||||
// Drawer) war kein Platz. Mit dem Router zieht das in echte Pfade um.
|
||||
//
|
||||
// Wer ein altes Lesezeichen hat, soll nichts merken: Diese Funktion läuft EINMAL vor dem
|
||||
// Router-Mount und ersetzt `#models` durch `/modelle` — per `replaceState`, damit die
|
||||
// Zurück-Taste nicht in einer Umleitungs-Schleife landet.
|
||||
//
|
||||
// Die Tabelle darf mit der Zeit schrumpfen, aber nicht kommentarlos: Solange jemand ein
|
||||
// Lesezeichen von früher haben könnte, ist sie die einzige Brücke dorthin.
|
||||
|
||||
import { NAV, type ViewId } from "@/nav"
|
||||
|
||||
const ALT_ZU_PFAD: Record<ViewId, string> = Object.fromEntries(
|
||||
NAV.map((n) => [n.id, n.pfad]),
|
||||
) as Record<ViewId, string>
|
||||
|
||||
export function hashUmleiten(): void {
|
||||
const hash = window.location.hash.replace(/^#\/?/, "")
|
||||
if (!hash) return
|
||||
const ziel = ALT_ZU_PFAD[hash as ViewId]
|
||||
if (!ziel) return
|
||||
window.history.replaceState(null, "", ziel + window.location.search)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Leichtgewichtiger Markdown-Renderer (Zeilen-basiert, XSS-frei da reines JSX,
|
||||
// bewusst ohne externe Lib). Überschriften, Listen, fett/kursiv, `code`, Codeblöcke.
|
||||
// Verwandt mit dem wiki-link-Renderer in WissenView, hier aber ohne [[links]] —
|
||||
// gedacht für Doku-Anzeige (z. B. das KONZEPT.md eines vorbereiteten IDE-Projekts).
|
||||
export function Markdown({ text, className }: { text: string; className?: string }) {
|
||||
const lines = (text || "").split("\n")
|
||||
const out: React.ReactNode[] = []
|
||||
let list: React.ReactNode[] = []
|
||||
let code: string[] | null = null
|
||||
|
||||
const flushList = (key: string) => {
|
||||
if (list.length) {
|
||||
out.push(<ul key={key} className="mb-3 ml-4 list-disc space-y-1">{list}</ul>)
|
||||
list = []
|
||||
}
|
||||
}
|
||||
|
||||
lines.forEach((raw, i) => {
|
||||
const key = `l${i}`
|
||||
if (code !== null) {
|
||||
if (raw.trimEnd() === "```") {
|
||||
out.push(<pre key={key} className="mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px] leading-relaxed text-foreground/85">{code.join("\n")}</pre>)
|
||||
code = null
|
||||
} else code.push(raw)
|
||||
return
|
||||
}
|
||||
if (raw.trimStart().startsWith("```")) { flushList(key); code = []; return }
|
||||
|
||||
const line = raw.trimEnd()
|
||||
if (!line.trim()) { flushList(key); return }
|
||||
|
||||
const h = line.match(/^(#{1,4})\s+(.*)$/)
|
||||
if (h) {
|
||||
flushList(key)
|
||||
const level = h[1].length
|
||||
const cls = level === 1 ? "text-base font-bold mt-1 mb-2" : level === 2 ? "text-sm font-bold mt-4 mb-1.5" : "text-xs font-bold mt-3 mb-1 uppercase tracking-wide text-muted-foreground"
|
||||
out.push(<p key={key} className={cn(cls, "font-space text-foreground")}>{inline(h[2], key)}</p>)
|
||||
return
|
||||
}
|
||||
const li = line.match(/^\s*[-*•]\s+(.*)$/)
|
||||
if (li) {
|
||||
list.push(<li key={key} className="text-xs leading-relaxed text-foreground/85">{inline(li[1], key)}</li>)
|
||||
return
|
||||
}
|
||||
const ol = line.match(/^\s*\d+\.\s+(.*)$/)
|
||||
if (ol) {
|
||||
list.push(<li key={key} className="text-xs leading-relaxed text-foreground/85">{inline(ol[1], key)}</li>)
|
||||
return
|
||||
}
|
||||
flushList(key)
|
||||
out.push(<p key={key} className="mb-2 text-xs leading-relaxed text-foreground/85">{inline(line, key)}</p>)
|
||||
})
|
||||
flushList("end")
|
||||
if (code !== null) out.push(<pre key="code-end" className="mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px]">{(code as string[]).join("\n")}</pre>)
|
||||
|
||||
return <div className={cn("max-w-3xl", className)}>{out}</div>
|
||||
}
|
||||
|
||||
// Inline: **fett**, *kursiv*, `code` — per Regex-Split, reines JSX (kein HTML-Inject).
|
||||
function inline(text: string, keyBase: string): React.ReactNode[] {
|
||||
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g)
|
||||
return parts.map((p, i) => {
|
||||
const key = `${keyBase}-${i}`
|
||||
if (/^\*\*[^*]+\*\*$/.test(p)) return <b key={key} className="font-semibold text-foreground">{p.slice(2, -2)}</b>
|
||||
if (/^\*[^*]+\*$/.test(p)) return <i key={key}>{p.slice(1, -1)}</i>
|
||||
if (/^`[^`]+`$/.test(p)) return <code key={key} className="rounded bg-background/50 px-1 font-mono text-[11px] text-foreground/90">{p.slice(1, -1)}</code>
|
||||
return <span key={key}>{p}</span>
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Kurze Rückmeldungen nach einer Aktion („Dienst neu gestartet“). Außerhalb von React
|
||||
// gehalten, damit jede Stelle melden kann, ohne einen Kontext durchzureichen.
|
||||
|
||||
import { useSyncExternalStore } from "react"
|
||||
|
||||
export type MeldungsArt = "erfolg" | "fehler" | "info"
|
||||
export interface Meldung {
|
||||
id: number
|
||||
art: MeldungsArt
|
||||
text: string
|
||||
}
|
||||
|
||||
let meldungen: Meldung[] = []
|
||||
let naechste = 1
|
||||
const hoerer = new Set<() => void>()
|
||||
const melde = () => hoerer.forEach((h) => h())
|
||||
|
||||
export function melden(art: MeldungsArt, text: string) {
|
||||
const id = naechste++
|
||||
meldungen = [...meldungen, { id, art, text }].slice(-4)
|
||||
melde()
|
||||
// Fehler bleiben, bis man sie wegklickt — die will man lesen können.
|
||||
if (art !== "fehler") window.setTimeout(() => entfernen(id), 6000)
|
||||
}
|
||||
|
||||
export function entfernen(id: number) {
|
||||
meldungen = meldungen.filter((m) => m.id !== id)
|
||||
melde()
|
||||
}
|
||||
|
||||
export const useMeldungen = () =>
|
||||
useSyncExternalStore(
|
||||
(h) => {
|
||||
hoerer.add(h)
|
||||
return () => {
|
||||
hoerer.delete(h)
|
||||
}
|
||||
},
|
||||
() => meldungen,
|
||||
)
|
||||
@@ -1,120 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { act, renderHook } from "@testing-library/react"
|
||||
import type { MetrikPunkt } from "./events"
|
||||
|
||||
// Der Verlaufsspeicher ist modul-global (er soll Tab-Wechsel überleben). Für jeden Test
|
||||
// eine frische Modul-Instanz, sonst tragen sich die Fälle gegenseitig Daten ein.
|
||||
async function frischerStore() {
|
||||
vi.resetModules()
|
||||
return await import("./metricsStore")
|
||||
}
|
||||
|
||||
function punkt(p: Partial<MetrikPunkt> = {}): MetrikPunkt {
|
||||
return {
|
||||
cpu: 10, ram: 20, ram_used: 1, ram_total: 2, gpu: null, disk: null,
|
||||
temp_cpu: null, temp_gpu: null, uptime_s: 100, tok_p: 0, tok_c: 0, ...p,
|
||||
}
|
||||
}
|
||||
|
||||
describe("metricsStore", () => {
|
||||
let store: Awaited<ReturnType<typeof frischerStore>>
|
||||
|
||||
beforeEach(async () => {
|
||||
store = await frischerStore()
|
||||
})
|
||||
|
||||
it("beginnt leer", () => {
|
||||
const { result } = renderHook(() => store.useSysHistory())
|
||||
expect(result.current).toEqual([])
|
||||
})
|
||||
|
||||
it("meldet neue Messpunkte an die Abonnenten", () => {
|
||||
const { result } = renderHook(() => store.useSysHistory())
|
||||
act(() => store.pushSys({ t: 1, cpu: 10, ram: 20, gpu: 30, disk: 40 }))
|
||||
expect(result.current).toHaveLength(1)
|
||||
expect(result.current[0].cpu).toBe(10)
|
||||
})
|
||||
|
||||
// Ohne Deckel wächst der Verlauf unbegrenzt — bei 1-s-Takt sind das ~86 400 Punkte
|
||||
// pro Tag in einem Dauerläufer-Tab.
|
||||
it("deckelt den Verlauf und behält die JÜNGSTEN Punkte", () => {
|
||||
const { result } = renderHook(() => store.useSysHistory())
|
||||
const n = store.MAX_POINTS + 20
|
||||
act(() => {
|
||||
for (let i = 0; i < n; i++) store.pushSys({ t: i, cpu: i, ram: 0, gpu: null, disk: null })
|
||||
})
|
||||
expect(result.current).toHaveLength(store.MAX_POINTS)
|
||||
expect(result.current[0].t).toBe(n - store.MAX_POINTS)
|
||||
expect(result.current[store.MAX_POINTS - 1].t).toBe(n - 1)
|
||||
})
|
||||
|
||||
it("hält System- und Token-Verlauf auseinander", () => {
|
||||
const { result: sys } = renderHook(() => store.useSysHistory())
|
||||
const { result: tok } = renderHook(() => store.useTokHistory())
|
||||
act(() => store.pushTok({ t: 1, prompt: 5, completion: 7 }))
|
||||
expect(sys.current).toHaveLength(0)
|
||||
expect(tok.current).toHaveLength(1)
|
||||
expect(tok.current[0].completion).toBe(7)
|
||||
})
|
||||
|
||||
// useSyncExternalStore vergleicht per Referenz: würde pushSys das Array an Ort und
|
||||
// Stelle verändern, bliebe die Anzeige stehen, obwohl Daten da sind.
|
||||
it("liefert bei jeder Änderung ein NEUES Array (Referenzwechsel)", () => {
|
||||
const { result } = renderHook(() => store.useSysHistory())
|
||||
const vorher = result.current
|
||||
act(() => store.pushSys({ t: 1, cpu: 1, ram: 1, gpu: null, disk: null }))
|
||||
expect(result.current).not.toBe(vorher)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Der Einstieg des Ereignisstroms (v3-Umbau P4) ───────────────────────────
|
||||
// Der Server schickt Token-GESAMTZÄHLER, keine Raten — die Umrechnung passiert hier.
|
||||
// Genau dort steckt die Fehleranfälligkeit: ein Backend-Neustart setzt die Zähler
|
||||
// zurück, und aus einem Rückwärtssprung darf keine negative Rate werden.
|
||||
describe("pushMetrik (Strom-Einstieg)", () => {
|
||||
let store: Awaited<ReturnType<typeof frischerStore>>
|
||||
|
||||
beforeEach(async () => {
|
||||
store = await frischerStore()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
it("schreibt beim ersten Punkt NOCH KEINE Rate — dafür fehlt der Vorgänger", () => {
|
||||
const { result: sys } = renderHook(() => store.useSysHistory())
|
||||
const { result: tok } = renderHook(() => store.useTokHistory())
|
||||
act(() => store.pushMetrik(punkt({ tok_p: 100, tok_c: 50 })))
|
||||
expect(sys.current).toHaveLength(1)
|
||||
expect(tok.current).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("rechnet aus zwei Zählerständen eine Rate pro Sekunde", () => {
|
||||
const { result } = renderHook(() => store.useTokHistory())
|
||||
act(() => store.pushMetrik(punkt({ tok_p: 100, tok_c: 50 })))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2_000)
|
||||
store.pushMetrik(punkt({ tok_p: 300, tok_c: 90 }))
|
||||
})
|
||||
expect(result.current).toHaveLength(1)
|
||||
expect(result.current[0].prompt).toBeCloseTo(100, 1) // 200 Token in 2 s
|
||||
expect(result.current[0].completion).toBeCloseTo(20, 1) // 40 Token in 2 s
|
||||
})
|
||||
|
||||
it("macht aus einem Zähler-Rücksprung keine negative Rate", () => {
|
||||
const { result } = renderHook(() => store.useTokHistory())
|
||||
act(() => store.pushMetrik(punkt({ tok_p: 5_000, tok_c: 5_000 })))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_000)
|
||||
store.pushMetrik(punkt({ tok_p: 0, tok_c: 0 })) // Backend neu gestartet
|
||||
})
|
||||
expect(result.current[0].prompt).toBe(0)
|
||||
expect(result.current[0].completion).toBe(0)
|
||||
})
|
||||
|
||||
it("hält den letzten Messpunkt für die Statusleiste bereit", () => {
|
||||
const { result } = renderHook(() => store.useLetzteMetrik())
|
||||
expect(result.current).toBeNull()
|
||||
act(() => store.pushMetrik(punkt({ uptime_s: 4_242, temp_cpu: 71 })))
|
||||
expect(result.current?.uptime_s).toBe(4_242)
|
||||
expect(result.current?.temp_cpu).toBe(71)
|
||||
})
|
||||
})
|
||||
@@ -1,114 +0,0 @@
|
||||
import { useEffect, useRef, useSyncExternalStore } from "react"
|
||||
import { useSystemStatus, useTokenStats } from "@/lib/queries"
|
||||
import type { MetrikPunkt } from "@/lib/events"
|
||||
|
||||
export type SysSample = { t: number; cpu: number; ram: number; gpu: number | null; disk: number | null }
|
||||
export type RateSample = { t: number; prompt: number; completion: number }
|
||||
|
||||
// v3-Umbau P4: Der Strom liefert jede Sekunde einen Punkt statt alle drei. Bei gleicher
|
||||
// Fenstergröße (~2 Min Live-Verlauf) heißt das dreimal so viele Punkte.
|
||||
// Exportiert, damit der Test gegen DIESEN Wert prüft und nicht gegen eine Kopie, die
|
||||
// beim nächsten Takt-Wechsel still danebenliegt.
|
||||
export const MAX_POINTS = 120
|
||||
|
||||
// Modul-globaler Verlauf: überlebt Mount/Unmount (Tab-Wechsel) → Graphen starten nie leer.
|
||||
//
|
||||
// Bewusst KEIN Zustand-Store (v3-Umbau P3/P4): Hier landet jede Sekunde ein Wert. Ein
|
||||
// Store-Update pro Sekunde würde jede abonnierende Komponente neu rendern; mit einem
|
||||
// eigenen `useSyncExternalStore` außerhalb von React rendern nur die Karten neu, die den
|
||||
// Verlauf wirklich lesen. Das ist der Grund, warum diese Datei nicht in app/store.ts
|
||||
// aufgegangen ist.
|
||||
let sysHist: SysSample[] = []
|
||||
let tokHist: RateSample[] = []
|
||||
const listeners = new Set<() => void>()
|
||||
const emit = () => listeners.forEach((l) => l())
|
||||
|
||||
function subscribe(l: () => void) {
|
||||
listeners.add(l)
|
||||
return () => { listeners.delete(l) }
|
||||
}
|
||||
|
||||
export function pushSys(s: SysSample) { sysHist = [...sysHist, s].slice(-MAX_POINTS); emit() }
|
||||
export function pushTok(s: RateSample) { tokHist = [...tokHist, s].slice(-MAX_POINTS); emit() }
|
||||
|
||||
// Der ZULETZT empfangene Messpunkt. Warum eigens gehalten: Seit P4 ist der Poller von
|
||||
// /api/system/status abgeschaltet, solange der Strom steht — die Query liefert dann nur
|
||||
// noch den Stand vom Seitenaufbau. Ohne diesen Wert froeren Betriebszeit, Temperatur und
|
||||
// die aktuellen Prozentzahlen bei der ersten gemessenen Sekunde ein.
|
||||
let letzteMetrik: MetrikPunkt | null = null
|
||||
|
||||
export const useSysHistory = () => useSyncExternalStore(subscribe, () => sysHist)
|
||||
export const useTokHistory = () => useSyncExternalStore(subscribe, () => tokHist)
|
||||
export const useLetzteMetrik = () => useSyncExternalStore(subscribe, () => letzteMetrik)
|
||||
|
||||
// Letzter Token-Gesamtstand, um daraus die Rate zu bilden. Der Server schickt bewusst
|
||||
// Zähler statt Raten — so ist er zustandslos und ein verpasster Punkt verfälscht nichts.
|
||||
let letzteToken: { p: number; c: number; t: number } | null = null
|
||||
|
||||
/** Einstieg für den Ereignisstrom (lib/events.ts). Ein Messpunkt → beide Verläufe. */
|
||||
export function pushMetrik(m: MetrikPunkt) {
|
||||
const jetzt = Date.now()
|
||||
letzteMetrik = m
|
||||
pushSys({ t: jetzt, cpu: m.cpu ?? 0, ram: m.ram ?? 0, gpu: m.gpu, disk: m.disk })
|
||||
|
||||
if (letzteToken) {
|
||||
const dt = Math.max((jetzt - letzteToken.t) / 1000, 0.001)
|
||||
// Nie negativ: Startet das Backend neu, springen die Zähler zurück — daraus eine
|
||||
// negative Rate zu zeichnen wäre schlicht falsch.
|
||||
pushTok({
|
||||
t: jetzt,
|
||||
prompt: Math.max(0, (m.tok_p - letzteToken.p) / dt),
|
||||
completion: Math.max(0, (m.tok_c - letzteToken.c) / dt),
|
||||
})
|
||||
}
|
||||
letzteToken = { p: m.tok_p, c: m.tok_c, t: jetzt }
|
||||
}
|
||||
|
||||
/** Sammler für den Rückfall-Betrieb (in der Shell eingehängt, also stets gemountet).
|
||||
*
|
||||
* Seit P4 kommen die Messpunkte über den Strom. Dieser Sammler füttert den Verlauf nur
|
||||
* noch aus den POLLERN — also genau dann, wenn der Strom gerissen ist und die Queries
|
||||
* wieder im Normaltakt nachfragen. Ohne ihn stünden die Graphen bei einem Ausfall still,
|
||||
* obwohl die Daten da wären.
|
||||
*
|
||||
* Doppelte Punkte kann es nicht geben: Steht der Strom, sind die Poller entspannt und
|
||||
* liefern kaum neue Zeitstempel; der Filter unten wirft alles weg, was jünger als eine
|
||||
* Sekunde nach dem letzten Punkt ist.
|
||||
*/
|
||||
export function useMetricsFeeder() {
|
||||
const { data: sys, dataUpdatedAt: sysAt } = useSystemStatus()
|
||||
const { data: ts, dataUpdatedAt: tsAt } = useTokenStats()
|
||||
const prevTok = useRef<{ p: number; c: number; t: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sys) return
|
||||
const jetzt = Date.now()
|
||||
if (sysHist.length && jetzt - sysHist[sysHist.length - 1].t < 1_000) return
|
||||
pushSys({
|
||||
t: jetzt,
|
||||
cpu: sys.cpu?.percent ?? 0,
|
||||
ram: sys.ram?.percent ?? 0,
|
||||
gpu: sys.gpu?.busy_percent ?? null,
|
||||
disk: sys.disk?.percent ?? null,
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sysAt])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ts) return
|
||||
const now = Date.now()
|
||||
const p = ts.prompt_tokens, c = ts.completion_tokens
|
||||
if (prevTok.current) {
|
||||
const dt = Math.max((now - prevTok.current.t) / 1000, 0.001)
|
||||
if (!tokHist.length || now - tokHist[tokHist.length - 1].t >= 1_000) {
|
||||
pushTok({
|
||||
t: now,
|
||||
prompt: Math.max(0, (p - prevTok.current.p) / dt),
|
||||
completion: Math.max(0, (c - prevTok.current.c) / dt),
|
||||
})
|
||||
}
|
||||
}
|
||||
prevTok.current = { p, c, t: now }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tsAt])
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
// Zentrale Daten-Hooks (TanStack Query). Kapseln lib/api.ts und liefern Caching,
|
||||
// Dedup (gleicher queryKey = eine Anfrage über alle Views), Retry und Polling.
|
||||
// Mutations invalidieren gezielt die betroffenen Keys, statt manuell neu zu laden.
|
||||
|
||||
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"
|
||||
import { sseVerbunden } from "./events"
|
||||
import {
|
||||
api,
|
||||
type AgentStatus,
|
||||
type AktivitaetResp,
|
||||
type ChronikResp,
|
||||
type ConnectResp,
|
||||
type ConnectHealth,
|
||||
type DiscoverResp,
|
||||
type DraftsResp,
|
||||
type GroupsResp,
|
||||
type HermesBrainResp,
|
||||
type Health,
|
||||
type HistoryResp,
|
||||
type IdeenLogResp,
|
||||
type IdeenResp,
|
||||
type Job,
|
||||
type MemoryGraph,
|
||||
type ModelsResp,
|
||||
type Reminder,
|
||||
type RoutingResp,
|
||||
type RoutingPolicyMeta,
|
||||
type ServicesResp,
|
||||
type SystemStatus,
|
||||
type TokenStats,
|
||||
type UpdatesResp,
|
||||
type VoiceTraceResp,
|
||||
type WissenResp,
|
||||
type ZeitmaschineResp,
|
||||
} from "./api"
|
||||
|
||||
// Zentrale Query-Keys (eine Quelle der Wahrheit für invalidate).
|
||||
export const qk = {
|
||||
health: ["health"] as const,
|
||||
systemStatus: ["system-status"] as const,
|
||||
services: ["services"] as const,
|
||||
models: ["models"] as const,
|
||||
groups: ["groups"] as const,
|
||||
routing: ["routing"] as const,
|
||||
routingPolicy: ["routing-policy"] as const,
|
||||
jobs: ["jobs"] as const,
|
||||
tokenStats: ["token-stats"] as const,
|
||||
agentStatus: ["agent-status"] as const,
|
||||
agentAktivitaet: ["agent-aktivitaet"] as const,
|
||||
hermesBrain: ["hermes-brain"] as const,
|
||||
updates: ["updates"] as const,
|
||||
discover: ["discover"] as const,
|
||||
drafts: (target?: string) => ["drafts", target ?? ""] as const,
|
||||
connect: (params?: string) => ["connect", params ?? ""] as const,
|
||||
connectHealth: ["connect-health"] as const,
|
||||
memoryGraph: ["memory-graph"] as const,
|
||||
voiceTrace: ["voice-trace"] as const,
|
||||
ideen: ["ideen"] as const,
|
||||
chronik: ["chronik"] as const,
|
||||
wissen: ["wissen"] as const,
|
||||
zeitmaschine: ["zeitmaschine"] as const,
|
||||
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, // System + Token: seit P4 nur noch Rueckfall/Randdaten (siehe unten)
|
||||
schnell: 8_000, // „fühlt sich live an": Modelle, Voice-Trace
|
||||
normal: 10_000, // Dienste, Routing, Agent-Status, Health
|
||||
gemuetlich: 30_000, // Chronik, Ideen
|
||||
traege: 60_000, // Updates-Check, Reminders, Zeitmaschine, HF-Abfragen
|
||||
} as const
|
||||
|
||||
// UMBAU v3 P3a: Läuft der SSE-Eventstrom (/api/events, siehe lib/events.ts), sind die
|
||||
// Poller der ereignishaften Quellen nur noch SICHERHEITSNETZ — der Strom invalidiert
|
||||
// bei Änderung sofort. Verbunden = Takt ×5; Strom weg = alter Takt (EventSource
|
||||
// reconnectet selbst). Live-Graphen/Status bleiben bewusst beim Polling.
|
||||
const relax = (ms: number) => () => (sseVerbunden() ? ms * 5 : ms)
|
||||
|
||||
// UMBAU v3 P4: Die MESSWERTE (CPU, RAM, GPU, Temperatur, Token-Zähler, Betriebszeit)
|
||||
// kommen jetzt gepusht — Ereignis `metrik`, jede Sekunde. Der Poller dieser beiden
|
||||
// Endpunkte war die halbe Grundlast der Startseite (Befund B-12).
|
||||
//
|
||||
// Er wird trotzdem NICHT abgeschaltet, sondern gedrosselt. Grund: Beide Antworten tragen
|
||||
// mehr als die Messwerte — `/api/system/status` liefert die Versions-Hashes (Sidebar-Fuß),
|
||||
// `/api/system/token-stats` die Gesamtsumme und die Cloud-Ersparnis, für die das Backend
|
||||
// die Tarife auflöst (Preis-Logik ist dort die einzige Wahrheit, sie hier nachzubauen wäre
|
||||
// eine zweite). Beides ändert sich langsam — einmal pro Minute reicht dafür völlig.
|
||||
//
|
||||
// 3 s → 60 s ist eine Zwanzigstel-Last; abschalten hätte diese Felder einfrieren lassen.
|
||||
const langsamWennStrom = (ms: number) => () => (sseVerbunden() ? 60_000 : ms)
|
||||
|
||||
// Ideen-Queue (natives Hermes-Kanban): das Backend cached ~15 s, öfter pollen lohnt nicht.
|
||||
export const useIdeen = (refetchInterval: number = TAKT.gemuetlich) =>
|
||||
useQuery({
|
||||
queryKey: qk.ideen,
|
||||
queryFn: () => api<IdeenResp>("/api/ideen"),
|
||||
refetchInterval: relax(refetchInterval),
|
||||
})
|
||||
|
||||
// Ideen-Log (Live Worker Log): pollt nur solange Idee triage/running UND offen
|
||||
export const useIdeenLog = (id: string, enabled: boolean = false) =>
|
||||
useQuery({
|
||||
queryKey: ["ideen-log", id],
|
||||
queryFn: () => api<IdeenLogResp>(`/api/ideen/${id}/log`),
|
||||
enabled,
|
||||
refetchInterval: 4000, // alle ~4s
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
// Limit gehört in den Key: zwei Aufrufer mit verschiedenen Limits würden sich sonst
|
||||
// denselben Cache-Eintrag gegenseitig überschreiben. SSE invalidiert das Präfix ["chronik"].
|
||||
export const useChronik = (limit = 150, refetchInterval: number = TAKT.gemuetlich) =>
|
||||
useQuery({
|
||||
queryKey: [...qk.chronik, limit],
|
||||
queryFn: () => api<ChronikResp>(`/api/chronik?limit=${limit}`),
|
||||
refetchInterval: relax(refetchInterval),
|
||||
select: (d) => d.items ?? [],
|
||||
})
|
||||
|
||||
export const useWissen = () =>
|
||||
useQuery({ queryKey: qk.wissen, queryFn: () => api<WissenResp>("/api/wissen") })
|
||||
|
||||
export const useZeitmaschine = (refetchInterval: number = TAKT.traege, enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: qk.zeitmaschine,
|
||||
queryFn: () => api<ZeitmaschineResp>("/api/zeitmaschine"),
|
||||
refetchInterval,
|
||||
enabled,
|
||||
})
|
||||
|
||||
export const useReminders = (refetchInterval: number = TAKT.traege) =>
|
||||
useQuery({
|
||||
queryKey: qk.reminders,
|
||||
queryFn: () => api<{ items: Reminder[] }>("/api/reminders"),
|
||||
refetchInterval: relax(refetchInterval),
|
||||
select: (d) => d.items ?? [],
|
||||
})
|
||||
|
||||
// Per-Turn-Latenz-Trace (letzte N Voice/Lucy-Turns mit Stufen-Breakdown).
|
||||
export const useVoiceTrace = (limit = 12, refetchInterval: number = TAKT.schnell) =>
|
||||
useQuery({
|
||||
queryKey: qk.voiceTrace,
|
||||
queryFn: () => api<VoiceTraceResp>(`/api/voice/trace?limit=${limit}`),
|
||||
refetchInterval,
|
||||
select: (d) => d.turns ?? [],
|
||||
})
|
||||
|
||||
export const useMemoryGraph = (enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: qk.memoryGraph,
|
||||
queryFn: () => api<MemoryGraph>("/api/wissen/graph"),
|
||||
enabled,
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
|
||||
export const useHealth = () =>
|
||||
useQuery({ queryKey: qk.health, queryFn: () => api<Health>("/api/health"), refetchInterval: TAKT.normal })
|
||||
|
||||
export const useSystemStatus = (refetchInterval: number = TAKT.graph) =>
|
||||
useQuery({ queryKey: qk.systemStatus, queryFn: () => api<SystemStatus>("/api/system/status"),
|
||||
refetchInterval: langsamWennStrom(refetchInterval) })
|
||||
|
||||
// Metrik-Verlauf (Cockpit-Zeitachse): 1h/24h aus dem Backend-Ringpuffer. Nur aktiv,
|
||||
// wenn eine Karte gerade diesen Bereich zeigt (enabled) — Live bleibt beim 3s-Store.
|
||||
export const useMetricHistory = (minutes: number, enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: ["metrics-history", minutes],
|
||||
queryFn: () => api<HistoryResp>(`/api/system/history?minutes=${minutes}`),
|
||||
enabled,
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
export const useServices = (refetchInterval: number = TAKT.normal) =>
|
||||
useQuery({ queryKey: qk.services, queryFn: () => api<ServicesResp>("/api/system/services"), refetchInterval })
|
||||
|
||||
// Der SSE-Strom invalidiert "models" bei Laden/Entladen → Poller ist nur Sicherheitsnetz.
|
||||
export const useModels = (refetchInterval: number = TAKT.schnell) =>
|
||||
useQuery({ queryKey: qk.models, queryFn: () => api<ModelsResp>("/api/models"), refetchInterval: relax(refetchInterval) })
|
||||
|
||||
export const useGroups = (refetchInterval: number = TAKT.normal) =>
|
||||
useQuery({ queryKey: qk.groups, queryFn: () => api<GroupsResp>("/api/groups"), refetchInterval })
|
||||
|
||||
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") })
|
||||
|
||||
// Jobs (Downloads) takten etwas schneller, damit Fortschritts-Balken flüssig wirken.
|
||||
// `enabled=false` pausiert den Beobachter (z. B. geschlossene System-Schublade) —
|
||||
// der Cache bleibt geteilt, es pollt nur niemand umsonst.
|
||||
export const useJobs = (refetchInterval = 3_000, enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: qk.jobs,
|
||||
queryFn: () => api<{ jobs: Job[] }>("/api/jobs"),
|
||||
// Seit P4 meldet der Fingerabdruck-Sammler Zustand UND Fortschritt der Jobs —
|
||||
// der Poller ist damit auch hier nur noch Sicherheitsnetz.
|
||||
refetchInterval: relax(refetchInterval),
|
||||
enabled,
|
||||
select: (d) => d.jobs ?? [],
|
||||
})
|
||||
|
||||
export const useTokenStats = (refetchInterval: number = TAKT.graph) =>
|
||||
useQuery({ queryKey: qk.tokenStats, queryFn: () => api<TokenStats>("/api/system/token-stats"),
|
||||
refetchInterval: langsamWennStrom(refetchInterval) })
|
||||
|
||||
export const useAgentStatus = (refetchInterval: number = TAKT.normal) =>
|
||||
useQuery({ queryKey: qk.agentStatus, queryFn: () => api<AgentStatus>("/api/agent/status"), refetchInterval })
|
||||
|
||||
// Werkzeug-Verlauf des Agenten. Liest das Ende von Hermes' Log — billig, aber es
|
||||
// aendert sich nur, wenn Lucy wirklich arbeitet; der gemuetliche Takt reicht.
|
||||
export const useWerkzeugverlauf = (refetchInterval: number = TAKT.gemuetlich) =>
|
||||
useQuery({
|
||||
queryKey: qk.agentAktivitaet,
|
||||
queryFn: () => api<AktivitaetResp>("/api/agent/aktivitaet?limit=80"),
|
||||
refetchInterval: relax(refetchInterval),
|
||||
})
|
||||
|
||||
// Agent-Hirn (Hermes) + bestes NousResearch-Update. Selten pollen (HF-Abfrage).
|
||||
export const useHermesBrain = (refetchInterval: number = TAKT.traege) =>
|
||||
useQuery({ queryKey: qk.hermesBrain, queryFn: () => api<HermesBrainResp>("/api/agent/brain"), refetchInterval })
|
||||
|
||||
// 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 = () =>
|
||||
useQuery({ queryKey: qk.discover, queryFn: () => api<DiscoverResp>("/api/discover") })
|
||||
|
||||
// Verfügbare Spec-Draft-Modelle + Vocab-Kompatibilität zum Ziel-Modell (GGUF-Pfad).
|
||||
export const useDrafts = (target?: string) =>
|
||||
useQuery({
|
||||
queryKey: qk.drafts(target),
|
||||
queryFn: () => api<DraftsResp>(`/api/models/drafts?target=${encodeURIComponent(target ?? "")}`),
|
||||
enabled: !!target,
|
||||
})
|
||||
|
||||
export const useConnect = (params?: string) =>
|
||||
useQuery({
|
||||
queryKey: qk.connect(params),
|
||||
queryFn: () => api<ConnectResp>(params ? `/api/connect?${params}` : "/api/connect"),
|
||||
})
|
||||
|
||||
// Live-Status der zwei Leitungen (Gateway + Gedächtnis) — alle 15s aktualisiert.
|
||||
export const useConnectHealth = () =>
|
||||
useQuery({
|
||||
queryKey: qk.connectHealth,
|
||||
queryFn: () => api<ConnectHealth>("/api/connect/health"),
|
||||
refetchInterval: 15000,
|
||||
})
|
||||
|
||||
/** Nach Mutationen die betroffenen Listen-Queries neu ziehen. */
|
||||
export function invalidate(qc: QueryClient, ...keys: (readonly unknown[])[]) {
|
||||
for (const key of keys) qc.invalidateQueries({ queryKey: key })
|
||||
}
|
||||
|
||||
export { useQueryClient }
|
||||
@@ -1,145 +0,0 @@
|
||||
import {
|
||||
Brain,
|
||||
BrainCog,
|
||||
Code,
|
||||
Eye,
|
||||
BookMarked,
|
||||
Telescope,
|
||||
Scale,
|
||||
ListOrdered,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
// ── EINE Quelle der Wahrheit für ALLE Serving-Rollen ────────────────────────
|
||||
// Klartext statt Kürzel: jede Rolle bekommt einen Zweck-Namen, ein Icon und
|
||||
// eine Ein-Satz-Erklärung, damit die UI ohne Fachjargon verständlich ist.
|
||||
// Das technische Kürzel (`role`) bleibt für den späteren Experten-Modus erhalten.
|
||||
//
|
||||
// Die Rollen hier spiegeln den ECHTEN Stand der Box (/api/models → role):
|
||||
// hermes · heavy · coder · coder-lite · vision · embed · scout
|
||||
// (Kein Phantom-`fast`: das ist nur ein Lane-Alias, keine Modell-Rolle. Lucys
|
||||
// Alltags-Hirn ist die Rolle `hermes` = Qwen3.6.)
|
||||
|
||||
export interface RoleMeta {
|
||||
role: string // technisches Kürzel (Experten-Modus)
|
||||
label: string // Klartext-Zweckname
|
||||
short: string // Kurzform für enge Badges
|
||||
icon: LucideIcon
|
||||
desc: string // Tooltip / Erklärung in einfacher Sprache
|
||||
tone: string // Farbklassen fürs Badge
|
||||
protected?: boolean // Hirn & Gedächtnis: lebenswichtig → nicht löschbar/entladbar (Etappe 3)
|
||||
}
|
||||
|
||||
// Reihenfolge = Anzeige-Reihenfolge in den Slot-Übersichten (Wichtigstes zuerst).
|
||||
export const ROLE_META: RoleMeta[] = [
|
||||
{
|
||||
role: "hermes",
|
||||
label: "Lucys Hirn",
|
||||
short: "Hirn",
|
||||
icon: Brain,
|
||||
desc: "Lucys Alltags-Verstand — denkt und redet mit dir. Immer bereit.",
|
||||
tone: "bg-indigo-500/15 text-indigo-400 border-indigo-500/25",
|
||||
protected: true,
|
||||
},
|
||||
{
|
||||
role: "embed",
|
||||
label: "Lucys Gedächtnis",
|
||||
short: "Gedächtnis",
|
||||
icon: BookMarked,
|
||||
desc: "Merkt sich Wichtiges und findet es später wieder.",
|
||||
tone: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25",
|
||||
protected: true,
|
||||
},
|
||||
{
|
||||
role: "reranker",
|
||||
label: "Gedächtnis-Sortierer",
|
||||
short: "Sortierer",
|
||||
icon: ListOrdered,
|
||||
desc: "Ordnet gefundene Erinnerungen nach echter Relevanz — die Feinsortierung hinter dem Gedächtnis.",
|
||||
tone: "bg-lime-500/15 text-lime-400 border-lime-500/25",
|
||||
},
|
||||
{
|
||||
role: "vision",
|
||||
label: "Lucys Augen",
|
||||
short: "Augen",
|
||||
icon: Eye,
|
||||
desc: "Sieht Bilder und Screenshots und beschreibt, was drauf ist.",
|
||||
tone: "bg-pink-500/15 text-pink-400 border-pink-500/25",
|
||||
},
|
||||
{
|
||||
role: "heavy",
|
||||
label: "Großes Hirn",
|
||||
short: "Groß",
|
||||
icon: BrainCog,
|
||||
desc: "Das schwere Geschütz — wird für besonders knifflige Fragen dazugeholt.",
|
||||
tone: "bg-amber-500/15 text-amber-400 border-amber-500/25",
|
||||
},
|
||||
{
|
||||
role: "coder-lite",
|
||||
label: "Programmierer (schnell)",
|
||||
short: "Code schnell",
|
||||
icon: Code,
|
||||
desc: "Schnelles Programmieren im Alltag.",
|
||||
tone: "bg-violet-500/15 text-violet-400 border-violet-500/25",
|
||||
},
|
||||
{
|
||||
role: "coder",
|
||||
label: "Programmierer",
|
||||
short: "Code",
|
||||
icon: Code,
|
||||
desc: "Baut und plant. Der Kopf der Coding-Mannschaft — er hält den Faden und verteilt Zuarbeit.",
|
||||
tone: "bg-fuchsia-500/15 text-fuchsia-400 border-fuchsia-500/25",
|
||||
protected: true,
|
||||
},
|
||||
{
|
||||
role: "scout",
|
||||
label: "Späher",
|
||||
short: "Späher",
|
||||
icon: Telescope,
|
||||
desc: "Schneller erster Blick und grobe Einschätzung.",
|
||||
tone: "bg-teal-500/15 text-teal-400 border-teal-500/25",
|
||||
},
|
||||
{
|
||||
role: "kritiker",
|
||||
label: "Kritiker",
|
||||
short: "Kritiker",
|
||||
icon: Scale,
|
||||
desc: "Liest dem Programmierer nach jeder Etappe gegen — bewusst aus einer FREMDEN Modellfamilie (Mistral statt Qwen), damit er andere blinde Flecken hat.",
|
||||
tone: "bg-cyan-500/15 text-cyan-400 border-cyan-500/25",
|
||||
},
|
||||
]
|
||||
|
||||
// Kanonische Rollen-Reihenfolge (ersetzt die alte, veraltete ROLES-Liste).
|
||||
export const ROLES: string[] = ROLE_META.map((m) => m.role)
|
||||
|
||||
const BY_ROLE: Record<string, RoleMeta> = Object.fromEntries(
|
||||
ROLE_META.map((m) => [m.role, m]),
|
||||
)
|
||||
|
||||
// Fallback für unbekannte/neue Rollen — nie „undefined", damit die UID nicht bricht.
|
||||
const UNKNOWN = (role?: string | null): RoleMeta => ({
|
||||
role: role || "?",
|
||||
label: role || "unbekannt",
|
||||
short: role || "?",
|
||||
icon: Brain,
|
||||
desc: "Rolle ohne hinterlegten Klartext-Namen.",
|
||||
tone: "bg-slate-500/15 text-slate-300 border-slate-500/25",
|
||||
})
|
||||
|
||||
// Aliase, die auf eine echte Rolle zeigen (z.B. Lane-Alias `fast` = Hirn).
|
||||
const ALIAS: Record<string, string> = {
|
||||
fast: "hermes",
|
||||
coder_lite: "coder-lite",
|
||||
}
|
||||
|
||||
export function roleMeta(role?: string | null): RoleMeta {
|
||||
if (!role) return UNKNOWN(role)
|
||||
return BY_ROLE[role] || BY_ROLE[ALIAS[role]] || UNKNOWN(role)
|
||||
}
|
||||
|
||||
// Klartext-Name einer Rolle (mit optionaler Kurzform).
|
||||
export const roleLabel = (role?: string | null, short = false) =>
|
||||
short ? roleMeta(role).short : roleMeta(role).label
|
||||
|
||||
// Farbton — bleibt kompatibel zur bisherigen roleTone()-Signatur.
|
||||
export const roleTone = (role?: string | null) => roleMeta(role).tone
|
||||
@@ -0,0 +1,89 @@
|
||||
// Live-Strom — Gegenstück zu backend/routers/events.py (/api/stream).
|
||||
//
|
||||
// Ein EventSource, zwei Ereignisarten:
|
||||
// · `invalidate` stößt die betroffenen Abfragen an (z. B. „start“, wenn der Wächter
|
||||
// etwas Neues findet — sein Stand ändert sich höchstens einmal pro Minute)
|
||||
// · `metrik` jede Sekunde ein Messpunkt für die Rundinstrumente
|
||||
//
|
||||
// Der Messpunkt liegt bewusst AUSSERHALB von React (useSyncExternalStore): So zeichnen
|
||||
// nur die Instrumente jede Sekunde neu, nicht die ganze Seite.
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
export interface MetrikPunkt {
|
||||
cpu: number
|
||||
ram: number
|
||||
ram_used: number
|
||||
ram_total: number
|
||||
gpu: number | null
|
||||
disk: number | null
|
||||
temp_cpu: number | null
|
||||
temp_gpu: number | null
|
||||
uptime_s: number | null
|
||||
tok_p: number
|
||||
tok_c: number
|
||||
}
|
||||
|
||||
export type StromLage = "verbinde" | "live" | "getrennt"
|
||||
|
||||
let lage: StromLage = "verbinde"
|
||||
let letzte: MetrikPunkt | null = null
|
||||
const hoerer = new Set<() => void>()
|
||||
const melde = () => hoerer.forEach((h) => h())
|
||||
|
||||
function abonniere(h: () => void) {
|
||||
hoerer.add(h)
|
||||
return () => {
|
||||
hoerer.delete(h)
|
||||
}
|
||||
}
|
||||
|
||||
export const stromVerbunden = () => lage === "live"
|
||||
export const useStromLage = () => useSyncExternalStore(abonniere, () => lage)
|
||||
export const useLetzteMetrik = () => useSyncExternalStore(abonniere, () => letzte)
|
||||
|
||||
/** Nur für Tests: Messpunkt direkt einspeisen. */
|
||||
export function metrikEinspeisen(m: MetrikPunkt | null) {
|
||||
letzte = m
|
||||
melde()
|
||||
}
|
||||
|
||||
function setzeLage(neu: StromLage) {
|
||||
if (neu !== lage) {
|
||||
lage = neu
|
||||
melde()
|
||||
}
|
||||
}
|
||||
|
||||
export function useStrom() {
|
||||
const qc = useQueryClient()
|
||||
useEffect(() => {
|
||||
const es = new EventSource("/api/stream")
|
||||
es.onopen = () => {
|
||||
setzeLage("live")
|
||||
// Was während einer Trennung passiert ist, hat der Strom nicht gemeldet.
|
||||
qc.invalidateQueries()
|
||||
}
|
||||
es.onerror = () => setzeLage("getrennt")
|
||||
es.addEventListener("invalidate", (e) => {
|
||||
try {
|
||||
const keys: string[] = JSON.parse((e as MessageEvent).data)?.keys ?? []
|
||||
for (const k of keys) qc.invalidateQueries({ queryKey: [k] })
|
||||
} catch {
|
||||
/* ein kaputtes Ereignis reißt den Strom nicht */
|
||||
}
|
||||
})
|
||||
es.addEventListener("metrik", (e) => {
|
||||
try {
|
||||
metrikEinspeisen(JSON.parse((e as MessageEvent).data) as MetrikPunkt)
|
||||
} catch {
|
||||
/* ein kaputter Messpunkt reißt den Strom nicht */
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
es.close()
|
||||
setzeLage("getrennt")
|
||||
}
|
||||
}, [qc])
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Datenformen der Box-Wart-Schnittstellen — Gegenstück zu backend/routers/boxwart.py,
|
||||
// services/waechter.py, modell_nutzung.py, zeitplan.py und routers/radar.py.
|
||||
|
||||
export type Stufe = "rot" | "gelb"
|
||||
export type LampenZustand = "ok" | "aus" | "warn" | "info" | "fehler"
|
||||
|
||||
export interface HinweisAktion {
|
||||
id: "neustart" | "protokoll" | "job-wiederholen" | string
|
||||
label: string
|
||||
dienst?: string
|
||||
job?: string
|
||||
}
|
||||
|
||||
export interface Hinweis {
|
||||
id: string
|
||||
stufe: Stufe
|
||||
titel: string
|
||||
text: string
|
||||
quelle: string
|
||||
seit: number
|
||||
zuletzt: number
|
||||
takte?: number
|
||||
aktionen: HinweisAktion[]
|
||||
}
|
||||
|
||||
export interface VerlaufEintrag {
|
||||
ts: number
|
||||
art: "neu" | "erledigt" | "auto"
|
||||
id: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface Lampe {
|
||||
id: string
|
||||
label: string
|
||||
zustand: LampenZustand
|
||||
wert: string
|
||||
}
|
||||
|
||||
export interface FlugplanEintrag {
|
||||
zeit: string
|
||||
titel: string
|
||||
text: string
|
||||
status: "erledigt" | "fehler" | "geplant"
|
||||
}
|
||||
|
||||
export interface Start {
|
||||
zustand: {
|
||||
stufe: "ok" | Stufe
|
||||
anzahl: number
|
||||
waechter_wach: boolean
|
||||
stand: number | null
|
||||
update_laeuft: boolean
|
||||
}
|
||||
hinweise: Hinweis[]
|
||||
verlauf: VerlaufEintrag[]
|
||||
lampen: Lampe[]
|
||||
box: {
|
||||
ram_used: number | null
|
||||
ram_total: number | null
|
||||
temp_cpu: number | null
|
||||
temp_gpu: number | null
|
||||
platte: { used: number; total: number; percent: number } | null
|
||||
uptime_s: number | null
|
||||
}
|
||||
updates: {
|
||||
bausteine: { id: string; name: string; neu: string }[]
|
||||
modelle: { role: string; title: string; repo: string }[]
|
||||
}
|
||||
flugplan: { gelaufen: FlugplanEintrag[]; geplant: FlugplanEintrag[] }
|
||||
}
|
||||
|
||||
export interface AktionsErgebnis {
|
||||
ok: boolean
|
||||
text?: string
|
||||
out?: string
|
||||
err?: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export interface Nutzung {
|
||||
tage: number
|
||||
absender: { name: string; anfragen: number }[]
|
||||
je_tag: Record<string, Record<string, number>>
|
||||
letzte_24h: { stunde: string; je_absender: Record<string, number> }[]
|
||||
zuletzt_geladen: Record<string, string>
|
||||
}
|
||||
|
||||
export interface Modell {
|
||||
name: string
|
||||
role: string | null
|
||||
aliases: string[]
|
||||
ctx: number | null
|
||||
ttl: number | null
|
||||
filename: string
|
||||
quant: string
|
||||
size_bytes: number | null
|
||||
spec_active: boolean
|
||||
parallel_slots: number
|
||||
capabilities?: { vision?: boolean; tools?: boolean; params_b?: number } & Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
id: string
|
||||
label: string
|
||||
state: "running" | "done" | "failed" | "cancelled" | string
|
||||
progress?: number | null
|
||||
log?: string[]
|
||||
started?: number
|
||||
finished?: number | null
|
||||
}
|
||||
|
||||
export interface Sicherung {
|
||||
snapshot: string
|
||||
file: string
|
||||
size_mb: number
|
||||
mtime?: number
|
||||
}
|
||||
|
||||
export type RadarStatus =
|
||||
| "neu" | "wartet" | "getestet" | "bestanden" | "durchgefallen" | "uebernommen" | "verworfen"
|
||||
|
||||
export interface RadarKandidat {
|
||||
id: string
|
||||
name: string
|
||||
rolle: "hirn" | "coder" | string
|
||||
repo: string
|
||||
datei: string
|
||||
groesse_gb: number | null
|
||||
passt: boolean
|
||||
eng: boolean
|
||||
quelle: string
|
||||
status: RadarStatus
|
||||
begruendung: string
|
||||
}
|
||||
|
||||
export interface RadarTest {
|
||||
id: string
|
||||
kandidat: string
|
||||
rolle: string
|
||||
datum: string
|
||||
ergebnis: "bestanden" | "durchgefallen" | "abgebrochen"
|
||||
werte: Record<string, unknown>
|
||||
vergleich: Record<string, unknown>
|
||||
zusammenfassung: string
|
||||
}
|
||||
|
||||
export interface Radar {
|
||||
naechster_test: string | null
|
||||
letzter_test: string | null
|
||||
kandidaten: RadarKandidat[]
|
||||
tests: RadarTest[]
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// Ein Hook für Alert/Confirm/Prompt-Dialoge — ersetzt die zuvor in jeder View
|
||||
// duplizierte showAlert/showConfirm-Logik + den lokalen Dialog-State.
|
||||
//
|
||||
// Nutzung:
|
||||
// const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog()
|
||||
// ...
|
||||
// showConfirm("Titel", "Wirklich?", () => doIt())
|
||||
// return (<>{dialogElement}...</>)
|
||||
|
||||
import { useCallback, useState } from "react"
|
||||
import { CustomDialog } from "@/components/CustomDialog"
|
||||
|
||||
interface DialogState {
|
||||
type: "alert" | "confirm" | "prompt"
|
||||
title: string
|
||||
message: string
|
||||
defaultValue?: string
|
||||
autoValue?: string
|
||||
autoLabel?: string
|
||||
onConfirm: (val?: string) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function useDialog() {
|
||||
const [dialog, setDialog] = useState<DialogState | null>(null)
|
||||
const close = useCallback(() => setDialog(null), [])
|
||||
|
||||
const showAlert = useCallback((title: string, message: string, onConfirm?: () => void) => {
|
||||
setDialog({
|
||||
type: "alert", title, message,
|
||||
onConfirm: () => { setDialog(null); onConfirm?.() },
|
||||
})
|
||||
}, [])
|
||||
|
||||
const showConfirm = useCallback(
|
||||
(title: string, message: string, onConfirm: () => void, onCancel?: () => void) => {
|
||||
setDialog({
|
||||
type: "confirm", title, message,
|
||||
onConfirm: () => { setDialog(null); onConfirm() },
|
||||
onCancel: () => { setDialog(null); onCancel?.() },
|
||||
})
|
||||
}, [])
|
||||
|
||||
const showPrompt = useCallback(
|
||||
(title: string, message: string, defaultValue: string,
|
||||
onConfirm: (val?: string) => void, onCancel?: () => void,
|
||||
opts?: { autoValue?: string; autoLabel?: string }) => {
|
||||
setDialog({
|
||||
type: "prompt", title, message, defaultValue,
|
||||
autoValue: opts?.autoValue, autoLabel: opts?.autoLabel,
|
||||
onConfirm: (val) => { setDialog(null); onConfirm(val) },
|
||||
onCancel: () => { setDialog(null); onCancel?.() },
|
||||
})
|
||||
}, [])
|
||||
|
||||
const dialogElement = dialog ? <CustomDialog {...dialog} /> : null
|
||||
|
||||
return { showAlert, showConfirm, showPrompt, close, dialogElement }
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
// „Geht's Lucy gut?" — bündelt die vielen technischen Status-Signale zu wenigen
|
||||
// Lucy-Fähigkeiten in Klartext, inkl. Gesamt-Verdikt, Speicher-Ampel und einer
|
||||
// konkreten 1-Klick-Reparatur pro Problem. EINE Quelle für das Status-Panel.
|
||||
|
||||
import { Brain, BookMarked, Eye, Plug, Bot, Mic, ShieldAlert, Server, type LucideIcon } from "lucide-react"
|
||||
import { useHealth, useServices, useModels, useSystemStatus } from "./queries"
|
||||
|
||||
export type CheckStatus = "ok" | "warn" | "down" | "loading"
|
||||
|
||||
// Reparatur-Rezept: entweder einen systemd-Dienst neu starten oder ein Modell laden.
|
||||
// WICHTIG: `service` sind die ECHTEN Unit-Namen (nicht die Anzeige-Namen der
|
||||
// Dienste-Karte), damit /api/maintenance/restart sie akzeptiert.
|
||||
export type Repair =
|
||||
| { kind: "restart"; service: string; label: string; needsSudo?: boolean }
|
||||
| { kind: "loadModel"; model: string; label: string }
|
||||
|
||||
export interface LucyCheck {
|
||||
id: string
|
||||
label: string // Klartext-Name der Fähigkeit
|
||||
icon: LucideIcon
|
||||
status: CheckStatus
|
||||
detail: string // Ein Satz: was ist gerade los
|
||||
critical: boolean // zählt fürs Gesamt-Verdikt „Problem"
|
||||
repair?: Repair
|
||||
}
|
||||
|
||||
export type Verdict = "loading" | "gut" | "warn" | "problem"
|
||||
|
||||
export interface MemoryAmpel {
|
||||
status: "ok" | "warn" | "full" | "unknown"
|
||||
usedGb: number
|
||||
totalGb: number
|
||||
pct: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface LucyHealth {
|
||||
verdict: Verdict
|
||||
checks: LucyCheck[]
|
||||
memory: MemoryAmpel
|
||||
problems: LucyCheck[] // kritische Down-Checks (für den Schnellzugriff im Banner)
|
||||
warns: LucyCheck[] // nicht-kritische Hinweise — damit das Banner SAGT, was los ist
|
||||
unreachable: boolean // Zentrale (MC2) antwortet nicht → alle anderen Anzeigen sind veraltet
|
||||
}
|
||||
|
||||
export function useLucyHealth(): LucyHealth {
|
||||
const { data: health, isError: healthErr } = useHealth()
|
||||
const { data: svc, isError: svcErr } = useServices()
|
||||
const { data: modelsResp } = useModels()
|
||||
const { data: sys } = useSystemStatus()
|
||||
|
||||
// React Query behält bei Fehlern die letzten Daten — ohne diesen Riegel zeigte die UI
|
||||
// bei totem Backend dauerhaft den letzten grünen Stand („eingefrorene Ampel", Review 15.07.).
|
||||
const unreachable = healthErr || svcErr
|
||||
|
||||
const loaded = !!health && !!svc
|
||||
// Matching über den ECHTEN systemd-Unit-Namen (Backend liefert `unit`) — vorher wurde
|
||||
// fragil im deutschen Anzeige-Namen gesucht ("gateway (integr" …).
|
||||
const svcOk = (unit: string): boolean | undefined =>
|
||||
svc?.services.find((s) => s.unit === unit)?.ok
|
||||
const svcHas = (unit: string): boolean => !!svc?.services.some((s) => s.unit === unit)
|
||||
|
||||
const engineOk = health?.engine_reachable
|
||||
const brainReady = health?.brain?.ready
|
||||
const brainName =
|
||||
health?.brain?.model ||
|
||||
modelsResp?.models.find((m) => m.role === "hermes")?.name ||
|
||||
""
|
||||
|
||||
const checks: LucyCheck[] = []
|
||||
|
||||
// 0) Die Zentrale selbst. Antwortet MC2 nicht, ist ALLES andere Spekulation —
|
||||
// dieser Check übertrumpft die übrigen (die zeigen sonst eingefrorene alte Werte).
|
||||
if (unreachable) {
|
||||
checks.push({
|
||||
id: "mc2", label: "Steuerpult (MC2)", icon: Server, critical: true, status: "down",
|
||||
detail: "Die Zentrale antwortet nicht — alle Anzeigen hier können veraltet sein. Läuft die Box? Deploy aktiv?",
|
||||
})
|
||||
}
|
||||
|
||||
// 1) Lucys Hirn — die wichtigste Fähigkeit. Engine läuft UND das Hirn-Modell ist geladen?
|
||||
checks.push(((): LucyCheck => {
|
||||
const base = { id: "brain", label: "Lucys Hirn", icon: Brain, critical: true }
|
||||
if (!loaded) return { ...base, status: "loading", detail: "Wird geprüft …" }
|
||||
if (engineOk === false)
|
||||
return {
|
||||
...base,
|
||||
status: "down",
|
||||
detail: "Die Motor-Maschine (Engine) läuft nicht — Lucy kann gerade gar nicht denken.",
|
||||
repair: { kind: "restart", service: "llama-swap", label: "Motor neu starten", needsSudo: true },
|
||||
}
|
||||
if (brainReady === false)
|
||||
return {
|
||||
...base,
|
||||
status: "down",
|
||||
detail: "Lucys Hirn ist gerade eingeschlafen — einmal aufwecken.",
|
||||
repair: brainName
|
||||
? { kind: "loadModel", model: brainName, label: "Hirn aufwecken" }
|
||||
: undefined,
|
||||
}
|
||||
return { ...base, status: "ok", detail: "Wach und ansprechbar." }
|
||||
})())
|
||||
|
||||
// 2) Lucys Augen — das Vision-Modell (Rolle `vision`). Seit der Warm-Set-Diät 07.07. ist
|
||||
// ON-DEMAND der NORMALZUSTAND (User-Entscheid: Augen nur, wenn Lucy an ist / IDE sie ruft;
|
||||
// die Lucy-App wärmt sie beim Start selbst an). Ruhende Augen sind darum OK, keine Warnung.
|
||||
checks.push(((): LucyCheck => {
|
||||
const base = { id: "vision", label: "Lucys Augen", icon: Eye, critical: false }
|
||||
if (!loaded || !modelsResp) return { ...base, status: "loading", detail: "Wird geprüft …" }
|
||||
const vm = modelsResp.models.find((m) => m.role === "vision")
|
||||
if (!vm)
|
||||
return { ...base, status: "warn", detail: "Kein Augen-Modell eingerichtet — Lucy kann keine Bilder ansehen." }
|
||||
const warm = (modelsResp.running ?? []).includes(vm.name)
|
||||
return warm
|
||||
? { ...base, status: "ok", detail: "Sieht gerade zu (geladen, solange Lucy sie nutzt)." }
|
||||
: {
|
||||
...base,
|
||||
status: "ok",
|
||||
detail: "Augen ruhen — sie laden von selbst, sobald Lucy startet oder ein Bild kommt.",
|
||||
repair: { kind: "loadModel", model: vm.name, label: "Augen wecken" },
|
||||
}
|
||||
})())
|
||||
|
||||
// 3) Lucys Gedächtnis — Hermes Native Gedächtnis (integriert in hermes-gateway).
|
||||
checks.push(((): LucyCheck => {
|
||||
const base = { id: "memory", label: "Lucys Gedächtnis", icon: BookMarked, critical: true }
|
||||
const ok = svcOk("hermes-gateway")
|
||||
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
|
||||
return ok
|
||||
? { ...base, status: "ok", detail: "Hermes-Natives Gedächtnis aktiv (SQLite & Embeddings)." }
|
||||
: {
|
||||
...base,
|
||||
status: "down",
|
||||
detail: "Lucy kann sich gerade nichts merken — das Gateway antwortet nicht.",
|
||||
repair: { kind: "restart", service: "hermes-gateway", label: "Gateway neu starten" },
|
||||
}
|
||||
})())
|
||||
|
||||
// 4) Verbindung (LLM-Gateway) — der Denkpfad, über den Apps/IDEs/Lucy die Modelle erreichen.
|
||||
// Seit UMBAU v3 ist das der EIGENE mc2-gateway-Prozess; health.gateway_reachable prüft
|
||||
// ihn jetzt wirklich (statt nur die Engine). Repariert wird die richtige Unit.
|
||||
checks.push(((): LucyCheck => {
|
||||
const base = { id: "gateway", label: "Verbindung (Gateway)", icon: Plug, critical: true }
|
||||
const ok = health ? health.gateway_reachable : svcOk("mc2-gateway")
|
||||
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
|
||||
const unit = svcHas("mc2-gateway") ? "mc2-gateway" : "mission-control-2"
|
||||
return ok
|
||||
? { ...base, status: "ok", detail: "Apps und IDEs können Lucy erreichen." }
|
||||
: {
|
||||
...base,
|
||||
status: "down",
|
||||
detail: "Der Modell-Gateway antwortet nicht — Lucy und die IDEs erreichen kein Modell.",
|
||||
repair: { kind: "restart", service: unit, label: "Gateway neu starten" },
|
||||
}
|
||||
})())
|
||||
|
||||
// 4b) Der Wächter (Steward) — überwacht Warm-Set, Health-Flanken und Gedächtnis-Dubletten.
|
||||
// Nicht kritisch fürs Antworten, aber ohne ihn merkt niemand, wenn etwas kippt.
|
||||
checks.push(((): LucyCheck => {
|
||||
const base = { id: "steward", label: "Wächter (Steward)", icon: ShieldAlert, critical: false }
|
||||
if (!loaded) return { ...base, status: "loading", detail: "Wird geprüft …" }
|
||||
if (!svcHas("mc2-steward"))
|
||||
return { ...base, status: "ok", detail: "Auf dieser Installation nicht eingerichtet." }
|
||||
return svcOk("mc2-steward")
|
||||
? { ...base, status: "ok", detail: "Wacht über Warm-Set, Dienste und Gedächtnis." }
|
||||
: {
|
||||
...base,
|
||||
status: "warn",
|
||||
detail: "Der Wächter schläft — niemand meldet gerade Ausfälle oder wärmt Modelle nach.",
|
||||
repair: { kind: "restart", service: "mc2-steward", label: "Wächter neu starten" },
|
||||
}
|
||||
})())
|
||||
|
||||
// 5) Lucys Agent (Hermes) — der Dienst, der als Lucy antwortet (Terminal/Telegram/Sprechen).
|
||||
checks.push(((): LucyCheck => {
|
||||
const base = { id: "agent", label: "Lucys Agent (Hermes)", icon: Bot, critical: true }
|
||||
const ok = svcOk("hermes-gateway") // unit-Name (identisch zum alten Namens-Match, jetzt exakt)
|
||||
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
|
||||
return ok
|
||||
? { ...base, status: "ok", detail: "Lucy ist bereit zu reden und zu handeln." }
|
||||
: {
|
||||
...base,
|
||||
status: "down",
|
||||
detail: "Lucys Agent ist offline — sie reagiert gerade nicht.",
|
||||
repair: { kind: "restart", service: "hermes-gateway", label: "Agent neu starten" },
|
||||
}
|
||||
})())
|
||||
|
||||
// 6) Lucys Stimme — Voice-Dienst (STT/TTS). Nicht kritisch: Chat geht auch ohne.
|
||||
checks.push(((): LucyCheck => {
|
||||
const base = { id: "voice", label: "Lucys Stimme", icon: Mic, critical: false }
|
||||
const ok = svcOk("voice-service")
|
||||
if (!loaded || ok === undefined) return { ...base, status: "loading", detail: "Wird geprüft …" }
|
||||
return ok
|
||||
? { ...base, status: "ok", detail: "Lucy kann hören und sprechen." }
|
||||
: {
|
||||
...base,
|
||||
status: "warn",
|
||||
detail: "Sprechen ist gerade aus — Tippen geht weiter normal.",
|
||||
repair: { kind: "restart", service: "voice-service", label: "Stimme neu starten" },
|
||||
}
|
||||
})())
|
||||
|
||||
// Speicher-Ampel = System-RAM, DIESELBE Quelle wie die RAM-Zeile in der
|
||||
// System-Status-Kachel. Vorher zeigte die Pille den GTT-Pool (GPU-Sicht auf den
|
||||
// Unified-Memory) — der weicht auf der APU vom System-RAM ab, wodurch „Box gesund"
|
||||
// und „System-Status" zwei verschiedene Speicher-Zahlen zeigten. Eine Box, eine Zahl.
|
||||
// Schwellen am RAM-Charakter geeicht: 4 Modelle warm ≈ 78 % gilt als gesund → grün.
|
||||
const total = sys?.ram?.total ?? 0
|
||||
const used = sys?.ram?.used ?? 0
|
||||
const pct = sys?.ram?.percent ?? (total > 0 ? Math.min(100, (used / total) * 100) : 0)
|
||||
const B = 1024 ** 3
|
||||
const memory: MemoryAmpel = !total
|
||||
? { status: "unknown", usedGb: 0, totalGb: 0, pct: 0, text: "Speicher-Auslastung unbekannt." }
|
||||
: pct >= 93
|
||||
? { status: "full", usedGb: used / B, totalGb: total / B, pct, text: "Speicher fast voll — Lucy könnte langsamer werden." }
|
||||
: pct >= 85
|
||||
? { status: "warn", usedGb: used / B, totalGb: total / B, pct, text: "Speicher gut gefüllt — noch okay." }
|
||||
: { status: "ok", usedGb: used / B, totalGb: total / B, pct, text: "Genug Speicher frei." }
|
||||
|
||||
const anyLoading = checks.some((c) => c.status === "loading")
|
||||
const problems = checks.filter((c) => c.critical && c.status === "down")
|
||||
const warns = checks.filter((c) => c.status === "warn" || (!c.critical && c.status === "down"))
|
||||
const memBad = memory.status === "full"
|
||||
|
||||
const verdict: Verdict = anyLoading && !problems.length
|
||||
? "loading"
|
||||
: problems.length
|
||||
? "problem"
|
||||
: warns.length || memBad
|
||||
? "warn"
|
||||
: "gut"
|
||||
|
||||
return { verdict, checks, memory, problems, warns, unreachable }
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useMemo } from "react"
|
||||
import { useSystemStatus } from "@/lib/queries"
|
||||
import { useLetzteMetrik, useSysHistory, type SysSample } from "@/lib/metricsStore"
|
||||
import type { SystemStatus } from "@/lib/api"
|
||||
|
||||
export type { SysSample }
|
||||
|
||||
/** Aktuelle System-Werte (für die Legende) + rollende Live-Historie.
|
||||
*
|
||||
* v3-Umbau P4: Die Query läuft nur noch minütlich, solange der Ereignisstrom steht —
|
||||
* ihre MESSWERTE wären damit bis zu 60 s alt. Deshalb werden sie hier mit dem letzten
|
||||
* Messpunkt aus dem Strom überschrieben. Alles, was der Strom NICHT trägt (VRAM-Detail,
|
||||
* Kernzahl, Versionen), bleibt aus der Query: Der Strom soll schlank bleiben, und diese
|
||||
* Felder ändern sich ohnehin selten.
|
||||
*/
|
||||
export function useSystemHistory() {
|
||||
const { data: query, error } = useSystemStatus()
|
||||
const live = useLetzteMetrik()
|
||||
const hist = useSysHistory()
|
||||
|
||||
const sys = useMemo<SystemStatus | undefined>(() => {
|
||||
if (!query) return query
|
||||
if (!live) return query
|
||||
return {
|
||||
...query,
|
||||
cpu: { ...query.cpu, percent: live.cpu ?? query.cpu.percent },
|
||||
ram: {
|
||||
...query.ram,
|
||||
percent: live.ram ?? query.ram.percent,
|
||||
used: live.ram_used ?? query.ram.used,
|
||||
total: live.ram_total ?? query.ram.total,
|
||||
},
|
||||
// gtt_used/gtt_total bleiben aus der Query — der Strom trägt nur die Auslastung.
|
||||
gpu: query.gpu ? { ...query.gpu, busy_percent: live.gpu ?? query.gpu.busy_percent } : query.gpu,
|
||||
disk: query.disk ? { ...query.disk, percent: live.disk ?? query.disk.percent } : query.disk,
|
||||
temp: { cpu: live.temp_cpu ?? query.temp?.cpu, gpu: live.temp_gpu ?? query.temp?.gpu },
|
||||
uptime_s: live.uptime_s ?? query.uptime_s,
|
||||
}
|
||||
}, [query, live])
|
||||
|
||||
return { sys, hist, error }
|
||||
}
|
||||
@@ -1,11 +1 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function resolveExternalUrl(url?: string): string {
|
||||
if (!url) return ""
|
||||
return url.replace(/127\.0\.0\.1|localhost/g, "192.168.178.151")
|
||||
}
|
||||
export { cn } from "cn"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { flugplanZeit, gbText, laufzeit, seit, vor } from "./zeit"
|
||||
import { rolleVon, temperaturFarbe, versionen } from "./anzeige"
|
||||
import type { Modell } from "./typen"
|
||||
|
||||
// Fester Bezugspunkt: Mittwoch, 23.09.2026, 20:45 Uhr Berliner Zeit.
|
||||
const JETZT = new Date("2026-09-23T20:45:00+02:00")
|
||||
|
||||
describe("flugplanZeit", () => {
|
||||
it("benennt heute, gestern und morgen", () => {
|
||||
expect(flugplanZeit("2026-09-23T03:00:00+02:00", JETZT)).toBe("HEUTE 03:00")
|
||||
expect(flugplanZeit("2026-09-22T23:10:00+02:00", JETZT)).toBe("GESTERN 23:10")
|
||||
expect(flugplanZeit("2026-09-24T03:00:00+02:00", JETZT)).toBe("MORGEN 03:00")
|
||||
})
|
||||
|
||||
it("nimmt für die nächsten Tage den Wochentag", () => {
|
||||
expect(flugplanZeit("2026-09-27T04:30:00+02:00", JETZT)).toBe("SO 04:30")
|
||||
})
|
||||
|
||||
it("rechnet UTC-Zeiten in Berliner Zeit um", () => {
|
||||
expect(flugplanZeit("2026-09-23T05:00:00Z", JETZT)).toBe("HEUTE 07:00")
|
||||
})
|
||||
})
|
||||
|
||||
describe("kleine Helfer", () => {
|
||||
it("seit und vor", () => {
|
||||
expect(seit(new Date("2026-09-07T11:03:00+02:00").getTime() / 1000, JETZT)).toBe("seit 07.09., 11:03")
|
||||
expect(vor(JETZT.getTime() / 1000 - 40, JETZT.getTime())).toBe("vor 40 s")
|
||||
expect(vor(JETZT.getTime() / 1000 - 2 * 86_400, JETZT.getTime())).toBe("vor 2 Tagen")
|
||||
})
|
||||
|
||||
it("laufzeit und gbText", () => {
|
||||
expect(laufzeit(10 * 86_400 + 14 * 3600 + 59)).toEqual([10, 14])
|
||||
expect(gbText(55 * 1024 ** 3)).toBe("55 GB")
|
||||
expect(gbText(1.9 * 1024 ** 4, 1)).toBe("1,9 TB")
|
||||
})
|
||||
|
||||
it("Temperaturfarbe wechselt bei 70 und 85 Grad", () => {
|
||||
expect(temperaturFarbe(37)).toBe("var(--gruen)")
|
||||
expect(temperaturFarbe(70)).toBe("var(--bernstein)")
|
||||
expect(temperaturFarbe(85)).toBe("var(--rot)")
|
||||
})
|
||||
})
|
||||
|
||||
describe("versionen", () => {
|
||||
it("zeigt Motor-Builds mit b und nur echte Neuerungen", () => {
|
||||
expect(versionen("engine", { kind: "engine", installed_build: 11057, latest_build: 11139 })).toEqual({ laeuft: "b11057", neu: "b11139" })
|
||||
expect(versionen("engine", { kind: "engine", installed_build: 11139, latest_build: 11139 })).toEqual({ laeuft: "b11139", neu: null })
|
||||
})
|
||||
|
||||
it("zählt beim Betriebssystem die Pakete", () => {
|
||||
expect(versionen("os", { kind: "os", count: 24 })).toEqual({ laeuft: "Ubuntu", neu: "24 Pakete" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("rolleVon", () => {
|
||||
const m = (aliases: string[]): Modell => ({
|
||||
name: "x", role: null, aliases, ctx: null, ttl: null, filename: "", quant: "", size_bytes: null, spec_active: false, parallel_slots: 1,
|
||||
})
|
||||
it("erkennt Hirn und Coder an den Aliasen", () => {
|
||||
expect(rolleVon(m(["hermes", "fast"]))).toBe("hirn")
|
||||
expect(rolleVon(m(["coder", "heavy"]))).toBe("coder")
|
||||
expect(rolleVon(m(["debugger"]))).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
// Anzeige-Helfer: Uhrzeiten, Dauer, Größen — alles auf Deutsch und in Box-Zeit.
|
||||
|
||||
const ZONE = "Europe/Berlin"
|
||||
|
||||
const uhr = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: ZONE })
|
||||
const tagKurz = new Intl.DateTimeFormat("de-DE", { weekday: "short", timeZone: ZONE })
|
||||
const datumKurz = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", timeZone: ZONE })
|
||||
const tagIso = new Intl.DateTimeFormat("en-CA", { timeZone: ZONE })
|
||||
|
||||
function tagSchluessel(d: Date) {
|
||||
return tagIso.format(d)
|
||||
}
|
||||
|
||||
/** "HEUTE 03:00", "GESTERN 23:10", "SA 08:00" (≤ 6 Tage entfernt), sonst "27.09. 04:30". */
|
||||
export function flugplanZeit(iso: string, jetzt = new Date()): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return "–"
|
||||
const heute = tagSchluessel(jetzt)
|
||||
const gestern = tagSchluessel(new Date(jetzt.getTime() - 86_400_000))
|
||||
const morgen = tagSchluessel(new Date(jetzt.getTime() + 86_400_000))
|
||||
const t = tagSchluessel(d)
|
||||
const zeit = uhr.format(d)
|
||||
if (t === heute) return `HEUTE ${zeit}`
|
||||
if (t === gestern) return `GESTERN ${zeit}`
|
||||
if (t === morgen) return `MORGEN ${zeit}`
|
||||
if (Math.abs(d.getTime() - jetzt.getTime()) < 6.5 * 86_400_000) {
|
||||
return `${tagKurz.format(d).replace(".", "").toUpperCase()} ${zeit}`
|
||||
}
|
||||
// de-DE liefert „27.09.“ schon mit Schlusspunkt.
|
||||
return `${datumKurz.format(d)} ${zeit}`
|
||||
}
|
||||
|
||||
/** Unix-Sekunden → "seit 07.09., 11:03" bzw. "seit heute 07:02". */
|
||||
export function seit(ts: number, jetzt = new Date()): string {
|
||||
const d = new Date(ts * 1000)
|
||||
if (tagSchluessel(d) === tagSchluessel(jetzt)) return `seit heute ${uhr.format(d)}`
|
||||
return `seit ${datumKurz.format(d)}, ${uhr.format(d)}`
|
||||
}
|
||||
|
||||
/** Unix-Sekunden → "vor 40 s", "vor 3 min", "vor 2 h", "vor 5 Tagen". */
|
||||
export function vor(ts: number, jetzt = Date.now()): string {
|
||||
const s = Math.max(0, Math.round(jetzt / 1000 - ts))
|
||||
if (s < 60) return `vor ${s} s`
|
||||
if (s < 3600) return `vor ${Math.round(s / 60)} min`
|
||||
if (s < 86_400) return `vor ${Math.round(s / 3600)} h`
|
||||
const tage = Math.round(s / 86_400)
|
||||
return `vor ${tage} ${tage === 1 ? "Tag" : "Tagen"}`
|
||||
}
|
||||
|
||||
/** Laufzeit in Sekunden → [Tage, Stunden]. */
|
||||
export function laufzeit(s: number | null | undefined): [number, number] | null {
|
||||
if (s == null || s < 0) return null
|
||||
return [Math.floor(s / 86_400), Math.floor((s % 86_400) / 3600)]
|
||||
}
|
||||
|
||||
export const gb = (bytes: number | null | undefined) =>
|
||||
bytes == null ? null : bytes / 1024 ** 3
|
||||
|
||||
export function gbText(bytes: number | null | undefined, stellen = 0): string {
|
||||
const g = gb(bytes)
|
||||
if (g == null) return "–"
|
||||
return g >= 1000 ? `${(g / 1024).toFixed(1).replace(".", ",")} TB` : `${g.toFixed(stellen).replace(".", ",")} GB`
|
||||
}
|
||||
|
||||
export const zahl = (n: number) => n.toLocaleString("de-DE")
|
||||
+10
-28
@@ -1,41 +1,23 @@
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { RouterProvider } from "@tanstack/react-router"
|
||||
import { router } from "./app/router"
|
||||
import { AppErrorBoundary } from "./components/AppErrorBoundary"
|
||||
import { hashUmleiten } from "./lib/hashUmleitung"
|
||||
import { veralteteVersionAbfangen } from "./lib/veralteteVersion"
|
||||
import "./index.css"
|
||||
|
||||
// Beides MUSS vor dem ersten Rendern laufen:
|
||||
// · hashUmleiten — alte Lesezeichen (#models) landen auf dem neuen Pfad (/modelle),
|
||||
// bevor der Router die Adresse zum ersten Mal liest (P2).
|
||||
// · veralteteVersionAbfangen — nach einem Deploy sucht ein offener Tab Nachlade-Chunks
|
||||
// des alten Builds, die es nicht mehr gibt (P1).
|
||||
hashUmleiten()
|
||||
veralteteVersionAbfangen()
|
||||
|
||||
// Zentraler Daten-Layer: Caching, Dedup, Retry, Polling pro Query-Hook.
|
||||
const queryClient = new QueryClient({
|
||||
const abfragen = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5_000,
|
||||
},
|
||||
queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 5_000 },
|
||||
},
|
||||
})
|
||||
|
||||
// AppErrorBoundary bleibt als LETZTES Netz stehen — für Fehler außerhalb jeder Route
|
||||
// (Provider, Router selbst). Innerhalb der Routen fängt seit P2 jede Ansicht für sich
|
||||
// (app/router.tsx → errorComponent), damit ein Absturz nicht die ganze Shell mitnimmt.
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<AppErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AppErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={abfragen}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Boxes,
|
||||
History,
|
||||
Library,
|
||||
Plug,
|
||||
Bot,
|
||||
SquareTerminal,
|
||||
HelpCircle,
|
||||
Lightbulb,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export type ViewId = "dashboard" | "ideen" | "skills" | "models" | "wissen" | "chronik" | "connect" | "agent" | "konsole" | "guide"
|
||||
|
||||
export type NavGroup = "Operativ" | "Wissen" | "Werkzeuge" | "Wartung"
|
||||
|
||||
export interface NavItem {
|
||||
id: ViewId
|
||||
/** Echter URL-Pfad (v3-Umbau P2). Vorher war die Ansicht nur ein Hash (#models) —
|
||||
* teilbar auf View-Ebene, aber ohne Platz für den Zustand DARUNTER. */
|
||||
pfad: string
|
||||
label: string
|
||||
hint: string
|
||||
icon: LucideIcon
|
||||
group: NavGroup
|
||||
}
|
||||
|
||||
// Eine Quelle der Wahrheit für Sidebar UND Command-Palette.
|
||||
export const NAV: NavItem[] = [
|
||||
{ id: "dashboard", pfad: "/cockpit", label: "Cockpit", hint: "Deine Box auf einen Blick", icon: LayoutDashboard, group: "Operativ" },
|
||||
// Der Haupteingang: Idee → Konzept → Repo → IDE. Steht bewusst direkt unter dem
|
||||
// Cockpit — hier fängt jede Arbeit an (eigene Seite seit 25.07.2026).
|
||||
// Hinweis bewusst kurz: die App zeigt ihn oben in Großbuchstaben, direkt über der
|
||||
// Seitenüberschrift „Ideen" — ein langer Satz stand dort dreifach gedoppelt.
|
||||
{ id: "ideen", pfad: "/ideen", label: "Ideen", hint: "Schreib hin, was entstehen soll", icon: Lightbulb, group: "Operativ" },
|
||||
{ id: "skills", pfad: "/skills", label: "Skills & Jobs", hint: "Autonome Skills manuell auslösen", icon: Bot, group: "Operativ" },
|
||||
{ id: "chronik", pfad: "/chronik", label: "Chronik", hint: "Was die Box von allein getan hat + Zeitmaschine", icon: History, group: "Operativ" },
|
||||
{ id: "wissen", pfad: "/wissen", label: "Wissen", hint: "Lucys Wissens-Vault (Traum-Notizen)", icon: Library, group: "Wissen" },
|
||||
{ id: "connect", pfad: "/verbinden", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug, group: "Werkzeuge" },
|
||||
{ id: "konsole", pfad: "/konsole", label: "Konsole", hint: "Direkte Box-Shell (SSH-artig)", icon: SquareTerminal, group: "Werkzeuge" },
|
||||
{ id: "guide", pfad: "/anleitung", label: "Anleitung", hint: "Einrichten & Vibe-Coding", icon: HelpCircle, group: "Werkzeuge" },
|
||||
{ id: "models", pfad: "/modelle", label: "Modelle", hint: "Speicher, laden & Rollen", icon: Boxes, group: "Wartung" },
|
||||
{ id: "agent", pfad: "/agent", label: "Hermes", hint: "Agent-Status & Verdrahtung", icon: Bot, group: "Wartung" },
|
||||
]
|
||||
|
||||
/** Nav-Eintrag zu einem Pfad — für Kopfzeile und aktive Markierung.
|
||||
* Längster Treffer gewinnt, damit /ideen/<id> beim Eintrag „Ideen" landet. */
|
||||
export function navFuerPfad(pfad: string): NavItem | undefined {
|
||||
return [...NAV]
|
||||
.sort((a, b) => b.pfad.length - a.pfad.length)
|
||||
.find((n) => pfad === n.pfad || pfad.startsWith(n.pfad + "/"))
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
import { useState } from "react"
|
||||
import { Bot, ExternalLink, Activity, Cpu, Wrench, Monitor, Shield, Check, X } from "lucide-react"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { Werkzeugverlauf } from "@/features/agent/Werkzeugverlauf"
|
||||
import { api } from "@/lib/api"
|
||||
import { useAgentStatus, useModels, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { cn, resolveExternalUrl } from "@/lib/utils"
|
||||
|
||||
// Lane-/Gateway-Aliase, auf die das Hirn zeigen kann (Lane-Sprache statt altem „auto").
|
||||
const ALIASES = ["chat", "coding", "fast", "heavy"]
|
||||
|
||||
export function AgentView() {
|
||||
const navigate = useNavigate()
|
||||
const { data: s, error: sErr } = useAgentStatus()
|
||||
const { data: modelsData } = useModels()
|
||||
const { showAlert, dialogElement } = useDialog()
|
||||
const qc = useQueryClient()
|
||||
const error = sErr ? String(sErr) : ""
|
||||
const [showBrainSelect, setShowBrainSelect] = useState(false)
|
||||
|
||||
const installed = modelsData?.models ?? []
|
||||
|
||||
// Adressen aus dem Status-Payload statt hartcodiert — die UI zeigt, was konfiguriert IST.
|
||||
const port = (url?: string) => url?.match(/:(\d+)/)?.[1] ?? ""
|
||||
const gwPort = port(s?.gateway_url) || "8642"
|
||||
const pcUrl = s?.pc_executor_url ?? ""
|
||||
const pcHost = pcUrl.replace(/^https?:\/\//, "")
|
||||
const pcPort = port(pcUrl) || "7777"
|
||||
|
||||
// Alias setzen → roher Config-Write (model.default zeigt aufs Alias).
|
||||
async function setBrainAlias(alias: string) {
|
||||
try {
|
||||
await api("/api/agent/brain", { method: "POST", body: JSON.stringify({ model: alias }) })
|
||||
showAlert("Erledigt", `Hermes-Hirn zeigt jetzt auf das Alias '${alias}'. Der Gateway-Dienst wurde neu gestartet.`)
|
||||
qc.invalidateQueries({ queryKey: qk.agentStatus })
|
||||
setShowBrainSelect(false)
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", `Wechsel fehlgeschlagen: ${e.message || e}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Installiertes Modell → warm-bewusst (hermes-Alias + brains-Gruppe + Config + Restart).
|
||||
async function setBrainWarm(modelName: string) {
|
||||
try {
|
||||
await api("/api/agent/brain/set", { method: "POST", body: JSON.stringify({ model_id: modelName }) })
|
||||
showAlert("Erledigt", "Agent-Hirn gewechselt (warm gehalten). Gateway wurde neugestartet.")
|
||||
qc.invalidateQueries({ queryKey: qk.agentStatus })
|
||||
qc.invalidateQueries({ queryKey: qk.models })
|
||||
setShowBrainSelect(false)
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", `Wechsel fehlgeschlagen: ${e.message || e}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
|
||||
<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">
|
||||
Hermes Agenten-Cockpit
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Hermes ist ein autonomer Agent, der im Hintergrund der Box läuft (Port <code>:{gwPort}</code>) und seine eigene UI besitzt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{s?.hermes_ui_url && (
|
||||
<a
|
||||
href={resolveExternalUrl(s.hermes_ui_url)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className={cn(
|
||||
"flex h-9 px-4 items-center gap-1.5 text-xs font-semibold rounded-lg transition-all cursor-pointer shadow-md shrink-0 self-start",
|
||||
s.hermes_ui_reachable
|
||||
? "bg-primary text-primary-foreground hover:opacity-90 shadow-primary/10"
|
||||
: "border border-border/60 text-muted-foreground bg-background/20"
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
<span>Hermes-GUI öffnen</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400 font-mono">
|
||||
Status nicht lesbar ({error}).
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s && (
|
||||
<div className="space-y-6">
|
||||
{/* Agent-Fluss: Terminal → Gateway → Hirn / Verdrahtung / PC (ruhig, statt SVG-Graph) */}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row lg:items-stretch gap-3">
|
||||
{/* Quelle: Terminal/Clients */}
|
||||
<button
|
||||
onClick={() => s.hermes_ui_reachable && window.open(resolveExternalUrl(s.hermes_ui_url), "_blank")}
|
||||
title={s.hermes_ui_reachable ? "Hermes-GUI öffnen" : "Desktop-Gateway offline"}
|
||||
className="lg:w-40 shrink-0 rounded-xl border border-border/60 bg-background/30 p-3 text-left hover:border-primary/40 transition-all cursor-pointer flex flex-col justify-center"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Activity className={cn("h-3.5 w-3.5", s.hermes_ui_reachable ? "text-emerald-400" : "text-amber-500")} />
|
||||
<span className="text-xs font-semibold text-foreground">Desktop & GUI</span>
|
||||
<span className={cn("ml-auto h-1.5 w-1.5 rounded-full animate-pulse", s.hermes_ui_reachable ? "bg-emerald-500" : "bg-amber-500")} />
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-mono mt-0.5">Hermes Desktop · Clients</span>
|
||||
</button>
|
||||
|
||||
<div className="hidden lg:flex items-center text-muted-foreground text-lg select-none">→</div>
|
||||
|
||||
{/* Gateway + nachgelagerte Module */}
|
||||
<div className="flex-1 flex flex-col gap-2.5">
|
||||
{/* Agent Gateway */}
|
||||
<div className="rounded-xl border border-primary/40 bg-primary/5 px-3.5 py-2.5 flex items-center gap-2">
|
||||
<Bot className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="text-xs font-semibold text-primary">Agent Gateway</span>
|
||||
<span className="text-[11px] font-mono text-muted-foreground">:{gwPort}</span>
|
||||
<span className={cn(
|
||||
"ml-auto inline-flex items-center gap-1.5 text-[10px] font-bold uppercase font-mono px-2 py-0.5 rounded-full border",
|
||||
s.gateway_reachable
|
||||
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
|
||||
: "bg-red-500/10 text-red-400 border-red-500/20"
|
||||
)}>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", s.gateway_reachable ? "bg-emerald-500 animate-pulse" : "bg-red-500")} />
|
||||
{s.gateway_reachable ? "online" : "offline"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Drei Module: Hirn (klickbar), Verdrahtung, PC */}
|
||||
<div className="grid gap-2.5 sm:grid-cols-3">
|
||||
<button
|
||||
onClick={() => setShowBrainSelect(true)}
|
||||
className="rounded-xl border border-border/60 bg-background/30 p-3 text-left hover:border-primary/45 transition-all cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Cpu className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">Hirn</span>
|
||||
<span className="ml-auto text-[9px] text-primary/70 font-bold uppercase">wechseln →</span>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono font-medium text-foreground truncate mt-1" title={s.brain_model}>
|
||||
{s.brain_model || "chat"}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="rounded-xl border border-border/60 bg-background/30 p-3">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Wrench className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">Verdrahtung</span>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono mt-1 flex flex-wrap gap-x-2 text-muted-foreground">
|
||||
<span>Config: {s.has_config ? "✓" : "—"}</span>
|
||||
<span>Skills: {s.has_skills ? "✓" : "—"}</span>
|
||||
<span>Memory: {s.has_memories ? "✓" : "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/60 bg-background/30 p-3">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Monitor className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">PC</span>
|
||||
<span className={cn("ml-auto h-1.5 w-1.5 rounded-full", s.pc_executor_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
|
||||
</div>
|
||||
<div className={cn("text-[11px] font-mono mt-1", s.pc_executor_reachable ? "text-emerald-400" : "text-muted-foreground")}>
|
||||
:{pcPort} {s.pc_executor_reachable ? "verbunden" : "offline"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PC Executor Status Card */}
|
||||
<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" />
|
||||
<h3 className="text-sm font-bold uppercase tracking-wider text-foreground">PC Verbindung</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("h-2 w-2 rounded-full", s.pc_executor_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
|
||||
<span className="text-xs font-semibold text-foreground">
|
||||
{s.pc_executor_reachable ? "Erreichbar" : "Nicht verbunden"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 text-xs text-muted-foreground leading-relaxed text-left">
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
Der <strong className="text-foreground">Hermes PC Executor</strong> läuft auf <code className="text-foreground font-mono px-1 py-0.5 rounded bg-muted/40 border border-border/30">{pcHost || "deinem Windows-PC"}</code> und ermöglicht Hermes den direkten Zugriff auf deinen Windows-PC — Shell-Befehle, Screenshots, Tastatureingaben.
|
||||
</p>
|
||||
<p>
|
||||
Hermes erreicht den PC über den MCP-Server <code className="text-foreground font-mono px-1 py-0.5 rounded bg-muted/40 border border-border/30">hermes-pc-control</code>, der automatisch beim Gateway-Start verbunden wird.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{s.pc_executor_reachable ? (
|
||||
<div className="p-3.5 bg-emerald-500/5 border border-emerald-500/20 rounded-xl space-y-1">
|
||||
<div className="text-emerald-400 font-semibold text-xs flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
PC Executor verbunden
|
||||
</div>
|
||||
<p className="text-[10px]">
|
||||
Hermes kann jetzt per Telegram oder Terminal Befehle auf TobisNicerPC ausführen. Nutze <code className="text-foreground">pc_shell</code>, <code className="text-foreground">pc_screenshot</code> oder <code className="text-foreground">pc_open</code>.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-bold text-foreground">PC Executor starten</h4>
|
||||
<p>Starte <code className="text-foreground font-mono">start.bat</code> im Ordner <code className="text-foreground font-mono">client\hermes-pc\</code> auf dem Windows-PC und lasse das Fenster offen.</p>
|
||||
<div className="p-3 bg-background/25 rounded-xl border border-border/30 font-mono text-[9px] text-cyan-300">
|
||||
client\hermes-pc\start.bat
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diagnostic Details if Offline */}
|
||||
{!s.gateway_reachable && (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground space-y-3 leading-relaxed">
|
||||
<p>
|
||||
Der Hermes-Dienst ist auf der Box derzeit offline oder kann sich nicht mit dem lokalen llama-swap verbinden.
|
||||
</p>
|
||||
<p>
|
||||
Am schnellsten: In der System-Schublade unter <strong>Dienste</strong> den Eintrag{" "}
|
||||
<strong>Hermes-Gateway</strong> mit einem Klick neu starten (dort gibt es auch die Logs dazu).
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate({ to: ".", search: (a) => ({ ...a, system: "wartung" as const }) })}
|
||||
className="flex h-9 items-center gap-1.5 rounded-lg border border-primary/40 bg-primary/10 px-4 text-[11px] font-bold uppercase tracking-wide text-primary transition-all hover:bg-primary/20 cursor-pointer">
|
||||
<Wrench className="h-3.5 w-3.5" /> Dienste-Übersicht öffnen
|
||||
</button>
|
||||
<p>
|
||||
Weitere Einrichtungsdetails (Brain-Konfiguration, MCP-Kopplungen, SSH-Tunneling und Such-Engines) findest du in der Dokumentationsdatei:
|
||||
<code className="ml-1 px-1.5 py-0.5 rounded bg-muted/40 text-foreground border border-border/30">docs/HERMES_SETUP.md</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Brain Selection Modal — eine Stelle für den Hirn-Wechsel (warm-bewusst + Alias) */}
|
||||
{s && showBrainSelect && (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 overscroll-contain" role="dialog" aria-modal="true" aria-label="Hermes-Hirn konfigurieren">
|
||||
<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-Hirn 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>
|
||||
|
||||
{/* Lane / Gateway-Alias */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Lane / Gateway-Alias</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ALIASES.map((a) => {
|
||||
const cur = s.brain_model === a
|
||||
return (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => setBrainAlias(a)}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-lg text-xs font-mono font-semibold border transition-all cursor-pointer flex items-center gap-1.5",
|
||||
cur ? "border-primary/40 bg-primary/10 text-primary" : "border-border/30 bg-background/20 text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{a}{cur && <Check className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground/70">
|
||||
Schlank & flexibel: das Hirn folgt dem Lane-Routing (z.B. <code className="text-primary">chat</code> = fast↔heavy).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Installierte Modelle (warm-bewusst) */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Installiertes Modell (warm gehalten)</div>
|
||||
<div className="space-y-1.5 max-h-56 overflow-y-auto pr-1">
|
||||
{installed.map((m) => {
|
||||
const isBrain = m.role === "hermes"
|
||||
return (
|
||||
<button
|
||||
key={m.name}
|
||||
onClick={() => !isBrain && setBrainWarm(m.name)}
|
||||
disabled={isBrain || m.incomplete}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2.5 rounded-lg text-xs flex items-center justify-between font-mono border transition-all",
|
||||
isBrain ? "border-indigo-500/40 bg-indigo-500/10 text-indigo-300"
|
||||
: m.incomplete ? "border-border/20 bg-background/10 text-muted-foreground/50 cursor-not-allowed"
|
||||
: "border-border/30 bg-background/20 text-foreground hover:bg-accent cursor-pointer"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-semibold truncate max-w-[280px]">{m.name.split("/").pop()?.replace(/\.gguf$/i, "")}</span>
|
||||
<span className="text-[9px] text-muted-foreground mt-0.5">
|
||||
{m.capabilities.params_b ? `${m.capabilities.params_b}B` : "?"} · {m.capabilities.tools !== "no" ? "Tools ✓" : "ohne Tools"}
|
||||
{m.role && ` · Rolle: ${m.role}`}
|
||||
</span>
|
||||
</div>
|
||||
{isBrain
|
||||
? <span className="text-[9px] font-bold uppercase text-indigo-300 shrink-0 flex items-center gap-1"><Check className="h-3.5 w-3.5" /> Aktiv</span>
|
||||
: <span className="text-[9px] font-bold uppercase text-primary/70 shrink-0">Warm setzen →</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] text-amber-400/80 flex items-start gap-1.5 border-t border-border/20 pt-2.5">
|
||||
<span>💡</span>
|
||||
<span>Für einen <strong>Agenten</strong> sind Hermes-Modelle (natives Tool-Calling) meist die robustere Wahl. Neues Modell? Erst über „Modell-Manager → Modelle finden" laden.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Werkzeug-Verlauf (v3-Umbau P6): was Lucy tatsaechlich getan hat, aus ihrem Log. */}
|
||||
<Werkzeugverlauf />
|
||||
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { AlarmClock, Archive, Bell, HeartPulse, History, Inbox, Loader2, MessageCircle, Moon, Radar, RotateCcw, Search, ShieldAlert, Sparkles, type LucideIcon } from "lucide-react"
|
||||
import { api, type ChronikItem, type Snapshot } from "@/lib/api"
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
import { Dichteleiste } from "@/features/chronik/Dichteleiste"
|
||||
import { useChronik, useZeitmaschine, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Chronik = die lesbare Geschichte dessen, was die Box von allein getan und gemeldet hat
|
||||
// (Quelle: Melde-Briefkasten). Daneben die Zeitmaschine: Snapshots ansehen und per Klick
|
||||
// zu einem Stand zurückkehren — mit hartem Bestätigungsdialog und Pre-Restore-Sicherung.
|
||||
|
||||
const SOURCE_UI: Record<string, { icon: LucideIcon; label: string; cls: string }> = {
|
||||
sentry: { icon: HeartPulse, label: "Health-Wächter", cls: "text-red-300 bg-red-500/10 border-red-500/25" },
|
||||
alarm: { icon: ShieldAlert, label: "Alarm", cls: "text-red-200 bg-red-500/15 border-red-500/40" },
|
||||
notify: { icon: Bell, label: "Meldung", cls: "text-sky-300 bg-sky-500/10 border-sky-500/25" },
|
||||
"chef-gutachter": { icon: Moon, label: "Morgenlage", cls: "text-violet-300 bg-violet-500/10 border-violet-500/25" },
|
||||
radar: { icon: Radar, label: "Radar", cls: "text-teal-300 bg-teal-500/10 border-teal-500/25" },
|
||||
auftragsbuch: { icon: Inbox, label: "Auftragsbuch", cls: "text-primary bg-primary/10 border-primary/25" },
|
||||
zeitmaschine: { icon: RotateCcw, label: "Zeitmaschine", cls: "text-amber-300 bg-amber-500/10 border-amber-500/25" },
|
||||
reminder: { icon: AlarmClock, label: "Erinnerung", cls: "text-amber-300 bg-amber-500/10 border-amber-500/25" },
|
||||
traum: { icon: Sparkles, label: "Traum", cls: "text-violet-300 bg-violet-500/10 border-violet-500/25" },
|
||||
}
|
||||
const SOURCE_FALLBACK = { icon: MessageCircle, label: "Box", cls: "text-muted-foreground bg-background/40 border-border/50" }
|
||||
|
||||
function dayKey(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long" })
|
||||
}
|
||||
|
||||
export function ChronikView() {
|
||||
const { data: alle = [], isLoading } = useChronik()
|
||||
const [limitPerDay, setLimitPerDay] = useState(true)
|
||||
|
||||
// v3-Umbau P6: Das Suchwort steht in der URL (/chronik?q=…). So landet der
|
||||
// „Finden"-Eintrag der Befehlspalette hier — und ein Treffer ist teilbar.
|
||||
const navigate = useNavigate()
|
||||
const { q = "" } = useSearch({ from: "/chronik" })
|
||||
const suchen = (wert: string) =>
|
||||
navigate({ to: "/chronik", search: wert ? { q: wert } : {}, replace: true })
|
||||
|
||||
const items = useMemo(() => {
|
||||
const ql = q.trim().toLowerCase()
|
||||
if (!ql) return alle
|
||||
return alle.filter((it) =>
|
||||
it.subject.toLowerCase().includes(ql)
|
||||
|| it.text.toLowerCase().includes(ql)
|
||||
|| it.source.toLowerCase().includes(ql))
|
||||
}, [alle, q])
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const g: { day: string; items: ChronikItem[] }[] = []
|
||||
for (const it of items) {
|
||||
const day = dayKey(it.ts)
|
||||
const last = g[g.length - 1]
|
||||
if (last && last.day === day) last.items.push(it)
|
||||
else g.push({ day, items: [it] })
|
||||
}
|
||||
return g
|
||||
}, [items])
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<div>
|
||||
<h1 className="bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent">
|
||||
Chronik
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Was deine Box von allein getan und gemeldet hat — und die Zeitmaschine, falls du zu einem früheren Stand zurück willst.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Zeitmaschine />
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||
<History className="h-3.5 w-3.5" /> Verlauf
|
||||
{q && <span className="normal-case text-primary">· {items.length} von {alle.length}</span>}
|
||||
</p>
|
||||
<div className="relative ml-auto w-full sm:w-64">
|
||||
<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" aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => suchen(e.target.value)}
|
||||
placeholder="Verlauf durchsuchen …"
|
||||
aria-label="Chronik durchsuchen"
|
||||
className="h-8 w-full rounded-lg border border-border/60 bg-background/40 pl-8 pr-3 text-xs text-foreground outline-none transition-colors focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zeigt, WANN die Box aktiv war — bevor man liest, WAS sie getan hat (§4.5). */}
|
||||
{!isLoading && items.length > 0 && <Dichteleiste items={items} aktiv={!!q} />}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-border/60 bg-card/30 p-6 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Chronik wird geladen …
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && items.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 text-center text-xs text-muted-foreground">
|
||||
{q
|
||||
? <>Nichts zu „{q}" im Verlauf. <button onClick={() => suchen("")} className="cursor-pointer text-primary underline underline-offset-2">Filter aufheben</button></>
|
||||
: "Noch keine Einträge. Sobald die Box etwas von allein tut oder meldet (Updates, Wächter, Träume), steht es hier."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{grouped.map(({ day, items: dayItems }) => (
|
||||
<div key={day}>
|
||||
<p className="mb-2 text-xs font-bold text-foreground/70">{day}</p>
|
||||
<div className="space-y-2 border-l-2 border-border/40 pl-4">
|
||||
{(limitPerDay ? dayItems.slice(0, 8) : dayItems).map((it) => {
|
||||
const ui = SOURCE_UI[it.source] ?? SOURCE_FALLBACK
|
||||
const Icon = ui.icon
|
||||
return (
|
||||
<div key={it.id} className="relative rounded-xl border border-border/50 bg-card/40 p-3 backdrop-blur-sm">
|
||||
<span className="absolute -left-[23px] top-4 h-2.5 w-2.5 rounded-full bg-border ring-4 ring-background/60" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={cn("flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[10px] font-bold", ui.cls)}>
|
||||
<Icon className="h-3 w-3" /> {ui.label}
|
||||
</span>
|
||||
{it.subject && <span className="text-[10px] font-semibold text-muted-foreground">{it.subject}</span>}
|
||||
<span className="ml-auto font-mono text-[10px] text-muted-foreground/60">
|
||||
{new Date(it.ts * 1000).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-foreground/85">{it.text}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{limitPerDay && dayItems.length > 8 && (
|
||||
<button onClick={() => setLimitPerDay(false)} className="text-[11px] text-muted-foreground underline hover:text-foreground">
|
||||
{dayItems.length - 8} weitere anzeigen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Zeitmaschine: Snapshots + Ein-Klick-Restore ───────────────────────────────
|
||||
function Zeitmaschine() {
|
||||
const { data } = useZeitmaschine()
|
||||
const qc = useQueryClient()
|
||||
const { showAlert, showConfirm, dialogElement } = useDialog()
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [backupBusy, setBackupBusy] = useState(false)
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
|
||||
const backups = data?.backups ?? []
|
||||
const shown = showAll ? backups : backups.slice(0, 4)
|
||||
|
||||
function fmtSnap(s: Snapshot): string {
|
||||
// Zeitstempel aus backup.sh ist kompakt (20260708-181136) — lesbar machen;
|
||||
// tolerant auch für Varianten mit Trennzeichen (2026-07-08T18-11).
|
||||
const m = s.snapshot.match(/^(\d{4})-?(\d{2})-?(\d{2})[T_-](\d{2})[-:]?(\d{2})/)
|
||||
if (!m) return s.snapshot
|
||||
return `${m[3]}.${m[2]}.${m[1]}, ${m[4]}:${m[5]} Uhr`
|
||||
}
|
||||
|
||||
async function restore(s: Snapshot) {
|
||||
setBusy(s.file)
|
||||
try {
|
||||
await api("/api/zeitmaschine/restore", { method: "POST", body: JSON.stringify({ file: s.file }) })
|
||||
showAlert("Zeitmaschine läuft",
|
||||
"Die Wiederherstellung wurde gestartet. Die Dienste starten gleich neu — die Zentrale ist kurz weg und meldet sich von selbst zurück. Vorher wurde automatisch ein Sicherheits-Backup des jetzigen Zustands gemacht.")
|
||||
} catch (e: any) {
|
||||
showAlert("Start fehlgeschlagen", String(e?.message || e))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function backupNow() {
|
||||
setBackupBusy(true)
|
||||
try {
|
||||
const r = await api<{ ok: boolean; snapshot?: string; error?: string }>("/api/system/backup", { method: "POST" })
|
||||
if (r.ok) showAlert("Gesichert", `Snapshot ${r.snapshot} wurde angelegt.`)
|
||||
else showAlert("Backup fehlgeschlagen", r.error || "Unbekannter Fehler")
|
||||
qc.invalidateQueries({ queryKey: qk.zeitmaschine })
|
||||
} catch (e: any) {
|
||||
showAlert("Backup fehlgeschlagen", String(e?.message || e))
|
||||
} finally {
|
||||
setBackupBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||
<Archive className="h-3.5 w-3.5" /> Zeitmaschine — gesicherte Stände
|
||||
</p>
|
||||
<button onClick={backupNow} disabled={backupBusy || !data?.available}
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-card/45 px-2.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground hover:border-primary/40 transition-all cursor-pointer disabled:opacity-50">
|
||||
{backupBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Archive className="h-3.5 w-3.5" />} Jetzt sichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{data && !data.available && (
|
||||
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-xs text-amber-300">
|
||||
Die Snapshots liegen auf der Box — im lokalen Dev-Modus nicht verfügbar.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.available && backups.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-5 text-center text-xs text-muted-foreground">
|
||||
Noch keine Snapshots. Das nächtliche Backup (03:30) legt sie automatisch an — oder „Jetzt sichern".
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shown.length > 0 && (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{shown.map((s, idx) => (
|
||||
<div key={s.file} className="flex items-center justify-between gap-3 rounded-xl border border-border/50 bg-card/40 p-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-foreground">
|
||||
{fmtSnap(s)} {idx === 0 && <span className="ml-1 rounded bg-emerald-500/10 px-1.5 py-0.5 text-[9px] font-bold text-emerald-300">neuester</span>}
|
||||
</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground/70 truncate" title={s.file}>{s.size_mb.toLocaleString("de-DE")} MB · {s.file}</p>
|
||||
</div>
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
showConfirm("Wirklich zu diesem Stand zurück?",
|
||||
`Die Box wird auf den Snapshot vom ${fmtSnap(s)} zurückgesetzt (Gedächtnis, Hermes-Config, Modell-Router). Der JETZIGE Zustand wird vorher automatisch gesichert — der Schritt ist also umkehrbar. Die Dienste starten neu, die Zentrale ist kurz weg.`,
|
||||
() => restore(s))}
|
||||
className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-2.5 text-[10px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50">
|
||||
{busy === s.file ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCcw className="h-3 w-3" />} Zurück
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{backups.length > 4 && !showAll && (
|
||||
<button onClick={() => setShowAll(true)} className="text-[11px] text-muted-foreground underline hover:text-foreground">
|
||||
Alle {backups.length} Snapshots anzeigen
|
||||
</button>
|
||||
)}
|
||||
{dialogElement}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Check, Copy, Cpu, Brain, Globe, Laptop, ArrowRight,
|
||||
Info, CircleCheck, CircleX, Loader2, ChevronDown, ChevronRight,
|
||||
} from "lucide-react"
|
||||
import { useConnect, useConnectHealth } from "@/lib/queries"
|
||||
import type { ConnectTool, ConnectLine } from "@/lib/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Dateiziel je Tool — nur Hinweis im Editor-Kopf, keine Logik.
|
||||
const FILE_NAMES: Record<string, string> = {
|
||||
hermes_desktop: "Hermes Desktop: Browser-Login",
|
||||
kilo: "Kilo: Einstellungen → API Provider",
|
||||
claude_code: "env / Übersetzer",
|
||||
}
|
||||
|
||||
function StatusBadge({ line, loading }: { line?: ConnectLine; loading: boolean }) {
|
||||
if (loading || !line) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> prüfe…
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return line.ok ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-400">
|
||||
<CircleCheck className="h-3 w-3" /> {line.detail}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-red-400">
|
||||
<CircleX className="h-3 w-3" /> {line.detail}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Schwarzes „Editor-Fenster" mit Ampel-Dots + Kopieren-Knopf.
|
||||
function CodeWindow({
|
||||
tool, fileName, accent, copied, onCopy,
|
||||
}: {
|
||||
tool: ConnectTool
|
||||
fileName: string
|
||||
accent: "teal" | "violet"
|
||||
copied: boolean
|
||||
onCopy: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col border border-border/60 bg-black/60 rounded-2xl overflow-hidden shadow-2xl">
|
||||
<div className="flex h-11 items-center justify-between px-4 border-b border-border/40 bg-black/40 shrink-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-3 w-3 rounded-full bg-red-500/80" />
|
||||
<span className="h-3 w-3 rounded-full bg-amber-500/80" />
|
||||
<span className="h-3 w-3 rounded-full bg-emerald-500/80" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-mono text-muted-foreground/75 bg-background/30 px-3 py-1 rounded-md border border-border/20">
|
||||
<span>{fileName}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
className="flex h-7 px-2.5 items-center gap-1.5 rounded-lg border border-border/40 bg-background/30 text-[10px] font-semibold text-muted-foreground hover:text-foreground hover:bg-background/60 transition-all cursor-pointer"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-emerald-400" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
<span>{copied ? "Kopiert" : "Kopieren"}</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre className={cn(
|
||||
"p-5 overflow-x-auto text-xs font-mono whitespace-pre leading-relaxed scrollbar-thin select-text bg-black/10",
|
||||
accent === "teal" ? "text-cyan-200/90" : "text-violet-200/90",
|
||||
)}>
|
||||
<code>{tool.snippet}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Die Box-Adresse kennt der Browser schon — er ist ja über sie verbunden. Nur im
|
||||
// Dev-Modus (localhost + Vite-Proxy) greift der bekannte LAN-Fallback.
|
||||
function defaultHost(): string {
|
||||
const h = window.location.hostname
|
||||
if (h && h !== "localhost" && h !== "127.0.0.1") return h
|
||||
return "192.168.178.151"
|
||||
}
|
||||
|
||||
export function ConnectView() {
|
||||
const [host, setHost] = useState(localStorage.getItem("mc_host") || defaultHost())
|
||||
// Legacy-Tools (roher /v1-Zugang) leben im Ausklapper — Hermes Desktop ist der Hauptweg.
|
||||
const [active, setActive] = useState("kilo")
|
||||
const [sonstigeOffen, setSonstigeOffen] = useState(false)
|
||||
const [copied, setCopied] = useState<"desktop" | "model" | null>(null)
|
||||
|
||||
const params = new URLSearchParams({ host })
|
||||
const { data, error: dataErr } = useConnect(params.toString())
|
||||
const { data: health, isLoading: healthLoading } = useConnectHealth()
|
||||
const error = dataErr ? String(dataErr) : ""
|
||||
|
||||
function saveHost(v: string) {
|
||||
setHost(v)
|
||||
if (v) localStorage.setItem("mc_host", v)
|
||||
}
|
||||
|
||||
const desktop = data?.tools["hermes_desktop"]
|
||||
const tool = data?.tools[active]
|
||||
|
||||
async function copy(which: "desktop" | "model", snippet?: string) {
|
||||
if (!snippet) return
|
||||
await navigator.clipboard.writeText(snippet)
|
||||
setCopied(which)
|
||||
setTimeout(() => setCopied(null), 1500)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<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">
|
||||
Verbindung & Integration
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="text-foreground">Hermes Desktop</span> ist die Tür zur Box — einmal verbinden, fertig.
|
||||
Für Spezialfälle gibt es darunter den rohen Gateway-Zugang ( /v1) für sonstige Tools.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hero: die zwei Leitungen */}
|
||||
<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">
|
||||
<Laptop className="h-7 w-7 mx-auto text-muted-foreground" />
|
||||
<div className="mt-2 text-sm font-semibold text-foreground">Dein lokaler Agent</div>
|
||||
<div className="text-[11px] text-muted-foreground">Hermes Desktop · Kilo Code · Claude Code …</div>
|
||||
</div>
|
||||
|
||||
{/* Zwei Leitungen */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{/* Leitung 1 — Modell */}
|
||||
<div className="flex items-center gap-3">
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 rounded-xl border border-primary/25 bg-primary/5 px-3.5 py-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-primary">
|
||||
<Cpu className="h-3.5 w-3.5" /> Leitung 1 — Modell
|
||||
</div>
|
||||
<StatusBadge line={health?.gateway} loading={healthLoading} />
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] font-mono text-primary/80">
|
||||
Gateway · :9001/v1 · Modelle coder / heavy / vision
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Leitung 2 — Gedächtnis */}
|
||||
<div className="flex items-center gap-3">
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 rounded-xl border border-violet-500/25 bg-violet-500/5 px-3.5 py-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-violet-300">
|
||||
<Brain className="h-3.5 w-3.5" /> Leitung 2 — Gedächtnis
|
||||
</div>
|
||||
<StatusBadge line={health?.memory} loading={healthLoading} />
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] font-mono text-violet-300/80">
|
||||
Hermes-nativ · im Agenten · nichts einzurichten
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Leitung 3 — Desktop-Gateway */}
|
||||
<div className="flex items-center gap-3">
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 rounded-xl border border-emerald-500/25 bg-emerald-500/5 px-3.5 py-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-emerald-300">
|
||||
<Laptop className="h-3.5 w-3.5" /> Leitung 3 — Desktop-Gateway
|
||||
</div>
|
||||
<StatusBadge line={health?.desktop_gateway} loading={healthLoading} />
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] font-mono text-emerald-300/80">
|
||||
Desktop-Gateway · :9119 · API-Status
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3.5 text-[11px] text-muted-foreground leading-relaxed">
|
||||
Der Gateway liefert <span className="text-foreground">nur das LLM</span>. Das Gedächtnis führt
|
||||
<span className="text-foreground"> Hermes selbst</span> (nichts einzurichten — die Kachel zeigt nur, ob es lebt), und das
|
||||
Desktop-Gateway bietet die Agenten-Schnittstelle. IDEs wählen die <span className="text-foreground">echten Modelle</span>
|
||||
{" "}(coder · heavy · vision); daneben gibt es die virtuellen Lanes <span className="text-foreground">chat</span> und
|
||||
{" "}<span className="text-foreground">coding</span>, die je Anfrage selbst das passende Modell wählen (Alltags-Chat, simple Tools).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Box-Adresse — vorbefüllt aus der Browser-URL; anfassen nur, wenn sich die Box-IP ändert. */}
|
||||
<div className="p-5 mc-card">
|
||||
<div className="space-y-1.5 max-w-md">
|
||||
<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
|
||||
<span className="normal-case font-semibold tracking-normal text-muted-foreground/60">— vorbefüllt aus der Adresse dieser Seite</span>
|
||||
</label>
|
||||
<input
|
||||
value={host}
|
||||
onChange={(e) => saveHost(e.target.value)}
|
||||
aria-label="Box LAN IP-Adresse"
|
||||
spellCheck={false}
|
||||
placeholder="z.B. 192.168.178.151"
|
||||
className="w-full h-9 rounded-lg border border-border/60 bg-background/40 px-3 font-mono text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground/70">
|
||||
Wird in alle Snippets eingebacken. Ändern musst du sie nur, wenn die Box eine neue IP bekommt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">
|
||||
Fehler beim Generieren der Snippets: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<div className="space-y-5">
|
||||
{/* Hauptweg — Hermes Desktop anbinden */}
|
||||
{desktop && (
|
||||
<div className="space-y-3 rounded-2xl border border-border/60 border-t-2 border-t-emerald-500/70 bg-card/30 p-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-space font-bold text-foreground">
|
||||
<Laptop className="h-4 w-4 text-emerald-400" />
|
||||
Hermes Desktop anbinden
|
||||
<span className="text-[10px] font-medium text-emerald-400/90 normal-case">der Hauptweg</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">
|
||||
Die Desktop-App dockt remote an die Box an — volle Agent-Oberfläche, Projekte, Review, Gedächtnis inklusive.
|
||||
</p>
|
||||
</div>
|
||||
{desktop.note && (
|
||||
<div className="flex items-start gap-2.5 p-3 rounded-xl bg-emerald-500/5 border border-emerald-500/20 text-[11px] text-muted-foreground leading-relaxed">
|
||||
<Info className="h-4 w-4 text-emerald-400 shrink-0 mt-0.5" />
|
||||
<span>{desktop.note}</span>
|
||||
</div>
|
||||
)}
|
||||
<CodeWindow
|
||||
tool={desktop}
|
||||
fileName={FILE_NAMES["hermes_desktop"]}
|
||||
accent="teal"
|
||||
copied={copied === "desktop"}
|
||||
onCopy={() => copy("desktop", desktop.snippet)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ausklapper — Legacy: roher /v1-Zugang + Gedächtnis-MCP für sonstige Tools */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card/30">
|
||||
<button
|
||||
onClick={() => setSonstigeOffen((v) => !v)}
|
||||
className="flex w-full items-center gap-2 p-4 text-sm font-space font-bold text-foreground cursor-pointer hover:text-primary transition-colors"
|
||||
>
|
||||
{sonstigeOffen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
Sonstige Tools (roher /v1-Zugang)
|
||||
<span className="text-[10px] font-medium text-muted-foreground normal-case">Kilo Code · Claude Code · Eigenbau</span>
|
||||
</button>
|
||||
|
||||
{sonstigeOffen && (
|
||||
<div className="grid gap-5 p-4 pt-0">
|
||||
{/* Leitung 1 — Modell anbinden */}
|
||||
<div className="space-y-3 rounded-2xl border border-border/60 border-t-2 border-t-primary/70 bg-card/30 p-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-space font-bold text-foreground">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-primary/15 text-primary text-[11px]">1</span>
|
||||
Modell anbinden
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">Wähle dein Tool — das Snippet zeigt auf den Gateway.</p>
|
||||
</div>
|
||||
|
||||
{/* Tool-Picker (ohne hermes_desktop — der hat oben seine eigene Karte) */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(data.tools).filter(([key]) => key !== "hermes_desktop").map(([key, t]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActive(key)}
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-1.5 text-[11px] font-semibold tracking-wide transition-all cursor-pointer",
|
||||
active === key
|
||||
? "bg-primary text-primary-foreground shadow-md shadow-primary/10"
|
||||
: "border border-border/50 text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tool && (
|
||||
<>
|
||||
{tool.note && (
|
||||
<div className="flex items-start gap-2.5 p-3 rounded-xl bg-primary/5 border border-primary/20 text-[11px] text-muted-foreground leading-relaxed">
|
||||
<Info className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<span>{tool.note}</span>
|
||||
</div>
|
||||
)}
|
||||
<CodeWindow
|
||||
tool={tool}
|
||||
fileName={FILE_NAMES[active] || "config.json"}
|
||||
accent="teal"
|
||||
copied={copied === "model"}
|
||||
onCopy={() => copy("model", tool.snippet)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import { useDienste, useProtokoll } from "@/lib/abfragen"
|
||||
import { post } from "@/lib/api"
|
||||
import { melden } from "@/lib/meldungen"
|
||||
import { cn } from "cn"
|
||||
|
||||
/** Dienste der Box mit Protokoll und Neustart — die „Log-Ansicht der Dienste“. */
|
||||
export function Dienste({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
const dienste = useDienste()
|
||||
const [gewaehlt, setGewaehlt] = useState<string | null>(null)
|
||||
const [bestaetigen, setBestaetigen] = useState<string | null>(null)
|
||||
const protokoll = useProtokoll(gewaehlt)
|
||||
|
||||
async function neustarten(unit: string) {
|
||||
setBestaetigen(null)
|
||||
try {
|
||||
const e = await post<{ ok: boolean; err?: string }>("/api/maintenance/restart", { service: unit })
|
||||
if (e.ok) melden("erfolg", `${unit} startet neu.`)
|
||||
else melden("fehler", e.err || `${unit} ließ sich nicht neu starten.`)
|
||||
qc.invalidateQueries({ queryKey: ["dienste"] })
|
||||
} catch (f) {
|
||||
melden("fehler", (f as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
|
||||
<SheetContent side="right" className="w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-2xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="schild text-lg">Dienste und Protokolle</SheetTitle>
|
||||
<SheetDescription className="text-text-2">Welcher Dienst läuft, was er zuletzt geschrieben hat.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ul className="m-0 flex list-none flex-col px-4 pb-2">
|
||||
{(dienste.data?.services ?? []).map((d) => (
|
||||
<li key={d.unit} className="flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5">
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
<span className={cn("size-2.5 shrink-0 rounded-full", d.ok ? "bg-gruen" : "bg-rot")} aria-hidden />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[15px]">{d.name}</span>
|
||||
<span className="ziffern text-xs text-text-3">{d.unit} · {d.ok ? "läuft" : "antwortet nicht"}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex gap-2">
|
||||
<Button variant={gewaehlt === d.unit ? "info" : "ghost"} size="sm" onClick={() => setGewaehlt(d.unit)}>
|
||||
Protokoll
|
||||
</Button>
|
||||
{bestaetigen === d.unit ? (
|
||||
<Button variant="gefahr" size="sm" onClick={() => neustarten(d.unit)}>Wirklich neu starten?</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => setBestaetigen(d.unit)}>Neu starten</Button>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{gewaehlt && (
|
||||
<pre className="ziffern mx-4 mb-4 max-h-[50vh] overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2">
|
||||
{protokoll.isFetching ? "Wird gelesen …" : protokoll.data?.text || protokoll.data?.err || "Kein Protokoll."}
|
||||
</pre>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState } from "react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import { api, post } from "@/lib/api"
|
||||
import { melden } from "@/lib/meldungen"
|
||||
|
||||
interface GeheimnisStand {
|
||||
hf_token_gesetzt: boolean
|
||||
hf_token_aus_env: boolean
|
||||
schreibbar: boolean
|
||||
}
|
||||
|
||||
export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
const stand = useQuery({
|
||||
queryKey: ["geheimnisse"],
|
||||
queryFn: () => api<GeheimnisStand>("/api/maintenance/geheimnisse"),
|
||||
enabled: offen,
|
||||
})
|
||||
const [token, setToken] = useState("")
|
||||
|
||||
async function speichern(wert: string | null) {
|
||||
try {
|
||||
await post("/api/maintenance/geheimnisse", { schluessel: "hf_token", wert })
|
||||
melden("erfolg", wert ? "Hugging-Face-Zugang gespeichert." : "Hugging-Face-Zugang gelöscht.")
|
||||
setToken("")
|
||||
qc.invalidateQueries({ queryKey: ["geheimnisse"] })
|
||||
} catch (f) {
|
||||
melden("fehler", (f as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const s = stand.data
|
||||
return (
|
||||
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
|
||||
<SheetContent side="right" className="w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-lg">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="schild text-lg">Einstellungen</SheetTitle>
|
||||
<SheetDescription className="text-text-2">Was die Box sich merkt.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<section className="flex flex-col gap-3 border-t border-linie px-4 pt-4">
|
||||
<h3 className="schild text-sm text-text-3">Hugging-Face-Zugang</h3>
|
||||
<p className="text-sm text-text-2">
|
||||
{s?.hf_token_gesetzt ? "Ist hinterlegt." : "Fehlt."} Nötig für gesperrte Modelle und schnellere Downloads.
|
||||
{s?.hf_token_aus_env ? " Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen." : ""}
|
||||
</p>
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (token.trim()) speichern(token.trim())
|
||||
}}
|
||||
>
|
||||
<label htmlFor="hf-token" className="sr-only">Hugging-Face-Zugang</label>
|
||||
<input
|
||||
id="hf-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="hf_…"
|
||||
className="h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan"
|
||||
/>
|
||||
<Button type="submit" disabled={!token.trim() || s?.schreibbar === false}>Speichern</Button>
|
||||
</form>
|
||||
{s?.hf_token_gesetzt && !s.hf_token_aus_env && (
|
||||
<Button variant="ghost" size="sm" className="self-start" onClick={() => speichern(null)}>Zugang löschen</Button>
|
||||
)}
|
||||
</section>
|
||||
<section className="flex flex-col gap-2 border-t border-linie px-4 pt-4">
|
||||
<h3 className="schild text-sm text-text-3">Hinweise</h3>
|
||||
<p className="text-sm text-text-2">
|
||||
Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier. Einfache Probleme wie
|
||||
einen abgestürzten Dienst behebt der Wächter selbst und schreibt es in den Verlauf.
|
||||
</p>
|
||||
</section>
|
||||
<section className="flex flex-col gap-2 border-t border-linie px-4 pt-4 pb-4">
|
||||
<h3 className="schild text-sm text-text-3">Hermes</h3>
|
||||
<a href="/hermes-ui/" target="_blank" rel="noopener" className="flex h-11 items-center gap-2 self-start rounded-lg border border-linie-stark bg-erhaben px-4 text-[15px] hover:border-text-3">
|
||||
<ExternalLink className="size-4" aria-hidden /> Hermes-Dashboard öffnen
|
||||
</a>
|
||||
<p className="text-xs text-text-3">Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.</p>
|
||||
</section>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Component, type ReactNode } from "react"
|
||||
|
||||
export class GraphErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
|
||||
state = { error: null as Error | null }
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div className="h-[480px] rounded-2xl border border-red-500/20 bg-red-500/5 p-6 text-xs text-red-400 overflow-auto">
|
||||
<div className="font-semibold mb-2">Graph konnte nicht gerendert werden</div>
|
||||
<pre className="whitespace-pre-wrap break-words text-[11px] leading-relaxed">{this.state.error.message}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import ForceGraph2D from "react-force-graph-2d"
|
||||
import { forceCollide, forceX, forceY } from "d3-force"
|
||||
import { RefreshCw } from "lucide-react"
|
||||
import { type MemoryGraph } from "@/lib/api"
|
||||
|
||||
const CAT_COLOR: Record<string, string> = {
|
||||
identity: "#00f5ff", knowledge: "#3b82f6", rules: "#d946ef", events: "#fbbf24",
|
||||
}
|
||||
const CAT_LABEL: Record<string, string> = {
|
||||
identity: "Identität", knowledge: "Wissen", rules: "Regeln", events: "Ereignisse",
|
||||
}
|
||||
const EDGE_HI = "rgba(99, 102, 241, 0.9)"
|
||||
const FALLBACK_EDGE = "rgba(100, 116, 139, 0.3)"
|
||||
|
||||
// Ruhefarben der Kanten je Kategorie EINMAL vorberechnen — niemals pro Frame rechnen.
|
||||
const CAT_EDGE_COLOR: Record<string, string> = {
|
||||
identity: "rgba(0, 245, 255, 0.3)", knowledge: "rgba(59, 130, 246, 0.3)",
|
||||
rules: "rgba(217, 70, 239, 0.3)", events: "rgba(251, 191, 36, 0.3)",
|
||||
}
|
||||
// Halo-Farben (billiger Glow-Ersatz für ctx.shadowBlur, das pro Knoten/Frame unbezahlbar ist)
|
||||
const CAT_HALO_COLOR: Record<string, string> = {
|
||||
identity: "rgba(0, 245, 255, 0.16)", knowledge: "rgba(59, 130, 246, 0.16)",
|
||||
rules: "rgba(217, 70, 239, 0.16)", events: "rgba(251, 191, 36, 0.16)",
|
||||
}
|
||||
// Cluster-Hüllen: Fläche, Rand und Landkarten-Label je Kategorie
|
||||
const CAT_HULL_FILL: Record<string, string> = {
|
||||
identity: "rgba(0, 245, 255, 0.05)", knowledge: "rgba(59, 130, 246, 0.05)",
|
||||
rules: "rgba(217, 70, 239, 0.05)", events: "rgba(251, 191, 36, 0.05)",
|
||||
}
|
||||
const CAT_HULL_STROKE: Record<string, string> = {
|
||||
identity: "rgba(0, 245, 255, 0.16)", knowledge: "rgba(59, 130, 246, 0.16)",
|
||||
rules: "rgba(217, 70, 239, 0.16)", events: "rgba(251, 191, 36, 0.16)",
|
||||
}
|
||||
const CAT_HULL_LABEL: Record<string, string> = {
|
||||
identity: "rgba(0, 245, 255, 0.3)", knowledge: "rgba(59, 130, 246, 0.3)",
|
||||
rules: "rgba(217, 70, 239, 0.3)", events: "rgba(251, 191, 36, 0.3)",
|
||||
}
|
||||
|
||||
// Konvexe Hülle (Andrew Monotone Chain) — bei ~300 Punkten pro Frame vernachlässigbar
|
||||
function convexHull(pts: [number, number][]): [number, number][] {
|
||||
if (pts.length < 3) return pts
|
||||
const p = [...pts].sort((a, b) => a[0] - b[0] || a[1] - b[1])
|
||||
const cross = (o: number[], a: number[], b: number[]) =>
|
||||
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
|
||||
const lower: [number, number][] = []
|
||||
for (const pt of p) {
|
||||
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop()
|
||||
lower.push(pt)
|
||||
}
|
||||
const upper: [number, number][] = []
|
||||
for (let i = p.length - 1; i >= 0; i--) {
|
||||
const pt = p[i]
|
||||
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop()
|
||||
upper.push(pt)
|
||||
}
|
||||
lower.pop(); upper.pop()
|
||||
return lower.concat(upper)
|
||||
}
|
||||
|
||||
export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
||||
data: MemoryGraph
|
||||
selectedNodeId: string | null
|
||||
onNodeSelect: (id: string | null) => void
|
||||
}) {
|
||||
const [hovered, setHovered] = useState<string | null>(null)
|
||||
// Gewichts-Schwelle: nur Kanten ab dieser Ähnlichkeit werden im Ruhezustand gezeichnet
|
||||
const [threshold, setThreshold] = useState(0.75)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const fgRef = useRef<any>(null)
|
||||
|
||||
const [dimensions, setDimensions] = useState({ width: 500, height: 560 })
|
||||
|
||||
// ResizeObserver für präzise Canvas-Dimensionen im Layout
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.contentRect.width > 0 && entry.contentRect.height > 0) {
|
||||
setDimensions({
|
||||
width: Math.floor(entry.contentRect.width),
|
||||
height: Math.floor(entry.contentRect.height),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(containerRef.current)
|
||||
return () => resizeObserver.disconnect()
|
||||
}, [])
|
||||
|
||||
// Knoten-Objekte über Daten-Updates hinweg WIEDERVERWENDEN: D3 behält x/y auf dem
|
||||
// Objekt — nur so überleben die Positionen Refetches und Suchfilter, statt dass der
|
||||
// ganze Graph bei jedem Update neu explodiert (und Knoten unterm Cursor wegwandern).
|
||||
const nodeCacheRef = useRef(new Map<string, any>())
|
||||
|
||||
const graphData = useMemo(() => {
|
||||
const cache = nodeCacheRef.current
|
||||
const seen = new Set<string>()
|
||||
const nodes = data.nodes.map((n) => {
|
||||
seen.add(n.id)
|
||||
let node = cache.get(n.id)
|
||||
if (!node) {
|
||||
node = { ...n }
|
||||
cache.set(n.id, node)
|
||||
} else if (node.content !== n.content || node.category !== n.category) {
|
||||
node.content = n.content
|
||||
node.category = n.category
|
||||
node.__baseTextWidth = undefined
|
||||
}
|
||||
return node
|
||||
})
|
||||
for (const id of Array.from(cache.keys())) {
|
||||
if (!seen.has(id)) cache.delete(id)
|
||||
}
|
||||
|
||||
const catById = new Map(data.nodes.map((n) => [n.id, n.category]))
|
||||
const links = data.edges.map((e) => ({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
weight: e.weight || 0,
|
||||
// Stabile String-IDs behalten (D3 ersetzt source/target durch Objektreferenzen)
|
||||
__sid: e.source,
|
||||
__tid: e.target,
|
||||
__sameCat: catById.get(e.source) === catById.get(e.target),
|
||||
__restColor: CAT_EDGE_COLOR[catById.get(e.source) ?? ""] ?? FALLBACK_EDGE,
|
||||
}))
|
||||
return { nodes, links }
|
||||
}, [data])
|
||||
|
||||
// Grad (Degree) berechnen
|
||||
const degree = useMemo(() => {
|
||||
const d: Record<string, number> = {}
|
||||
data.edges.forEach((e) => {
|
||||
d[e.source] = (d[e.source] || 0) + 1
|
||||
d[e.target] = (d[e.target] || 0) + 1
|
||||
})
|
||||
return d
|
||||
}, [data])
|
||||
|
||||
// Quadranten-Fokuskoordinaten für sauberes Clustering
|
||||
const getClusterX = (category: string) => {
|
||||
return {
|
||||
identity: -240,
|
||||
knowledge: 240,
|
||||
rules: -240,
|
||||
events: 240,
|
||||
}[category] || 0
|
||||
}
|
||||
|
||||
const getClusterY = (category: string) => {
|
||||
return {
|
||||
identity: -240,
|
||||
knowledge: -240,
|
||||
rules: 240,
|
||||
events: 240,
|
||||
}[category] || 0
|
||||
}
|
||||
|
||||
// Reheat nur, wenn sich die STRUKTUR ändert (Knoten-/Kantenmenge) — nicht bei jeder
|
||||
// neuen Objekt-Identität der Daten. Verhindert Layout-Neustarts bei blossen Refetches.
|
||||
const structureKey = useMemo(() => {
|
||||
return `${data.nodes.map((n) => n.id).sort().join(",")}#${data.edges.length}`
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
const fg = fgRef.current
|
||||
if (!fg) return
|
||||
|
||||
// 1. Standard-Zentrumskraft entfernen
|
||||
fg.d3Force("center", null)
|
||||
|
||||
// 2. Clustering-Kräfte — etwas stärker, damit die Inseln klar auseinanderziehen
|
||||
fg.d3Force("x", forceX().x((node: any) => getClusterX(node.category)).strength(0.1))
|
||||
fg.d3Force("y", forceY().y((node: any) => getClusterY(node.category)).strength(0.1))
|
||||
|
||||
// 3. Starke Abstoßung (Charge) für ein weit gefächertes, luftiges Netz
|
||||
const charge = fg.d3Force("charge")
|
||||
if (charge) charge.strength(-380)
|
||||
|
||||
// 4. Konstanter, stabiler Kollisionsradius
|
||||
fg.d3Force("collide", forceCollide().radius(28).iterations(2))
|
||||
|
||||
// 5. Federkraft (Links): Kanten innerhalb einer Kategorie ziehen stark an
|
||||
// (gewichtet nach Ähnlichkeit), Querkanten fast gar nicht — so trennen sich
|
||||
// die Inseln räumlich, statt vom Quernetz zusammengezurrt zu werden.
|
||||
const link = fg.d3Force("link")
|
||||
if (link) {
|
||||
link
|
||||
.distance((l: any) => (l.__sameCat ? 70 : 200))
|
||||
.strength((l: any) => (l.__sameCat ? 0.3 * (l.weight || 0.6) : 0.01))
|
||||
}
|
||||
|
||||
// Simulation einmalig heiss starten für die neue Struktur
|
||||
fg.d3ReheatSimulation()
|
||||
}, [structureKey])
|
||||
|
||||
const activeNode = hovered || selectedNodeId
|
||||
|
||||
// Anzahl der im Ruhezustand sichtbaren Kanten (für die Anzeige am Regler)
|
||||
const visibleEdges = useMemo(() => {
|
||||
return graphData.links.filter((l: any) => l.__sameCat && (l.weight || 0) >= threshold).length
|
||||
}, [graphData, threshold])
|
||||
|
||||
// Layout zurücksetzen/neu anschubsen
|
||||
const relayout = () => {
|
||||
if (!fgRef.current) return
|
||||
fgRef.current.zoomToFit(400, 40)
|
||||
}
|
||||
|
||||
// Nachbarmenge für extrem schnellen Lookup
|
||||
const connectedNodes = useMemo(() => {
|
||||
if (!activeNode) return new Set<string>()
|
||||
const set = new Set<string>()
|
||||
data.edges.forEach((e) => {
|
||||
if (e.source === activeNode) set.add(e.target)
|
||||
if (e.target === activeNode) set.add(e.source)
|
||||
})
|
||||
return set
|
||||
}, [activeNode, data])
|
||||
|
||||
// Sichtbarkeit statt Durcheinander: im Ruhezustand nur Insel-Kanten (gleiche
|
||||
// Kategorie) ab der Gewichts-Schwelle. Querkanten erscheinen NUR am aktiven
|
||||
// Knoten (Hover/Auswahl) — dort dann alle seine Verbindungen, auch schwache.
|
||||
const linkVisibility = useCallback((link: any) => {
|
||||
if (activeNode) return link.__sid === activeNode || link.__tid === activeNode
|
||||
return link.__sameCat && (link.weight || 0) >= threshold
|
||||
}, [activeNode, threshold])
|
||||
|
||||
const linkColor = useCallback((link: any) => {
|
||||
if (activeNode && (link.__sid === activeNode || link.__tid === activeNode)) return EDGE_HI
|
||||
return link.__restColor
|
||||
}, [activeNode])
|
||||
|
||||
const linkWidth = useCallback((link: any) => {
|
||||
return (activeNode && (link.__sid === activeNode || link.__tid === activeNode)) ? 2.5 : 1.4
|
||||
}, [activeNode])
|
||||
|
||||
const linkParticles = useCallback((link: any) => {
|
||||
return (activeNode && (link.__sid === activeNode || link.__tid === activeNode)) ? 4 : 0
|
||||
}, [activeNode])
|
||||
|
||||
// EIGENES Picking statt der Bibliotheks-Hit-Map: force-graph erkennt Hover/Klick
|
||||
// über eine Schatten-Canvas, die nur alle 800 ms aktualisiert wird — solange die
|
||||
// Simulation läuft oder gezoomt wird, zeigt die Erkennung auf Positionen von vor
|
||||
// fast einer Sekunde. Wir rechnen stattdessen exakt gegen die LIVE-Koordinaten
|
||||
// der Knoten (319 Distanzchecks pro Mausbewegung sind vernachlässigbar).
|
||||
const pickNode = useCallback((clientX: number, clientY: number) => {
|
||||
const fg = fgRef.current
|
||||
const el = containerRef.current
|
||||
if (!fg || !el) return null
|
||||
const rect = el.getBoundingClientRect()
|
||||
const { x: gx, y: gy } = fg.screen2GraphCoords(clientX - rect.left, clientY - rect.top)
|
||||
const k = Math.max(fg.zoom() || 1, 0.01)
|
||||
let best: any = null
|
||||
let bestD2 = Infinity
|
||||
for (const n of graphData.nodes as any[]) {
|
||||
if (n.x == null || n.y == null) continue
|
||||
const size = 6 + Math.min(degree[n.id] || 0, 15) * 0.8
|
||||
// Trefferradius nie kleiner als ~16 Bildschirm-Pixel, egal wie weit rausgezoomt
|
||||
const hitR = Math.max(size + 3, 16 / k)
|
||||
const dx = n.x - gx
|
||||
const dy = n.y - gy
|
||||
const d2 = dx * dx + dy * dy
|
||||
if (d2 <= hitR * hitR && d2 < bestD2) { bestD2 = d2; best = n }
|
||||
}
|
||||
return best
|
||||
}, [graphData, degree])
|
||||
|
||||
const handlePointerMove = useCallback((ev: React.PointerEvent) => {
|
||||
// Nur auf der Canvas picken — über Buttons/Legende gilt: kein Hover
|
||||
const node = ev.target instanceof HTMLCanvasElement ? pickNode(ev.clientX, ev.clientY) : null
|
||||
setHovered(node ? node.id : null)
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.cursor = node ? "pointer" : "default"
|
||||
}
|
||||
}, [pickNode])
|
||||
|
||||
const handlePointerLeave = useCallback(() => {
|
||||
setHovered(null)
|
||||
if (containerRef.current) containerRef.current.style.cursor = "default"
|
||||
}, [])
|
||||
|
||||
// Klick selbst auswerten — aber nicht nach einem Pan (Mausweg > 6 px zählt nicht als Klick)
|
||||
const downPosRef = useRef<{ x: number; y: number } | null>(null)
|
||||
const handlePointerDown = useCallback((ev: React.PointerEvent) => {
|
||||
downPosRef.current = { x: ev.clientX, y: ev.clientY }
|
||||
}, [])
|
||||
|
||||
const handleClick = useCallback((ev: React.MouseEvent) => {
|
||||
if (!(ev.target instanceof HTMLCanvasElement)) return // Buttons/Legende nicht abfangen
|
||||
const down = downPosRef.current
|
||||
if (down && Math.hypot(ev.clientX - down.x, ev.clientY - down.y) > 6) return
|
||||
const node = pickNode(ev.clientX, ev.clientY)
|
||||
onNodeSelect(node ? node.id : null)
|
||||
}, [pickNode, onNodeSelect])
|
||||
|
||||
// Landkarten-Look: weiche, getönte Hülle + großes Label hinter jeder Kategorie-Insel.
|
||||
// Läuft VOR Kanten/Knoten (onRenderFramePre); Konvexhülle über ~300 Punkte ist billig.
|
||||
const paintHulls = useCallback((ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const byCat: Record<string, [number, number][]> = {}
|
||||
for (const n of graphData.nodes as any[]) {
|
||||
if (n.x == null || n.y == null) continue
|
||||
;(byCat[n.category] ??= []).push([n.x, n.y])
|
||||
}
|
||||
for (const [cat, pts] of Object.entries(byCat)) {
|
||||
const fill = CAT_HULL_FILL[cat]
|
||||
if (!fill || pts.length === 0) continue
|
||||
const hull = convexHull(pts)
|
||||
let cx = 0, cy = 0
|
||||
hull.forEach((p) => { cx += p[0]; cy += p[1] })
|
||||
cx /= hull.length; cy /= hull.length
|
||||
|
||||
const PAD = 34
|
||||
ctx.beginPath()
|
||||
if (hull.length < 3) {
|
||||
ctx.arc(cx, cy, PAD + 16, 0, 2 * Math.PI)
|
||||
} else {
|
||||
// Hülle vom Schwerpunkt weg aufpolstern und durch Mittelpunkte glätten
|
||||
const exp = hull.map(([x, y]) => {
|
||||
const dx = x - cx, dy = y - cy
|
||||
const d = Math.hypot(dx, dy) || 1
|
||||
return [x + (dx / d) * PAD, y + (dy / d) * PAD] as [number, number]
|
||||
})
|
||||
const mid = (a: number[], b: number[]) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
|
||||
const m0 = mid(exp[exp.length - 1], exp[0])
|
||||
ctx.moveTo(m0[0], m0[1])
|
||||
for (let i = 0; i < exp.length; i++) {
|
||||
const cur = exp[i]
|
||||
const m = mid(cur, exp[(i + 1) % exp.length])
|
||||
ctx.quadraticCurveTo(cur[0], cur[1], m[0], m[1])
|
||||
}
|
||||
ctx.closePath()
|
||||
}
|
||||
ctx.fillStyle = fill
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = CAT_HULL_STROKE[cat]
|
||||
ctx.lineWidth = 1.2 / Math.max(globalScale, 0.3)
|
||||
ctx.stroke()
|
||||
|
||||
// Insel-Label in Weltkoordinaten — zoomt wie eine Landkarten-Beschriftung mit
|
||||
const minY = Math.min(...hull.map((p) => p[1]))
|
||||
ctx.font = '600 26px "Space Grotesk", sans-serif'
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "bottom"
|
||||
ctx.fillStyle = CAT_HULL_LABEL[cat]
|
||||
ctx.fillText(CAT_LABEL[cat] || cat, cx, minY - PAD - 8)
|
||||
}
|
||||
}, [graphData])
|
||||
|
||||
const nodeCanvasObject = useCallback((node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const x = node.x ?? 0
|
||||
const y = node.y ?? 0
|
||||
const size = 6 + Math.min(degree[node.id] || 0, 15) * 0.8
|
||||
const color = CAT_COLOR[node.category] || "#64748b"
|
||||
const isHovered = node.id === hovered
|
||||
const isSelected = node.id === selectedNodeId
|
||||
|
||||
const isDimmed = activeNode && node.id !== activeNode && !connectedNodes.has(node.id)
|
||||
const finalColor = isDimmed ? "#1e293b" : color
|
||||
|
||||
// 1. Billiger Glow: transparenter Halo-Kreis statt ctx.shadowBlur (das kostet bei
|
||||
// hunderten Knoten pro Frame die gesamte Framerate). Echter Blur nur für den
|
||||
// EINEN aktiven Knoten.
|
||||
if (!isDimmed) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, size * 1.9, 0, 2 * Math.PI, false)
|
||||
ctx.fillStyle = CAT_HALO_COLOR[node.category] || "rgba(100, 116, 139, 0.14)"
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
if (isHovered || isSelected) {
|
||||
ctx.shadowColor = color
|
||||
ctx.shadowBlur = 15
|
||||
}
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, size, 0, 2 * Math.PI, false)
|
||||
ctx.fillStyle = finalColor
|
||||
ctx.fill()
|
||||
ctx.shadowBlur = 0
|
||||
|
||||
if (isHovered || isSelected) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, size + 2.5, 0, 2 * Math.PI, false)
|
||||
ctx.strokeStyle = isSelected ? "#ffffff" : "rgba(255, 255, 255, 0.7)"
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// 2. Label nur für den aktiven Fokus (Hovered, Selected & direkte Nachbarn)
|
||||
const showLabel = isHovered || isSelected || (activeNode && connectedNodes.has(node.id))
|
||||
|
||||
if (showLabel) {
|
||||
const label = node.content.length > 35 ? node.content.slice(0, 34) + "…" : node.content
|
||||
|
||||
// Textbreite EINMALIG auf dem Objekt cachen (ctx.measureText ist teuer)
|
||||
if (node.__baseTextWidth === undefined) {
|
||||
const prevFont = ctx.font
|
||||
ctx.font = '500 10px "Space Grotesk", sans-serif'
|
||||
node.__baseTextWidth = ctx.measureText(label).width
|
||||
ctx.font = prevFont
|
||||
}
|
||||
|
||||
const baseFontSize = isHovered || isSelected ? 10.5 : 9.5
|
||||
const fontSize = baseFontSize / Math.max(0.5, globalScale)
|
||||
|
||||
ctx.font = `${isHovered || isSelected ? "bold" : "500"} ${fontSize}px "Space Grotesk", sans-serif`
|
||||
|
||||
// Breite linear zur geänderten FontSize skalieren — kein measureText pro Frame
|
||||
const textWidth = node.__baseTextWidth * (fontSize / 10)
|
||||
const bpad = 4.5 / Math.max(0.5, globalScale)
|
||||
|
||||
const rx = x - textWidth / 2 - bpad
|
||||
const ry = y + size + (6 / Math.max(0.5, globalScale))
|
||||
const rw = textWidth + bpad * 2
|
||||
const rh = fontSize + bpad * 1.6
|
||||
|
||||
ctx.fillStyle = isDimmed ? "rgba(10, 15, 25, 0.45)" : "rgba(8, 12, 20, 0.88)"
|
||||
ctx.fillRect(rx, ry, rw, rh)
|
||||
|
||||
ctx.strokeStyle = isHovered || isSelected ? "rgba(255, 255, 255, 0.2)" : "rgba(255, 255, 255, 0.08)"
|
||||
ctx.lineWidth = 0.6 / Math.max(0.5, globalScale)
|
||||
ctx.strokeRect(rx, ry, rw, rh)
|
||||
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "top"
|
||||
ctx.fillStyle = isDimmed ? "#475569" : (isHovered || isSelected ? "#ffffff" : "#cbd5e1")
|
||||
|
||||
ctx.fillText(label, x, y + size + (7 / Math.max(0.5, globalScale)))
|
||||
}
|
||||
}, [degree, hovered, selectedNodeId, activeNode, connectedNodes])
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0" ref={containerRef}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onPointerDown={handlePointerDown}
|
||||
onClick={handleClick}
|
||||
style={{ background: "radial-gradient(circle at center, rgba(59, 130, 246, 0.08) 0%, #030712 100%)" }}>
|
||||
|
||||
<ForceGraph2D
|
||||
ref={fgRef}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
graphData={graphData}
|
||||
backgroundColor="rgba(0,0,0,0)"
|
||||
cooldownTicks={120}
|
||||
// Bibliotheks-Picking komplett aus: Hover/Klick machen wir selbst (exakt,
|
||||
// ohne 800-ms-Throttle), Drag verschluckt sonst Klicks und schubst das Layout an.
|
||||
enablePointerInteraction={false}
|
||||
enableNodeDrag={false}
|
||||
onRenderFramePre={paintHulls}
|
||||
linkVisibility={linkVisibility}
|
||||
linkColor={linkColor}
|
||||
linkWidth={linkWidth}
|
||||
linkDirectionalParticles={linkParticles}
|
||||
linkDirectionalParticleWidth={2.2}
|
||||
linkDirectionalParticleSpeed={0.006}
|
||||
nodeCanvasObject={nodeCanvasObject}
|
||||
/>
|
||||
|
||||
<button onClick={relayout} title="Zentrieren"
|
||||
className="absolute right-3 top-3 z-10 flex items-center gap-1.5 rounded-lg border border-border/60 bg-background/50 px-2.5 py-1.5 text-[11px] text-muted-foreground hover:text-foreground backdrop-blur-sm transition-colors cursor-pointer">
|
||||
<RefreshCw className="h-3.5 w-3.5" /> Zentrieren
|
||||
</button>
|
||||
|
||||
{/* Legende + Verbindungs-Regler */}
|
||||
<div className="absolute left-3 bottom-3 flex flex-wrap items-center gap-x-3 gap-y-1.5 rounded-lg bg-background/40 px-2.5 py-1.5 backdrop-blur-sm">
|
||||
{Object.entries(CAT_LABEL).map(([k, l]) => (
|
||||
<span key={k} className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<span className="h-2 w-2 rounded-full" style={{ background: CAT_COLOR[k] }} />{l}
|
||||
</span>
|
||||
))}
|
||||
<label className="flex items-center gap-1.5 text-[10px] text-muted-foreground border-l border-border/40 pl-3">
|
||||
<span>Verbindungen</span>
|
||||
<span className="text-[9px] text-muted-foreground/60">alle</span>
|
||||
<input
|
||||
type="range" min={0.45} max={0.95} step={0.05} value={threshold}
|
||||
onChange={(e) => setThreshold(Number(e.target.value))}
|
||||
className="w-20 accent-primary cursor-pointer"
|
||||
title="Nur Verbindungen ab dieser Stärke zeigen"
|
||||
/>
|
||||
<span className="text-[9px] text-muted-foreground/60">stark</span>
|
||||
<span className="font-mono text-[9px] text-muted-foreground/80 min-w-[3ch] text-right">{visibleEdges}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,528 +0,0 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import {
|
||||
BookOpen, Boxes, Cpu, Layers, Plug, Bot,
|
||||
Wrench, Shield, Compass, Code, Network, Database, Sparkles, Gauge,
|
||||
Lock, Lightbulb, Server, Zap, ArrowUpRight, LifeBuoy, Puzzle,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { SystemSchaubild } from "./guide/SystemSchaubild"
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Anleitung = Allgemeine AI-Bibel (Stand Juli 2026).
|
||||
* Ganz oben „Dein System": Schaubild + Kreislauf der eigenen Box.
|
||||
* Danach allgemeines Nachschlagewerk für lokale & agentische AI;
|
||||
* Stack-Spezifika nur als "Bei dir konkret"-Callouts.
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const SECTIONS: { id: string; label: string }[] = [
|
||||
{ id: "system", label: "Dein System" },
|
||||
{ id: "grundlagen", label: "Grundlagen" },
|
||||
{ id: "moe", label: "MoE & Quant" },
|
||||
{ id: "lokal", label: "Lokal betreiben" },
|
||||
{ id: "modelle", label: "Modell-Landschaft" },
|
||||
{ id: "gateway", label: "Gateway-Trick" },
|
||||
{ id: "mcp", label: "MCP" },
|
||||
{ id: "skills", label: "Skills" },
|
||||
{ id: "memory", label: "Gedächtnis & RAG" },
|
||||
{ id: "agents", label: "Agenten" },
|
||||
{ id: "ide", label: "IDE anbinden" },
|
||||
{ id: "tricks", label: "Tricks & Kniffe" },
|
||||
{ id: "wartung", label: "Sicherheit & Wartung" },
|
||||
{ id: "ressourcen", label: "Ressourcen" },
|
||||
{ id: "troubleshooting", label: "Troubleshooting" },
|
||||
]
|
||||
|
||||
function scrollToId(id: string) {
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||
}
|
||||
|
||||
/* ---- kleine Bausteine ---- */
|
||||
|
||||
function Card({ id, icon: Icon, color, title, kicker, children, wide }: {
|
||||
id?: string
|
||||
icon: LucideIcon
|
||||
color: string
|
||||
title: string
|
||||
kicker?: string
|
||||
children: ReactNode
|
||||
wide?: boolean
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={cn(
|
||||
"scroll-mt-20 mc-card p-6 space-y-3",
|
||||
wide && "md:col-span-2"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 border-b border-border/20 pb-3">
|
||||
<Icon className={cn("h-5 w-5 shrink-0", color)} />
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-foreground">{title}</h3>
|
||||
{kicker && <span className={cn("text-[9px] font-mono", color)}>{kicker}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-y-2.5 leading-relaxed">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/* "Bei dir konkret" — Stack-Einschub, nur wo eine Einschränkung es rechtfertigt */
|
||||
function Stack({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="mt-1 rounded-xl border border-primary/25 bg-primary/[0.06] p-3 text-[11px] leading-relaxed">
|
||||
<div className="flex items-center gap-1.5 font-bold text-primary mb-1">
|
||||
<Sparkles className="h-3 w-3" /> Bei dir konkret
|
||||
</div>
|
||||
<div className="text-muted-foreground space-y-1">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Pill({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<code className="rounded bg-background/40 border border-border/30 px-1.5 py-0.5 font-mono text-[10px] text-foreground">
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
function LinkItem({ href, name, note }: { href: string; name: string; note: string }) {
|
||||
return (
|
||||
<li className="leading-relaxed">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-primary hover:underline font-semibold inline-flex items-center gap-0.5"
|
||||
>
|
||||
{name}
|
||||
<ArrowUpRight className="h-3 w-3 opacity-60" />
|
||||
</a>
|
||||
<span className="text-muted-foreground"> — {note}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function GuideView() {
|
||||
const [showAllNav, setShowAllNav] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
{/* Hero */}
|
||||
<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>
|
||||
<div className="space-y-1">
|
||||
<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">
|
||||
Die AI-Bibel
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed max-w-2xl">
|
||||
Erst <span className="font-semibold text-foreground">dein System auf einen Blick</span>, dann das
|
||||
allgemeine Nachschlagewerk für lokale & agentische AI — Konzepte, Tricks, Kniffe und
|
||||
kuratierte Quellen. <span className="font-semibold text-foreground">Stand Juli 2026.</span>{" "}
|
||||
<span className="text-primary font-semibold">„Bei dir konkret"</span>-Kästen
|
||||
zeigen, was dein Stack daraus macht.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sprung-Navigation (sticky) */}
|
||||
<div className="sticky top-0 z-10 -mx-1 px-1">
|
||||
<div className="rounded-xl border border-border/50 bg-card/70 backdrop-blur-xl p-2 shadow-lg shadow-black/20">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Compass className="h-3.5 w-3.5 text-primary ml-1 mr-0.5 shrink-0" />
|
||||
{(showAllNav ? SECTIONS : SECTIONS.slice(0, 9)).map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => scrollToId(s.id)}
|
||||
className="rounded-lg px-2.5 py-1 text-[11px] font-semibold text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors cursor-pointer"
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setShowAllNav((v) => !v)}
|
||||
className="rounded-lg px-2 py-1 text-[11px] font-semibold text-primary hover:bg-primary/10 transition-colors cursor-pointer"
|
||||
>
|
||||
{showAllNav ? "weniger" : "+ mehr"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 0 — Dein System (Schaubild) */}
|
||||
<section id="system" className="scroll-mt-20 mc-card p-6 space-y-4">
|
||||
<div className="flex items-center gap-2.5 border-b border-border/20 pb-3">
|
||||
<Network className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-foreground">So funktioniert dein System</h3>
|
||||
<span className="text-[9px] font-mono text-primary">Landkarte & Kreislauf — das große Bild</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed max-w-3xl">
|
||||
Vier Türen führen zur selben Box: <strong className="text-foreground">diese Oberfläche</strong> zum Steuern
|
||||
und Abnicken, <strong className="text-foreground">Lucy</strong> für Zurufe per Stimme,
|
||||
<strong className="text-foreground"> Hermes Desktop</strong> für richtige Projekte und
|
||||
<strong className="text-foreground"> Telegram</strong> für unterwegs. In der Box denken die Modelle, das
|
||||
Gedächtnis lernt automatisch mit — und nachts arbeitet sie allein: Sie träumt, inventarisiert sich selbst,
|
||||
entwickelt Ideen zu Konzepten. Gebaut wird am PC in der IDE, jede Änderung geht als Branch nach Gitea.
|
||||
<strong className="text-primary"> Nichts geht live ohne deinen Merge</strong> — die Ampel-CI muss grün sein,
|
||||
danach spielt <code>deploy.sh</code> den Stand ein.
|
||||
</p>
|
||||
<SystemSchaubild />
|
||||
</section>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
|
||||
{/* 1 — Grundlagen */}
|
||||
<Card id="grundlagen" icon={Cpu} color="text-cyan-400" title="1. Wie LLMs ticken" kicker="Grundlagen">
|
||||
<p>
|
||||
Ein LLM ist im Kern ein <strong>Wahrscheinlichkeits-Rechner für das nächste Token</strong> (Wortteil).
|
||||
Es „weiß" nichts — es setzt fort, was statistisch am plausibelsten ist. Daraus folgt fast alles andere.
|
||||
</p>
|
||||
<ul className="list-disc pl-4 space-y-1">
|
||||
<li><strong>Tokens</strong> sind die Einheit — ~¾ Wort. Ein- und Ausgabe werden in Tokens gezählt.</li>
|
||||
<li><strong>Kontextfenster</strong> = wie viele Tokens das Modell gleichzeitig „sehen" kann (Prompt + Antwort). Voll = Anfang fällt raus.</li>
|
||||
<li><strong>Parameter</strong> = die trainierten Gewichte. Training ist einmalig, <strong>Inferenz</strong> ist jede Antwort.</li>
|
||||
<li><strong>Temperatur / Sampling</strong> steuert Zufall: niedrig = deterministisch/präzise (Code), hoch = kreativ.</li>
|
||||
<li><strong>Halluzination</strong> ist kein Bug, sondern die Kehrseite des Ratens. Gegenmittel: Grounding via Tools/RAG, Verifikation.</li>
|
||||
<li><strong>Reasoning-/Thinking-Modelle</strong> „denken" in unsichtbaren Tokens vor der Antwort — besser bei Logik, langsamer/teurer.</li>
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
{/* 2 — MoE & Quant */}
|
||||
<Card id="moe" icon={Layers} color="text-violet-400" title="2. MoE & Quantisierung" kicker="Mehr Modell, weniger Last">
|
||||
<p>
|
||||
<strong>Mixture of Experts (MoE):</strong> Statt für jedes Token das ganze Netz zu aktivieren, wählt ein
|
||||
<em> Router</em> nur ein paar spezialisierte <em>Experts</em> aus. So läuft ein 100B-Modell mit der aktiven
|
||||
Rechenlast eines viel kleineren (z.B. „122B total / 10B aktiv").
|
||||
</p>
|
||||
<p>
|
||||
<strong>Quantisierung:</strong> Gewichte von FP16 auf weniger Bits eindampfen — kleiner & schneller bei
|
||||
minimalem Qualitätsverlust. Faustregel:
|
||||
</p>
|
||||
<ul className="list-disc pl-4 space-y-1">
|
||||
<li><Pill>Q8</Pill> nahezu verlustfrei, größter Footprint</li>
|
||||
<li><Pill>Q6_K</Pill> sehr gut, guter Mittelweg</li>
|
||||
<li><Pill>Q4_K_M</Pill> der Sweet-Spot für lokal — viel Modell pro GB</li>
|
||||
</ul>
|
||||
<Stack>
|
||||
VRAM/RAM ist die harte Grenze — <strong>darum</strong> ist beides hier zentral: MoE lässt 100B+ überhaupt
|
||||
laufen, Quant entscheidet, was reinpasst. Dein Line-up fährt durchweg <Pill>Q4_K_M</Pill>/<Pill>Q6_K</Pill>.
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* 3 — Lokal betreiben */}
|
||||
<Card id="lokal" icon={Server} color="text-emerald-400" title="3. Lokal betreiben: Engines & Backends" kicker="llama.cpp, vLLM & Co." wide>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<p><strong>Inference-Engines</strong> — was die Modelle ausführt:</p>
|
||||
<ul className="list-disc pl-4 space-y-1">
|
||||
<li><strong>llama.cpp / GGUF</strong> — der De-facto-Standard für lokal, CPU+GPU, läuft überall.</li>
|
||||
<li><strong>Ollama / LM Studio</strong> — bequeme Wrapper mit One-Click-Downloads (LM Studio mit GUI).</li>
|
||||
<li><strong>vLLM / TGI</strong> — Server-Engines für maximalen Durchsatz auf dicken GPUs.</li>
|
||||
<li><strong>llama-swap</strong> — Proxy, der <em>mehrere</em> Modelle hinter <em>einem</em> Port hält und je nach Anfrage automatisch ein-/aussattelt.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p><strong>GPU-Backends</strong> — wie gerechnet wird:</p>
|
||||
<ul className="list-disc pl-4 space-y-1">
|
||||
<li><Pill>CUDA</Pill> NVIDIA · <Pill>ROCm</Pill> AMD · <Pill>Vulkan</Pill> herstellerübergreifend · <Pill>Metal</Pill> Apple</li>
|
||||
</ul>
|
||||
<p className="pt-1"><strong>Begriffe, die immer wiederkommen:</strong> Kontext (<Pill>-c</Pill>), Slots/Parallel, KV-Cache, Modell-TTL/Swap, <strong>Speculative Decoding</strong> (kleines Draft-Modell rät voraus → schneller).</p>
|
||||
</div>
|
||||
</div>
|
||||
<Stack>
|
||||
Deine Box hat eine AMD-APU (gfx1151). <strong>Einschränkung & Trick:</strong> dort schlägt <Pill>Vulkan/RADV</Pill> das
|
||||
offizielle <Pill>ROCm</Pill> bei Token-Generierung um ~12–22 % — darum läuft die Engine auf Vulkan.
|
||||
<Pill>llama-swap</Pill> hält die Alltags-Hirne dauerwarm; <Pill>coder</Pill> nutzt Spec-Decoding.
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* 4 — Modell-Landschaft */}
|
||||
<Card id="modelle" icon={Boxes} color="text-sky-400" title="4. Modell-Landschaft" kicker="Stand Juni 2026" wide>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<p><strong>Open-Weight (lokal nutzbar):</strong></p>
|
||||
<ul className="list-disc pl-4 space-y-1">
|
||||
<li><strong>Qwen</strong> (Alibaba) — starke Allrounder + Coder + Vision, viele Größen/MoE.</li>
|
||||
<li><strong>Llama</strong> (Meta), <strong>Gemma</strong> (Google), <strong>Phi</strong> (Microsoft) — solide Open-Familien.</li>
|
||||
<li><strong>DeepSeek</strong>, <strong>Mistral/Mixtral</strong> — kräftige MoE-/Reasoning-Modelle.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p><strong>Frontier (Cloud-API):</strong></p>
|
||||
<ul className="list-disc pl-4 space-y-1">
|
||||
<li><strong>Claude</strong> (Anthropic), <strong>GPT</strong> (OpenAI), <strong>Gemini</strong> (Google) — die stärksten Allrounder, aber nicht lokal.</li>
|
||||
</ul>
|
||||
<p className="pt-1"><strong>Modellwahl nach Aufgabe:</strong> schnelles Alltags-Hirn · großes Logik-Hirn für harte Tasks · dediziertes Code-Modell · multimodal für Bilder · Embedding-Modell fürs Gedächtnis.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] italic">
|
||||
Leaderboards (HF, LMArena) sind grobe Orientierung — der einzige Benchmark, der zählt, ist deine eigene Aufgabe.
|
||||
Vorsicht vor „Benchmaxxing".
|
||||
</p>
|
||||
<Stack>
|
||||
Dein Line-up via llama-swap (Klartext-Rollen): <Pill>Hirn</Pill> (Agent, dauerwarm) · <Pill>Groß</Pill> (schwere
|
||||
Logik) · <Pill>Coder</Pill> · <Pill>Augen</Pill> (Bilder) · <Pill>Späher</Pill> (schnelle Recherche) ·
|
||||
<Pill>Kritiker</Pill> (Fremdblick/Review) · <Pill>Gedächtnis</Pill> + <Pill>Sortierer</Pill> (Embedding &
|
||||
Reranking). Verwalten/tauschen im <strong>Modelle</strong>-Tab.
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* 5 — Gateway-Trick */}
|
||||
<Card id="gateway" icon={Network} color="text-amber-400" title="5. Der Gateway-Trick" kicker="OpenAI-kompatibel = überall andocken">
|
||||
<p>
|
||||
Fast jedes AI-Tool spricht heute das <strong>OpenAI-Format</strong> (<Pill>/v1/chat/completions</Pill>). Ein
|
||||
<strong> Gateway</strong> davor gibt dir <em>einen</em> Endpunkt für alles: zentrales Key-Management, Logging
|
||||
und transparentes <strong>Routing</strong> — der Client merkt nicht, welches Modell tatsächlich antwortet.
|
||||
</p>
|
||||
<p>
|
||||
Die Lane-Idee: du wählst <Pill>chat</Pill> (Alltag, <Pill>auto</Pill> geht als Alias weiter) oder{" "}
|
||||
<Pill>coding</Pill> (agentisch), das Gateway pickt das passende echte Modell und lädt es bei Bedarf.
|
||||
</p>
|
||||
<Stack>
|
||||
Dein Gateway: <Pill>http://192.168.178.151:9001/v1</Pill>, Model <Pill>chat</Pill> oder <Pill>coding</Pill>, API-Key beliebig.
|
||||
Fertige Configs erzeugt dir der <strong>Verbinden</strong>-Tab live — eine Wahrheit, kein Abtippen.
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* 6 — MCP */}
|
||||
<Card id="mcp" icon={Plug} color="text-violet-400" title="6. MCP — Werkzeuge für Agenten" kicker="USB-C für AI-Tools">
|
||||
<p>
|
||||
Das <strong>Model Context Protocol</strong> (offen, von Anthropic initiiert) standardisiert, wie ein
|
||||
AI-Client mit externen Tools & Datenquellen spricht. Ein <strong>MCP-Server</strong> bringt Fähigkeiten:
|
||||
Dateien, Web-Fetch, Git, Datenbanken, eigene APIs.
|
||||
</p>
|
||||
<p>Statt für jeden Editor eigene Tools zu schreiben, bindet jeder MCP-fähige Agent denselben Server an.</p>
|
||||
<div className="flex items-start gap-1.5 rounded-xl border border-amber-500/25 bg-amber-500/[0.06] p-2.5 text-[11px]">
|
||||
<Lock className="h-3.5 w-3.5 text-amber-400 shrink-0 mt-0.5" />
|
||||
<span><strong>Sicherheit:</strong> ein MCP-Server hat echten Zugriff (Dateien, Shell). Nur vertrauenswürdige Server laufen lassen, Rechte minimal halten, Quelle prüfen.</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 7 — Skills */}
|
||||
<Card id="skills" icon={Puzzle} color="text-emerald-400" title="7. Agent Skills" kicker="Wiederverwendbare Fähigkeiten">
|
||||
<p>
|
||||
Ein <strong>Skill</strong> ist ein Ordner mit einer <Pill>SKILL.md</Pill> (YAML-Kopf + Anleitung, optional
|
||||
Skripte/Beispiele), den der Agent für eine bestimmte Aufgabe lädt — z.B. TDD, Code-Vereinfachung, API-Design.
|
||||
</p>
|
||||
<pre className="p-2.5 bg-background/25 rounded-lg border border-border/30 font-mono text-[9px] text-foreground overflow-x-auto whitespace-pre">
|
||||
{`---
|
||||
name: tdd-pro
|
||||
description: Treibt Entwicklung mit strikter TDD-Praxis
|
||||
---
|
||||
# Instructions
|
||||
...`}
|
||||
</pre>
|
||||
<p>
|
||||
Suchen & installieren über die <strong>skills.sh</strong>-Registry: <Pill>npx skills find</Pill> /
|
||||
<Pill>npx skills add owner/repo</Pill>. Skills liegen projektweit (vom Agent beim Start gelesen) oder global.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* 8 — Gedächtnis & RAG */}
|
||||
<Card id="memory" icon={Database} color="text-indigo-400" title="8. Gedächtnis & RAG" kicker="Modelle vergessen — du nicht" wide>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<p>LLMs sind <strong>zustandslos</strong>: ohne Hilfe weiß das Modell beim nächsten Turn nichts mehr. Gedächtnis lebt extern.</p>
|
||||
<ul className="list-disc pl-4 space-y-1">
|
||||
<li><strong>Embeddings</strong> wandeln Text in Vektoren; ein <strong>Vektor-Store</strong> (Chroma, …) findet semantisch Ähnliches.</li>
|
||||
<li><strong>RAG</strong> = relevante Fakten vor dem Turn heraussuchen und einblenden statt alles im Prompt zu halten.</li>
|
||||
<li><strong>Long-Context vs. RAG:</strong> riesige Fenster sind bequem, aber teuer & „lost in the middle" — gezieltes Abrufen skaliert besser.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p><strong>Auto-lernendes Gedächtnis</strong> ist die nächste Stufe:</p>
|
||||
<ul className="list-disc pl-4 space-y-1">
|
||||
<li>extrahiert Fakten <em>selbst</em> aus Gesprächen, statt nur abzuspeichern</li>
|
||||
<li>dedupliziert semantisch (kein 10× derselbe Fakt)</li>
|
||||
<li><strong>Graph-Sicht</strong> = Fakten <em>plus</em> ihre Beziehungen, nicht nur eine flache Liste</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<Stack>
|
||||
Dein Gedächtnis führt <strong>Hermes</strong> selbst — auto-lernend und semantisch. Der Agent lernt im
|
||||
Hintergrund nach jedem Turn, egal ob du per Desktop, Konsole, Telegram oder Lucy mit ihm sprichst.
|
||||
<br />Deshalb hat das Steuerpult <strong>keinen eigenen Gedächtnis-Tab</strong>: bis August 2026 lag das
|
||||
Gedächtnis in einem MC2-Sidecar, seit dessen Ablösung ist Hermes die einzige Quelle — angesehen und
|
||||
gepflegt wird es dort, nicht hier. Unter <strong>Wissen</strong> findest du weiterhin die Graph-Sicht
|
||||
auf Lucys Wissens-Vault.
|
||||
<br /><strong>Gotcha:</strong> ein gelöschter Fakt kann wieder auftauchen, solange die Original-Nachricht noch im Verlauf steht (additive Extraktion).
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* 9 — Agenten */}
|
||||
<Card id="agents" icon={Bot} color="text-rose-400" title="9. Autonome Agenten betreiben" kicker="LLM in der Schleife" wide>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
Ein <strong>Agent</strong> ist ein LLM in einer Schleife mit Tools:
|
||||
<em> denken → Tool aufrufen → Ergebnis beobachten → weiter</em>, bis das Ziel erreicht ist.
|
||||
</p>
|
||||
<ul className="list-disc pl-4 space-y-1">
|
||||
<li><strong>Guardrails</strong> (Hard-Stop, Tool-Limits) verhindern Endlos-Loops, wenn ein Tool fehlt oder hängt.</li>
|
||||
<li><strong>Kontext-Hygiene</strong> ist die wichtigste Disziplin: frische Sessions starten — vollgemüllte Historien werden langsam, driften und halluzinieren.</li>
|
||||
<li><strong>Kanäle:</strong> CLI/Terminal, Chat-UI, Messaging (z.B. Telegram), reine API.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p><strong>Least Privilege:</strong> gib einem Agenten nur die Tools, die er wirklich braucht. Kritische Aktionen (Shell auf dem Host, Löschen, Geld/Deploys) hinter menschliche Freigabe.</p>
|
||||
<p><strong>Was sich vollautomatisch lohnt:</strong> Modell-Routing, Hintergrund-Gedächtnis, Recherche. <strong>Was Freigabe braucht:</strong> Systembefehle, Updates/Reboots, permanentes Löschen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Stack>
|
||||
<strong>Hermes</strong> ist dein Box-Agent mit eigenem Agent-Hirn (Rolle <Pill>Hirn</Pill>, dauerwarm).
|
||||
Reden tust du mit ihm über <strong>Hermes Desktop</strong> (am PC, remote via <Pill>/hermes-ui</Pill>),
|
||||
die <strong>Konsole</strong> (Box-Shell im Browser), <strong>Telegram</strong> — oder per Stimme über
|
||||
<strong> Lucy</strong>. Er hat MCP-Server für Gedächtnis, Stack-Steuerung, Web-Fetch und
|
||||
<strong> PC-Steuerung</strong> (Executor auf deinem Windows-PC). Nachts betreibt er Radar-Crons
|
||||
und meldet Funde in Chronik, Briefkasten und Telegram.
|
||||
Status & Verdrahtung im <strong>Hermes</strong>-Tab.
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* 10 — IDE anbinden */}
|
||||
<Card id="ide" icon={Code} color="text-cyan-400" title="10. IDE / Editor anbinden" kicker="Vibe-Coding lokal">
|
||||
<p>
|
||||
Jede <strong>OpenAI-kompatible</strong> IDE oder Extension lässt sich auf einen lokalen Server umbiegen —
|
||||
Base-URL + Model eintragen, fertig:
|
||||
</p>
|
||||
<ul className="list-disc pl-4 space-y-1">
|
||||
<li><strong>Cline</strong> & <strong>Roo Code</strong> (VS-Code-Extensions, voller Agent + MCP)</li>
|
||||
<li><strong>Cursor</strong> (VS-Code-Fork mit Top-Autocomplete)</li>
|
||||
<li><strong>Continue</strong>, <strong>aider</strong> (CLI), <strong>OpenCode</strong> (Desktop)</li>
|
||||
</ul>
|
||||
<Stack>
|
||||
Deine Haupt-IDE ist inzwischen <strong>Hermes Desktop</strong> — sie ist ab Werk mit Box, Gedächtnis und
|
||||
Skills verdrahtet, da ist nichts zu konfigurieren. Für alles andere gilt: Tipp den Kram nicht ab — der
|
||||
<strong> Verbinden</strong>-Tab generiert die fertige Config (inkl. MCP-Gedächtnis) pro Tool zum Kopieren.
|
||||
Essenz: Base <Pill>…:9001/v1</Pill>, Model <Pill>coder</Pill> (bauen) / <Pill>heavy</Pill> (denken) /{" "}
|
||||
<Pill>vision</Pill> (Bilder); für simple Chat-Tools tut es die Lane <Pill>chat</Pill>. Key beliebig.
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* 11 — Tricks & Kniffe */}
|
||||
<Card id="tricks" icon={Lightbulb} color="text-amber-400" title="11. Tricks & Kniffe" kicker="Was im Alltag wirklich spart" wide>
|
||||
<div className="grid md:grid-cols-3 gap-3">
|
||||
{[
|
||||
["Kontext sauber halten", "Neue Session statt Mega-Historie. Drift, Tempo und Halluzination hängen direkt am Kontext-Müll."],
|
||||
["Plan-then-Act", "Erst Plan/Vorgehen bestätigen lassen, dann ausführen. Spart teure Holzwege."],
|
||||
["Gezielte Edits", "Nie ganze Dateien überschreiben für eine Zeile — Such-/Ersetz-Tools nutzen. Weniger Tokens, weniger Fehler."],
|
||||
["Non-interaktive Befehle", "Keine interaktiven Prompts im Agent-Terminal; lange Tasks als Background-Job. Sonst hängt der Loop."],
|
||||
["DevTools koppeln", "Browser-/Konsolen-Zugriff geben — der Agent liest echte Fehler statt blind zu raten."],
|
||||
["Richtige Modellwahl", "Schnelles Hirn für Alltag, großes für harte Logik, Coder fürs Coden. Nicht alles mit der Kanone."],
|
||||
["Akzeptanzkriterien", "Sag konkret, woran „fertig“ erkennbar ist. Vage Prompts → vage Ergebnisse."],
|
||||
["Verifizieren statt vertrauen", "Tests/Live-Check fordern. „Sollte funktionieren“ ist kein Beweis."],
|
||||
["Regeln ins Gedächtnis", "Wiederkehrende Vorlieben/Konventionen einmal ablegen — der Agent zieht sie selbst."],
|
||||
].map(([t, d]) => (
|
||||
<div key={t} className="space-y-1 p-3 bg-background/10 rounded-xl border border-border/30">
|
||||
<div className="flex items-center gap-1.5 font-bold text-foreground text-[11px]">
|
||||
<Zap className="h-3 w-3 text-amber-400" /> {t}
|
||||
</div>
|
||||
<p className="text-[11px]">{d}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 12 — Sicherheit & Wartung */}
|
||||
<Card id="wartung" icon={Shield} color="text-emerald-400" title="12. Sicherheit & Wartung" kicker="Damit's auch morgen noch läuft" wide>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="flex items-center gap-1.5 font-bold text-foreground"><Gauge className="h-3.5 w-3.5 text-emerald-400" /> Goldene Regeln</p>
|
||||
<ul className="list-disc pl-4 space-y-1">
|
||||
<li><strong>Ein ungetesteter Restore ist kein Backup.</strong> Wiederherstellung mindestens einmal echt durchspielen.</li>
|
||||
<li><strong>Updates können Dinge still zerschießen</strong> — gerade bei AI-Stacks (Embeddings, Provider-Interna). Versionen pinnen, Smoke-Test, Post-Check.</li>
|
||||
<li><strong>Least Privilege</strong> für Agenten, MCP-Server und PC-Zugriff. Kritisches hinter Freigabe.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="flex items-center gap-1.5 font-bold text-foreground"><Wrench className="h-3.5 w-3.5 text-emerald-400" /> Bei dir im SystemDrawer</p>
|
||||
<ul className="list-disc pl-4 space-y-1">
|
||||
<li><strong>Backup:</strong> PBS-Backup-Zentrum (Sicherung auf den Proxmox-Server) + tägliche Tarball-Sicherung; Restore echt durchgespielt (<Pill>restore.sh</Pill>, Doku in <Pill>docs/BACKUP.md</Pill>).</li>
|
||||
<li><strong>Updates:</strong> git-basierter Update-Check; <strong>Update-Buttons</strong> mit anschließendem <strong>Gehirn-Check</strong> (rot, falls ein Update das Gedächtnis zerschießt).</li>
|
||||
<li><strong>Deploy-Sicherheit:</strong> <code>deploy.sh</code> sichert vorher und prüft danach die Gesundheit; die Zeitmaschine in der Chronik ist der Rückweg.</li>
|
||||
<li><strong>Dienste & Gefahrenzone:</strong> Restart/Logs pro Dienst, kritische Eingriffe getrennt.</li>
|
||||
</ul>
|
||||
<p className="text-[10px] italic">Erreichbar über das System-Icon → Tab „Wartung".</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 13 — Ressourcen */}
|
||||
<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>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-foreground">13. Kuratierte Ressourcen & Links</h3>
|
||||
<span className="text-[9px] font-mono text-primary">Sauber geordnet — die guten Quellen</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-5 text-xs">
|
||||
<div className="space-y-2">
|
||||
<div className="font-bold text-foreground flex items-center gap-1.5"><Boxes className="h-3.5 w-3.5 text-sky-400" /> Modelle & Engines</div>
|
||||
<ul className="list-disc pl-4 space-y-1.5">
|
||||
<LinkItem href="https://huggingface.co/models" name="Hugging Face" note="das Repository für offene Modelle (GGUF, Embeddings, Datasets)." />
|
||||
<LinkItem href="https://github.com/ggml-org/llama.cpp" name="llama.cpp" note="die Referenz-Engine für lokale Inferenz." />
|
||||
<LinkItem href="https://github.com/mostlygeek/llama-swap" name="llama-swap" note="mehrere Modelle hinter einem Port, Auto-Swap." />
|
||||
<LinkItem href="https://ollama.com" name="Ollama" note="einfachster Einstieg, One-Command-Modelle." />
|
||||
<LinkItem href="https://lmstudio.ai" name="LM Studio" note="lokale GUI zum Stöbern, Laden & Chatten." />
|
||||
<LinkItem href="https://github.com/vllm-project/vllm" name="vLLM" note="Hochdurchsatz-Serving auf dicken GPUs." />
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="font-bold text-foreground flex items-center gap-1.5"><Plug className="h-3.5 w-3.5 text-violet-400" /> MCP & Skills</div>
|
||||
<ul className="list-disc pl-4 space-y-1.5">
|
||||
<LinkItem href="https://modelcontextprotocol.io" name="MCP — offizielle Doku" note="Spezifikation & Einstieg." />
|
||||
<LinkItem href="https://github.com/modelcontextprotocol/servers" name="Offizielle MCP-Server" note="filesystem, git, postgres, fetch & mehr." />
|
||||
<LinkItem href="https://smithery.ai" name="Smithery" note="Registry zum Suchen & Auto-Installieren von MCP-Servern." />
|
||||
<LinkItem href="https://glama.ai/mcp/servers" name="Glama MCP Registry" note="kuratierte Community-Datenbank." />
|
||||
<LinkItem href="https://skills.sh" name="skills.sh" note="Registry & CLI für Agent-Skills." />
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="font-bold text-foreground flex items-center gap-1.5"><Bot className="h-3.5 w-3.5 text-rose-400" /> Agenten & Frameworks</div>
|
||||
<ul className="list-disc pl-4 space-y-1.5">
|
||||
<LinkItem href="https://github.com/NousResearch/hermes-agent" name="Hermes Agent (Nous)" note="der Agent, der auf deiner Box läuft." />
|
||||
<LinkItem href="https://docs.claude.com" name="Anthropic / Claude Docs" note="API, Tool-Use, Agent SDK, MCP." />
|
||||
<LinkItem href="https://github.com/cline/cline" name="Cline" note="autonomer Coding-Agent für VS Code." />
|
||||
<LinkItem href="https://aider.chat" name="aider" note="AI-Pair-Programming im Terminal, git-nativ." />
|
||||
<LinkItem href="https://continue.dev" name="Continue" note="Open-Source-Autopilot für IDEs." />
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="font-bold text-foreground flex items-center gap-1.5"><BookOpen className="h-3.5 w-3.5 text-amber-400" /> Lernen & Community</div>
|
||||
<ul className="list-disc pl-4 space-y-1.5">
|
||||
<LinkItem href="https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/overview" name="Prompt-Engineering (Anthropic)" note="die beste praktische Anleitung." />
|
||||
<LinkItem href="https://github.com/anthropics/anthropic-cookbook" name="Anthropic Cookbook" note="lauffähige Rezepte für Tools, RAG, Agenten." />
|
||||
<LinkItem href="https://www.promptingguide.ai" name="Prompt Engineering Guide" note="herstellerneutrales Nachschlagewerk." />
|
||||
<LinkItem href="https://hermes-agent.nousresearch.com" name="Hermes Agent" note="die autonome Open-Source Agenten-Plattform unserer Box." />
|
||||
<LinkItem href="https://www.reddit.com/r/LocalLLaMA/" name="r/LocalLLaMA" note="der Puls der lokalen-LLM-Szene." />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 14 — Troubleshooting */}
|
||||
<Card id="troubleshooting" icon={LifeBuoy} color="text-rose-400" title="14. Troubleshooting" kicker="Wenn's hakt" wide>
|
||||
<ul className="space-y-2 list-disc pl-4">
|
||||
<li><strong>Keine Verbindung?</strong> Selbes LAN wie die Box? IP/Port stimmen (<Pill>:9001</Pill>)? Backend-Status im <strong>Cockpit</strong> prüfen.</li>
|
||||
<li><strong>Modell antwortet nicht / langsam?</strong> Im <strong>Cockpit → Dienste</strong> schauen, ob <Pill>llama-swap</Pill> grün ist; sonst Restart. Erstes Token nach Swap dauert (Modell lädt).</li>
|
||||
<li><strong>Agent dreht durch / halluziniert?</strong> Frische Session starten (Hermes Desktop oder <strong>Konsole</strong>) — meist ist es vollgemüllter Kontext.</li>
|
||||
<li><strong>Gedächtnis findet nichts?</strong> Embedding-Dienst online? Semantisch suchen (Sinn statt exaktem Wort).</li>
|
||||
<li><strong>Angenommene Karte hat etwas zerschossen?</strong> Meist fängt es der Auto-Revert; sonst <strong>Chronik → Zeitmaschine</strong> (Stand von gestern) oder Backup & <Pill>restore.sh</Pill>.</li>
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
<p className="text-center text-[10px] text-muted-foreground/50 pt-2">
|
||||
Mission Control 2 · AI-Bibel · Stand Juli 2026 — lebendes Dokument, wächst mit dem Stack.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// Wegweiser (v3-Umbau P5). Die Ansicht wohnt jetzt in features/ideen/.
|
||||
// Diese Datei bleibt eine Fassung lang stehen, damit ein uebersehener Import nicht
|
||||
// stillschweigend bricht — danach entfernen und den Router direkt zeigen lassen.
|
||||
export { IdeenView } from "@/features/ideen/IdeenAnsicht"
|
||||
@@ -1,92 +0,0 @@
|
||||
import { useState } from "react"
|
||||
import { SquareTerminal, ExternalLink, AlertTriangle, RefreshCw } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
import { useAgentStatus, useQueryClient, qk, invalidate } from "@/lib/queries"
|
||||
import { resolveExternalUrl, cn } from "@/lib/utils"
|
||||
import { melden } from "@/app/store"
|
||||
|
||||
// Konsole = direkter, SSH-artiger Box-Zugriff (ttyd → Login-Shell auf :7682), eingebettet
|
||||
// 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()
|
||||
const qc = useQueryClient()
|
||||
const url = agent?.box_console_url ? resolveExternalUrl(agent.box_console_url) : undefined
|
||||
const ok = agent?.box_console_reachable
|
||||
const [restarting, setRestarting] = useState(false)
|
||||
const [restartMsg, setRestartMsg] = useState("")
|
||||
|
||||
// Ein-Klick-Reparatur statt SSH-Anweisung: box-console ist ein normaler User-Dienst.
|
||||
async function restartConsole() {
|
||||
setRestarting(true)
|
||||
setRestartMsg("")
|
||||
try {
|
||||
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
|
||||
method: "POST", body: JSON.stringify({ service: "box-console" }),
|
||||
})
|
||||
const text = r.ok
|
||||
? "Konsolen-Dienst neu gestartet — einen Moment, dann lädt das Terminal."
|
||||
: `Neustart fehlgeschlagen: ${r.err || "Unbekannter Fehler"}`
|
||||
setRestartMsg(text)
|
||||
melden(r.ok ? "erfolg" : "fehler", text)
|
||||
invalidate(qc, qk.agentStatus, qk.services)
|
||||
} catch (e: any) {
|
||||
const text = `Neustart fehlgeschlagen: ${e?.message || e}`
|
||||
setRestartMsg(text)
|
||||
melden("fehler", text)
|
||||
} finally {
|
||||
setRestarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent">
|
||||
Konsole
|
||||
</h1>
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
Direkte Shell auf der Box — wie ein SSH-Fenster, mitten im Browser.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span className={cn("h-2 w-2 rounded-full", ok ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
|
||||
{ok ? "online" : "offline"}
|
||||
</span>
|
||||
{url && (
|
||||
<a href={url} target="_blank" rel="noopener"
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground">
|
||||
<ExternalLink className="h-3.5 w-3.5" /> In neuem Tab
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{url ? (
|
||||
<div className="relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25">
|
||||
{ok === false && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center">
|
||||
<AlertTriangle className="h-8 w-8 text-amber-400" />
|
||||
<div className="text-sm font-semibold text-amber-300">Konsole nicht erreichbar</div>
|
||||
<p className="max-w-sm text-[11px] leading-normal text-muted-foreground">
|
||||
Der Terminal-Dienst (<code className="font-mono text-primary">box-console</code>) läuft gerade nicht.
|
||||
</p>
|
||||
<button onClick={restartConsole} disabled={restarting}
|
||||
className="flex h-9 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 text-[11px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50">
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", restarting && "animate-spin")} /> Dienst neu starten
|
||||
</button>
|
||||
{restartMsg && <p className="max-w-sm text-[11px] text-muted-foreground">{restartMsg}</p>}
|
||||
</div>
|
||||
)}
|
||||
<iframe src={url} title="Box-Konsole" className="h-full w-full border-0" style={{ minHeight: "58vh" }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground">
|
||||
<SquareTerminal className="mr-2 h-4 w-4" /> Lade Konsole…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState } from "react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import { api, post } from "@/lib/api"
|
||||
import { melden } from "@/lib/meldungen"
|
||||
import { zahl } from "@/lib/zeit"
|
||||
|
||||
interface Treffer { repo: string; downloads: number; likes: number }
|
||||
interface Quant { quant?: string; name?: string; size_bytes?: number; total_bytes?: number }
|
||||
|
||||
/** Modelle selbst auf Hugging Face suchen und installieren — zusätzlich zum Radar. */
|
||||
export function ModellSuche({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
const [eingabe, setEingabe] = useState("")
|
||||
const [begriff, setBegriff] = useState("")
|
||||
const [repo, setRepo] = useState<string | null>(null)
|
||||
const [rolle, setRolle] = useState("")
|
||||
|
||||
const treffer = useQuery({
|
||||
queryKey: ["hf-suche", begriff],
|
||||
queryFn: () => api<{ results: Treffer[] }>(`/api/hf/search?q=${encodeURIComponent(begriff)}`),
|
||||
enabled: begriff.length > 1,
|
||||
})
|
||||
const quants = useQuery({
|
||||
queryKey: ["hf-quants", repo],
|
||||
queryFn: () => api<{ repo: string; quants: Quant[] | string[] }>(`/api/hf/quants?repo=${encodeURIComponent(repo ?? "")}`),
|
||||
enabled: !!repo,
|
||||
})
|
||||
|
||||
async function installieren(quant: string) {
|
||||
if (!repo) return
|
||||
try {
|
||||
await post("/api/models/install", { repo, quant, role: rolle || null })
|
||||
melden("erfolg", `${repo} (${quant}) wird heruntergeladen. Den Fortschritt siehst du unter Updates.`)
|
||||
qc.invalidateQueries({ queryKey: ["jobs"] })
|
||||
qc.invalidateQueries({ queryKey: ["models"] })
|
||||
onSchliessen()
|
||||
} catch (f) {
|
||||
melden("fehler", (f as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const quantListe = (quants.data?.quants ?? []).map((q) => (typeof q === "string" ? { quant: q } : q))
|
||||
|
||||
return (
|
||||
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
|
||||
<SheetContent side="right" className="w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="schild text-lg">Modelle selbst suchen</SheetTitle>
|
||||
<SheetDescription className="text-text-2">Auf Hugging Face suchen, Quantisierung wählen, installieren.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<form
|
||||
className="flex gap-2 px-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
setRepo(null)
|
||||
setBegriff(eingabe.trim())
|
||||
}}
|
||||
>
|
||||
<label htmlFor="hf-suche" className="sr-only">Suchbegriff</label>
|
||||
<input
|
||||
id="hf-suche"
|
||||
value={eingabe}
|
||||
onChange={(e) => setEingabe(e.target.value)}
|
||||
placeholder="z. B. Qwen3.8 GGUF"
|
||||
className="h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan"
|
||||
/>
|
||||
<Button type="submit">Suchen</Button>
|
||||
</form>
|
||||
<div className="flex flex-col px-4 pb-4">
|
||||
{treffer.isFetching && <p className="py-3 text-sm text-text-2">Suche läuft …</p>}
|
||||
{!repo && treffer.data?.results.map((t) => (
|
||||
<button
|
||||
key={t.repo}
|
||||
type="button"
|
||||
onClick={() => setRepo(t.repo)}
|
||||
className="flex min-h-11 items-center justify-between gap-3 border-t border-linie py-2 text-left hover:text-cyan"
|
||||
>
|
||||
<span className="ziffern min-w-0 text-sm break-all">{t.repo}</span>
|
||||
<span className="ziffern shrink-0 text-xs text-text-3">{zahl(t.downloads)} ↓</span>
|
||||
</button>
|
||||
))}
|
||||
{repo && (
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="ziffern text-sm break-all">{repo}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => setRepo(null)}>Zurück</Button>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 text-sm text-text-2">
|
||||
Rolle
|
||||
<select value={rolle} onChange={(e) => setRolle(e.target.value)} className="h-11 rounded-lg border border-linie-stark bg-erhaben px-3 text-foreground">
|
||||
<option value="">ohne Rolle (nur installieren)</option>
|
||||
<option value="coder">Coder</option>
|
||||
</select>
|
||||
</label>
|
||||
{quants.isFetching && <p className="text-sm text-text-2">Quantisierungen werden gelesen …</p>}
|
||||
{quantListe.map((q) => {
|
||||
const name = q.quant ?? q.name ?? "?"
|
||||
const bytes = q.total_bytes ?? q.size_bytes
|
||||
return (
|
||||
<div key={name} className="flex items-center justify-between gap-3 border-t border-linie py-2">
|
||||
<span className="ziffern text-sm">{name}</span>
|
||||
<span className="flex items-center gap-3">
|
||||
{bytes ? <span className="ziffern text-xs text-text-3">{(bytes / 1024 ** 3).toFixed(1).replace(".", ",")} GB</span> : null}
|
||||
<Button variant="outline" size="sm" onClick={() => installieren(name)}>Installieren</Button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useState } from "react"
|
||||
import { Search } from "lucide-react"
|
||||
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
useModellLaden, useModelle, useNutzung, useRadar, useRadarAktion, useRadarSuche, useStart,
|
||||
} from "@/lib/abfragen"
|
||||
import { rolleVon } from "@/lib/anzeige"
|
||||
import { useLetzteMetrik } from "@/lib/strom"
|
||||
import type { Modell, Nutzung, RadarKandidat } from "@/lib/typen"
|
||||
import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit"
|
||||
import { ModellSuche } from "./ModellSuche"
|
||||
|
||||
/** Grenze aus der Erfahrung (Memory „llama-swap: nur EINE Gruppe resident“): darüber OOM. */
|
||||
const GRENZE_GB = 115
|
||||
|
||||
function Speicher({ modelle, laufend }: { modelle: Modell[]; laufend: string[] }) {
|
||||
const m = useLetzteMetrik()
|
||||
const start = useStart()
|
||||
const gesamt = gb(m?.ram_total ?? start.data?.box.ram_total) ?? 122.7
|
||||
const belegt = gb(m?.ram_used ?? start.data?.box.ram_used) ?? 0
|
||||
const geladen = modelle.filter((x) => laufend.includes(x.name))
|
||||
const dateien = geladen.reduce((s, x) => s + (gb(x.size_bytes) ?? 0), 0)
|
||||
const rest = Math.max(0, belegt - dateien)
|
||||
const pct = (g: number) => `${Math.min(100, (g / gesamt) * 100)}%`
|
||||
return (
|
||||
<Panel titel="Speicher" rechts={<span className="ziffern text-sm text-text-2">{Math.round(belegt)} von {Math.round(gesamt)} GB belegt · Grenze ~{GRENZE_GB} GB</span>}>
|
||||
<div className="relative h-8 rounded-lg bg-erhaben" role="img" aria-label={`Speicher: ${Math.round(belegt)} von ${Math.round(gesamt)} GB belegt`}>
|
||||
<div className="absolute inset-y-0 left-0 flex overflow-hidden rounded-lg" style={{ width: pct(belegt) }}>
|
||||
<div className="h-full bg-cyan" style={{ width: `${belegt ? (dateien / belegt) * 100 : 0}%` }} />
|
||||
<div className="h-full bg-cyan/35" style={{ width: `${belegt ? (rest / belegt) * 100 : 0}%` }} />
|
||||
</div>
|
||||
<div className="absolute -top-1.5 -bottom-1.5 w-0.5 bg-bernstein" style={{ left: pct(GRENZE_GB) }} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 text-sm text-text-2">
|
||||
<span className="flex items-center gap-2"><span className="size-2.5 rounded-sm bg-cyan" /> Geladene Modelle: {Math.round(dateien)} GB Dateien</span>
|
||||
<span className="flex items-center gap-2"><span className="size-2.5 rounded-sm bg-cyan/35" /> Cache, Dienste, System: {Math.round(rest)} GB</span>
|
||||
<span className="flex items-center gap-2"><span className="h-3 w-0.5 bg-bernstein" /> Ab hier wird es eng</span>
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Wert({ name, wert }: { name: string; wert: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<dt className="text-xs text-text-3">{name}</dt>
|
||||
<dd className="ziffern m-0 text-lg">{wert}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Rolle({ titel, modell, laeuft, nutzer }: { titel: string; modell?: Modell; laeuft: boolean; nutzer: string }) {
|
||||
const laden = useModellLaden()
|
||||
if (!modell) {
|
||||
return (
|
||||
<article className="flex flex-col gap-3.5 rounded-2xl border border-dashed border-linie-stark p-5">
|
||||
<div className="flex items-center justify-between"><span className="schild text-sm text-text-3">{titel}</span><Chip>Frei</Chip></div>
|
||||
<h3 className="font-anzeige text-xl font-semibold text-text-2">Nicht belegt</h3>
|
||||
<p className="text-sm text-text-2">Nur belegen, wenn ein Modell hier echten Mehrwert bringt. Das Radar meldet Kandidaten.</p>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
const kontext = modell.ctx ? Math.round(modell.ctx / Math.max(1, modell.parallel_slots) / 1024) : null
|
||||
const bilder = Boolean(modell.capabilities?.vision)
|
||||
return (
|
||||
<article className="flex flex-col gap-3.5 rounded-2xl border border-linie bg-panel p-5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="schild text-sm text-text-3">{titel}</span>
|
||||
<Chip art={laeuft ? "gruen" : "grau"}>{laeuft ? "Geladen" : "Auf Abruf"}</Chip>
|
||||
</div>
|
||||
<h3 className="ziffern m-0 text-lg font-medium break-all">{modell.name}</h3>
|
||||
<dl className="m-0 grid grid-cols-3 gap-3">
|
||||
<Wert name="Größe" wert={gbText(modell.size_bytes)} />
|
||||
<Wert name="Kontext" wert={kontext ? `${kontext}k` : "–"} />
|
||||
<Wert name="Bilder" wert={bilder ? "ja" : "nein"} />
|
||||
</dl>
|
||||
<p className="text-sm text-text-2">Genutzt von {nutzer}.{modell.spec_active ? " Mit Draft-Beschleunigung." : ""}</p>
|
||||
{!laeuft && (
|
||||
<Button variant="outline" size="sm" className="mt-auto self-start" onClick={() => laden.mutate(modell.name)} disabled={laden.isPending}>
|
||||
Jetzt laden
|
||||
</Button>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function NutzungsPanel({ nutzung }: { nutzung: Nutzung | undefined }) {
|
||||
if (!nutzung) return null
|
||||
const max = Math.max(1, ...nutzung.absender.map((a) => a.anfragen))
|
||||
const stunden = nutzung.letzte_24h.map((s) => Object.values(s.je_absender).reduce((a, b) => a + b, 0))
|
||||
const maxStunde = Math.max(1, ...stunden)
|
||||
return (
|
||||
<Panel titel="Wer nutzt die Modelle" rechts={<span className="ziffern text-xs text-text-3">letzte {nutzung.tage} Tage</span>}>
|
||||
{nutzung.absender.length === 0 && <p className="text-[15px] text-text-2">Keine Anfragen aufgezeichnet.</p>}
|
||||
<ul className="m-0 flex list-none flex-col gap-3.5 p-0">
|
||||
{nutzung.absender.map((a) => (
|
||||
<li key={a.name} className="flex flex-col gap-1.5">
|
||||
<div className="flex justify-between gap-3 text-sm"><span>{a.name}</span><span className="ziffern">{zahl(a.anfragen)}</span></div>
|
||||
<div className="h-2 rounded-full bg-erhaben"><div className="h-2 rounded-full bg-cyan" style={{ width: `${(a.anfragen / max) * 100}%` }} /></div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="schild text-xs text-text-3">Anfragen je Stunde, letzte 24 h</span>
|
||||
<svg viewBox="0 0 240 64" className="h-16 w-full" role="img" aria-label="Anfragen je Stunde in den letzten 24 Stunden">
|
||||
{stunden.map((n, i) => {
|
||||
const h = n ? Math.max(3, (n / maxStunde) * 50) : 2
|
||||
return <rect key={i} x={i * 10 + 1.5} y={52 - h} width={7} height={h} rx={1.5} fill={n ? "var(--cyan)" : "var(--linie)"} />
|
||||
})}
|
||||
{[0, 6, 12, 18].map((s) => (
|
||||
<text key={s} x={s * 10 + 5} y={63} textAnchor="middle" style={{ fontSize: 9, fill: "var(--text-3)", fontFamily: "var(--font-mono)" }}>{s}</text>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xs text-text-3">Anfragen an llama-swap. NerdQuiz geht direkt dorthin, alles andere über MC2.</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_ART: Record<string, ChipArt> = {
|
||||
bestanden: "gruen", durchgefallen: "rot", wartet: "cyan", neu: "cyan", getestet: "grau", uebernommen: "gruen", verworfen: "grau",
|
||||
}
|
||||
|
||||
function Kandidat({ k }: { k: RadarKandidat }) {
|
||||
const aktion = useRadarAktion()
|
||||
const offen = ["bestanden", "wartet", "neu"].includes(k.status)
|
||||
return (
|
||||
<li className="flex flex-col gap-2 border-t border-linie py-3.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="ziffern text-[15px] font-medium">{k.name}</span>
|
||||
<Chip art="cyan">{k.rolle === "hirn" ? "fürs Hirn" : "fürs Coden"}</Chip>
|
||||
<Chip art={k.eng ? "bernstein" : k.passt ? "gruen" : "rot"}>{k.eng ? "passt knapp" : k.passt ? "passt" : "passt nicht"}</Chip>
|
||||
<Chip art={STATUS_ART[k.status] ?? "grau"}>{k.status}</Chip>
|
||||
</div>
|
||||
<p className="text-sm text-text-2">
|
||||
{k.groesse_gb != null ? `${k.groesse_gb.toLocaleString("de-DE")} GB. ` : ""}
|
||||
{k.begruendung}
|
||||
</p>
|
||||
{offen && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{k.status === "bestanden" && (
|
||||
<Button size="sm" disabled={aktion.isPending} onClick={() => aktion.mutate({ id: k.id, aktion: "uebernehmen", name: k.name })}>
|
||||
Übernehmen
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" disabled={aktion.isPending} onClick={() => aktion.mutate({ id: k.id, aktion: "verwerfen", name: k.name })}>
|
||||
Verwerfen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function RadarPanel() {
|
||||
const radar = useRadar()
|
||||
const suche = useRadarSuche()
|
||||
if (radar.isError) {
|
||||
return (
|
||||
<Panel titel="Modell-Radar" id="radar">
|
||||
<p className="text-[15px] text-text-2">Das Radar ist auf dieser Box noch nicht eingerichtet.</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
const d = radar.data
|
||||
return (
|
||||
<Panel
|
||||
titel="Modell-Radar"
|
||||
id="radar"
|
||||
rechts={<Button variant="info" size="sm" onClick={() => suche.mutate(undefined)} disabled={suche.isPending}>Jetzt suchen</Button>}
|
||||
>
|
||||
<p className="-mt-2 text-sm text-text-2">
|
||||
Testet nachts zwischen 00:30 und 02:30 selbst, höchstens einen Kandidaten pro Woche.
|
||||
{d?.naechster_test ? ` Nächster Test: ${flugplanZeit(d.naechster_test)}.` : ""}
|
||||
</p>
|
||||
{!d ? (
|
||||
<p className="text-[15px] text-text-2">Wird gelesen …</p>
|
||||
) : d.kandidaten.length === 0 ? (
|
||||
<p className="text-[15px] text-text-2">Nichts Besseres in Sicht.</p>
|
||||
) : (
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{d.kandidaten.map((k) => <Kandidat key={k.id} k={k} />)}
|
||||
</ul>
|
||||
)}
|
||||
{d && d.tests.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="schild text-xs text-text-3">Letzte Tests</h3>
|
||||
{d.tests.slice(0, 3).map((t) => (
|
||||
<p key={t.id} className="text-sm text-text-2">
|
||||
<span className="ziffern text-text-3">{flugplanZeit(t.datum)}</span> · {t.zusammenfassung}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelleSeite() {
|
||||
const modelle = useModelle()
|
||||
const nutzung = useNutzung()
|
||||
const [suche, setSuche] = useState(false)
|
||||
|
||||
if (modelle.isPending) return <Zustandsfeld text="Modelle werden gelesen …" />
|
||||
if (modelle.error || !modelle.data) return <Zustandsfeld fehler text={`Die Modell-Liste fehlt: ${modelle.error?.message ?? "keine Daten"}`} />
|
||||
|
||||
const liste = modelle.data.models
|
||||
const laufend = modelle.data.running
|
||||
const hirn = liste.find((m) => rolleVon(m) === "hirn")
|
||||
const coder = liste.find((m) => rolleVon(m) === "coder")
|
||||
const weitere = liste.filter((m) => m !== hirn && m !== coder)
|
||||
const zuletzt = nutzung.data?.zuletzt_geladen ?? {}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Speicher modelle={liste} laufend={laufend} />
|
||||
<section aria-label="Rollen" className="grid gap-5 md:grid-cols-3 md:gap-6">
|
||||
<Rolle titel="Hirn" modell={hirn} laeuft={!!hirn && laufend.includes(hirn.name)} nutzer="Lucy, NerdQuiz und OpenChamber für Kleinkram" />
|
||||
<Rolle titel="Coder" modell={coder} laeuft={!!coder && laufend.includes(coder.name)} nutzer="OpenChamber zum Planen und Bauen und Lucys Delegation" />
|
||||
<Rolle titel="Dritte Rolle" laeuft={false} nutzer="" />
|
||||
</section>
|
||||
<div className="grid gap-5 md:gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)]">
|
||||
<NutzungsPanel nutzung={nutzung.data} />
|
||||
<RadarPanel />
|
||||
</div>
|
||||
<Panel
|
||||
titel="Weitere Einträge"
|
||||
rechts={<Button variant="outline" size="sm" onClick={() => setSuche(true)}><Search aria-hidden /> Modelle selbst suchen</Button>}
|
||||
>
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{weitere.map((m) => (
|
||||
<li key={m.name} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-t border-linie py-2.5 sm:grid-cols-[minmax(0,1fr)_120px_160px]">
|
||||
<span className="min-w-0">
|
||||
<span className="ziffern block text-sm break-all">{m.name}</span>
|
||||
<span className="text-xs text-text-3">{m.aliases.length ? m.aliases.join(", ") : "ohne Rolle"}</span>
|
||||
</span>
|
||||
<span className="ziffern text-sm text-text-2">{gbText(m.size_bytes)}</span>
|
||||
<span className="ziffern hidden text-xs text-text-3 sm:block">
|
||||
{laufend.includes(m.name) ? "geladen" : zuletzt[m.name] ? `zuletzt ${flugplanZeit(zuletzt[m.name])}` : "länger nicht geladen"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
<ModellSuche offen={suche} onSchliessen={() => setSuche(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { JobsBar } from "@/components/models/JobsBar"
|
||||
import { LaneEditor } from "@/components/models/LaneEditor"
|
||||
import { WarmSetManager } from "@/components/models/WarmSetManager"
|
||||
import { ModelsWorkbench } from "./models/werkbank/ModelsWorkbench"
|
||||
import { Discover } from "./models/Discover"
|
||||
|
||||
// Modell-Manager: Werkbank (verwalten) · Routing & Warm-Set (Lanes-Policy + Immer-bereit-Set,
|
||||
// vorher im toten Alt-Cockpit verwaist und damit UNERREICHBAR — Review 15.07.) · Entdecken.
|
||||
const TABS = [
|
||||
{ id: "werkbank", label: "Werkbank" },
|
||||
{ id: "routing", label: "Routing & Warm-Set" },
|
||||
{ id: "discover", label: "Modelle finden" },
|
||||
] as const
|
||||
type TabId = (typeof TABS)[number]["id"]
|
||||
|
||||
export function ModelsView() {
|
||||
// v3-Umbau P2: Der Reiter lag in useState und war damit nicht teilbar und nicht
|
||||
// reload-fest — wer jemandem „schau dir das Routing an" schicken wollte, schickte
|
||||
// die Werkbank. Jetzt steht er in der URL (/modelle?reiter=routing).
|
||||
const navigate = useNavigate()
|
||||
const { reiter } = useSearch({ from: "/modelle" })
|
||||
const tab: TabId = reiter ?? "werkbank"
|
||||
const setTab = (t: TabId) =>
|
||||
navigate({ to: "/modelle", search: (alt) => ({ ...alt, reiter: t }), replace: true })
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
|
||||
<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">
|
||||
Modell-Manager
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Modelle verwalten, das Lane-Routing einstellen und Neues von HuggingFace holen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex mc-card-sm p-1 self-start">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-1.5 text-xs font-semibold tracking-wide uppercase transition-all cursor-pointer",
|
||||
tab === t.id
|
||||
? "bg-primary text-primary-foreground shadow-md shadow-primary/10"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JobsBar />
|
||||
|
||||
<div className="transition-all duration-300">
|
||||
{tab === "werkbank" && <ModelsWorkbench />}
|
||||
{tab === "routing" && (
|
||||
<div className="space-y-5">
|
||||
<LaneEditor />
|
||||
<WarmSetManager />
|
||||
</div>
|
||||
)}
|
||||
{tab === "discover" && <Discover />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user