Files
mission-control-v2/frontend/src/views/cockpit/CockpitView.tsx
T

454 lines
21 KiB
TypeScript

import { useState } from "react"
import {
Boxes, Brain, Server, ShieldAlert, Plug,
HeartPulse, AlertTriangle, Loader2, Wrench, Check, HardDrive, ChevronRight,
Inbox, HelpCircle,
} from "lucide-react"
import { api } from "@/lib/api"
import {
useModels, useServices, useUpdates, useMemory, useAgentStatus, useConnectHealth,
useAuftragsbuch, useIdeen, useQueryClient, qk,
} from "@/lib/queries"
import { useLucyHealth, type Repair } from "@/lib/useLucyHealth"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
import { CockpitTile } from "./CockpitTile"
import { ErinnerungenCard } from "./ErinnerungenCard"
import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard"
import { LatencyCard } from "@/components/dashboard/LatencyCard"
// Öffnet die bestehende System-Schublade (Pflege/Logs) via globalem Event.
const openDrawer = (tab: "maintenance" | "logs") =>
window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab } }))
// Cockpit = die Startseite. Ruhige Übersicht: ehrliche Status-Zeile oben, „Braucht
// dich"-Bereich für offene Aktionen, darunter die Bereichs-Kacheln (je EINE Live-Zahl).
// Überblick außen, Tiefe hinter der Kachel — Daten/Aktionen aus den IST-Hooks.
export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void }) {
const { data: models } = useModels()
const { data: svc } = useServices()
const { data: updates } = useUpdates()
const { data: memory } = useMemory()
const { data: agent } = useAgentStatus()
const { data: connect } = useConnectHealth()
const { data: auftraege } = useAuftragsbuch()
const { data: ideen } = useIdeen()
const health = useLucyHealth()
const { showAlert, dialogElement } = useDialog()
const qc = useQueryClient()
const [busy, setBusy] = useState<Record<string, boolean>>({})
async function runRepair(id: string, repair: Repair) {
setBusy((b) => ({ ...b, [id]: true }))
try {
if (repair.kind === "loadModel") {
await api(`/api/models/${encodeURIComponent(repair.model)}/load`, { method: "POST" })
} else {
const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", {
method: "POST", body: JSON.stringify({ service: repair.service }),
})
if (!r.ok) showAlert("Reparatur nicht ganz geklappt", (r.err || "Unbekannter Fehler") +
(repair.needsSudo ? "\n\nTipp: Dafür wird evtl. das Box-Passwort gebraucht (oben rechts hinterlegen)." : ""))
}
qc.invalidateQueries({ queryKey: qk.health })
qc.invalidateQueries({ queryKey: qk.services })
qc.invalidateQueries({ queryKey: qk.models })
} catch (e: any) {
showAlert("Reparatur fehlgeschlagen", String(e?.message || e))
} finally {
setBusy((b) => ({ ...b, [id]: false }))
}
}
// ── Kachel-Zahlen aus den IST-Daten ableiten ──────────────────────────────
const running = models?.running?.length ?? 0
const svcOk = svc?.services.filter((s) => s.ok).length ?? 0
const svcTotal = svc?.services.length ?? 0
const svcErrors = svcTotal - svcOk
const notes = memory?.length ?? 0
const pendingCount =
(updates ? (updates.os > 0 ? 1 : 0) + (updates.engine > 0 ? 1 : 0) + (updates.swap > 0 ? 1 : 0) + (updates.models > 0 ? 1 : 0) : 0) +
(updates?.components?.filter((c) => c.update === true).length ?? 0)
const openAuftraege = auftraege?.available ? auftraege.open_count : 0
// Hängende Karten warten auf DEINE Antwort — bisher sah das Cockpit sie gar nicht
// („Nichts zu tun" trotz roter Karte im Auftragsbuch, Review 15.07.).
const blockedIdeen = ideen?.available ? ideen.items.filter((i) => i.status === "blocked").length : 0
// Verbinden: die drei Leitungen (Modell-Gateway · Gedächtnis · Desktop-Gateway).
const connectLines = connect ? [connect.gateway, connect.memory, connect.desktop_gateway] : []
const connectOkCount = connectLines.filter((l) => l?.ok).length
const connectBad = connect
? (!connect.gateway.ok ? "Modell-Leitung" : !connect.memory.ok ? "Gedächtnis-Leitung" : !connect.desktop_gateway.ok ? "Desktop-Gateway" : "")
: ""
// Zusätzliche reaktive Livedaten für die Kacheln
const activeBrain = models?.models?.find((m: any) => m.role === "hermes")
const brainName = activeBrain?.name ? activeBrain.name.split("/").pop()?.replace(/\.gguf$/i, "") : ""
const autoNotes = memory ? memory.filter((m: any) => ["auto", "agent", "hermes"].includes(m.source)).length : 0
const failedSvc = svc?.services.filter((s: any) => !s.ok).map((s: any) => s.name).join(", ")
const upParts = updates ? [
updates.os > 0 && "OS",
updates.engine > 0 && "Engine",
updates.swap > 0 && "Swap",
updates.models > 0 && "Modelle"
].filter(Boolean) : []
const updateSummary = upParts.length > 0 ? upParts.join(" + ") : ""
return (
<div className="space-y-6">
{/* Kopf */}
<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">
Cockpit
</h1>
<p className="text-sm text-muted-foreground">Deine Box auf einen Blick Details hinter jeder Kachel.</p>
</div>
{/* Main Grid: 2/3 Links (Inhalte & Performance), 1/3 Rechts (Shortcuts & Kalender) */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
{/* Hauptspalte Links */}
<div className="lg:col-span-2 space-y-6">
{/* Status-Zeile */}
<StatusLine verdict={health.verdict} memory={health.memory} warns={health.warns} problems={health.problems} />
{/* Leistung — die Live-Graphen */}
<section>
<ZoneLabel>Leistung</ZoneLabel>
<div className="grid gap-4 md:grid-cols-1">
<SystemStatusCard />
</div>
<div className="mt-4">
<LatencyCard />
</div>
</section>
{/* Bereichs-Kacheln (6 statt 7 - Logs ist in die Werkzeug-Liste gewandert) */}
<section>
<ZoneLabel>Bereiche</ZoneLabel>
<div className="grid gap-4 grid-cols-2 md:grid-cols-3">
<CockpitTile
icon={Inbox} title="Auftragsbuch" value={auftraege?.available ? openAuftraege : "—"}
unit={openAuftraege === 1 ? "Vorschlag" : "Vorschläge"}
tone={!auftraege ? "loading" : blockedIdeen > 0 || openAuftraege > 0 ? "warn" : "ok"}
hint={blockedIdeen > 0
? `${blockedIdeen} Karte${blockedIdeen === 1 ? "" : "n"} wartet auf deine Antwort`
: openAuftraege > 0 ? `${openAuftraege} offene Patches` : "Keine ausstehenden Vorschläge"}
onClick={() => onNavigate("auftraege")}
/>
<CockpitTile
icon={Boxes} title="Modelle" value={running} unit="warm"
tone={running > 0 ? "ok" : "muted"}
hint={brainName ? `Hirn: ${brainName}` : "Kein Hermes-Modell geladen"}
onClick={() => onNavigate("models")}
/>
<CockpitTile
icon={Brain} title="Gedächtnis" value={memory ? notes : "…"} unit="Notizen"
tone={memory ? "ok" : "loading"}
hint={memory ? `${notes} Fakten (${autoNotes} auto gelernt)` : "Gedächtnis-Pool laden..."}
onClick={() => onNavigate("memory")}
/>
<CockpitTile
icon={Server} title="Dienste" value={svc ? `${svcOk}/${svcTotal}` : "…"} unit="laufen"
tone={!svc ? "loading" : svcErrors > 0 ? "warn" : "ok"}
hint={failedSvc ? `Ausfall: ${failedSvc}` : "Alle Dienste online"}
onClick={() => openDrawer("maintenance")}
/>
<CockpitTile
icon={ShieldAlert} title="Updates" value={updates ? (pendingCount > 0 ? pendingCount : "0") : "…"}
unit={pendingCount > 0 ? "bereit" : "aktuell"}
tone={!updates ? "loading" : pendingCount > 0 ? "warn" : "ok"}
hint={pendingCount > 0 ? `Verfügbar: ${updateSummary}` : "Alles auf dem neuesten Stand"}
onClick={() => openDrawer("maintenance")}
/>
<CockpitTile
icon={Plug} title="Verbinden" value={connect ? `${connectOkCount}/${connectLines.length}` : "…"}
unit="Leitungen"
tone={!connect ? "loading" : connectOkCount === connectLines.length ? "ok" : "warn"}
hint={connectBad ? `Gestört: ${connectBad}` : "Hermes Desktop & IDEs bereit"}
onClick={() => onNavigate("connect")}
/>
</div>
</section>
</div>
{/* Seitenspalte Rechts */}
<div className="space-y-6">
{/* Handlungsbedarf (NeedsYou) */}
<NeedsYou
problems={health.problems}
pendingCount={pendingCount}
openAuftraege={openAuftraege}
blockedIdeen={blockedIdeen}
busy={busy}
onRepair={runRepair}
onNavigate={onNavigate}
/>
{/* Anstehendes — Erinnerungen & Routinen */}
<section>
<ZoneLabel>Anstehendes</ZoneLabel>
<ErinnerungenCard />
</section>
{/* Werkzeuge & Shortcuts */}
<section>
<ZoneLabel>Werkzeuge & Diagnose</ZoneLabel>
<ToolListCard agent={agent} svcErrors={svcErrors} onNavigate={onNavigate} />
</section>
</div>
</div>
{dialogElement}
</div>
)
}
function ZoneLabel({ children }: { children: React.ReactNode }) {
return <p className="mb-2 ml-0.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">{children}</p>
}
// Ehrliche Status-Zeile: großes Verdikt + Speicher-Pille. Erzählrahmen = Box.
function StatusLine({ verdict, memory, warns, problems }: {
verdict: ReturnType<typeof useLucyHealth>["verdict"]
memory: ReturnType<typeof useLucyHealth>["memory"]
warns: ReturnType<typeof useLucyHealth>["warns"]
problems: ReturnType<typeof useLucyHealth>["problems"]
}) {
const warnText = warns.length
? `${warns[0].label}: ${warns[0].detail}${warns.length > 1 ? ` (+${warns.length - 1} weitere)` : ""}`
: memory.status === "full" ? memory.text : "Nichts Schlimmes — nur ein Hinweis."
const problemText = problems.length
? `${problems[0].label}: ${problems[0].detail}${problems.length > 1 ? ` (+${problems.length - 1} weitere)` : ""}`
: "Ein wichtiger Dienst hakt."
const b = {
loading: { ring: "border-border/60 bg-card/45", icon: Loader2, iconCls: "text-muted-foreground animate-spin", title: "Box wird geprüft …", sub: "Einen Moment." },
gut: { ring: "border-emerald-500/40 bg-emerald-500/5", icon: HeartPulse, iconCls: "text-emerald-400", title: "Box gesund", sub: "Alle wichtigen Dienste laufen." },
warn: { ring: "border-amber-500/40 bg-amber-500/5", icon: AlertTriangle, iconCls: "text-amber-400", title: "Kleinigkeit an der Box", sub: warnText },
problem: { ring: "border-red-500/50 bg-red-500/5", icon: AlertTriangle, iconCls: "text-red-400", title: "Box braucht Hilfe", sub: problemText },
}[verdict]
const Icon = b.icon
const memTone = { ok: "text-emerald-400", warn: "text-amber-400", full: "text-red-400", unknown: "text-muted-foreground" }[memory.status]
const memBar = { ok: "bg-emerald-500", warn: "bg-amber-500", full: "bg-red-500", unknown: "bg-muted-foreground/40" }[memory.status]
return (
<div className={cn("flex flex-col gap-4 rounded-2xl border p-5 shadow-lg shadow-black/15 backdrop-blur-md transition-colors sm:flex-row sm:items-center sm:justify-between h-full", b.ring)}>
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-border/40 bg-background/30">
<Icon className={cn("h-6 w-6", b.iconCls)} />
</div>
<div className="min-w-0">
<h2 className="text-base font-bold tracking-tight text-foreground">{b.title}</h2>
<p className="text-xs text-muted-foreground">{b.sub}</p>
</div>
</div>
{/* Speicher-Pille */}
<div className="w-full max-w-[16rem] rounded-xl border border-border/30 bg-background/20 p-3">
<div className="mb-1.5 flex items-center justify-between text-[11px]">
<span className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-muted-foreground">
<HardDrive className="h-3.5 w-3.5" /> Speicher
</span>
<span className={cn("font-mono font-bold", memTone)}>
{memory.status === "unknown" ? "—" : `${memory.usedGb.toFixed(1)} / ${memory.totalGb.toFixed(1)} 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", memBar)} style={{ width: `${memory.pct}%` }} />
</div>
</div>
</div>
)
}
// „Braucht dich": kritische Probleme (mit 1-Klick-Reparatur) + bereitliegende Updates
// + offene Vorschläge im Auftragsbuch.
function NeedsYou({
problems, pendingCount, openAuftraege, blockedIdeen, busy, onRepair, onNavigate,
}: {
problems: ReturnType<typeof useLucyHealth>["problems"]
pendingCount: number
openAuftraege: number
blockedIdeen: number
busy: Record<string, boolean>
onRepair: (id: string, r: Repair) => void
onNavigate: (v: string) => void
}) {
const hasUpdates = pendingCount > 0
const hasAuftraege = openAuftraege > 0
const hasBlocked = blockedIdeen > 0
const nothing = problems.length === 0 && !hasUpdates && !hasAuftraege && !hasBlocked
return (
<div className="h-full flex flex-col justify-between">
{nothing ? (
<div className="flex items-center gap-3 rounded-2xl border border-emerald-500/25 bg-emerald-500/5 p-4 h-full">
<Check className="h-5 w-5 shrink-0 text-emerald-400" />
<div>
<p className="text-sm font-semibold text-foreground">Nichts zu tun</p>
<p className="text-xs text-muted-foreground">Alles läuft. Du musst gerade nichts abnicken.</p>
</div>
</div>
) : (
<div className="space-y-2 h-full flex flex-col justify-center">
{problems.map((c) => (
<div key={c.id} className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-2xl border border-red-500/25 bg-red-500/5 p-4">
<div className="flex min-w-0 items-center gap-3 w-full">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-red-500/30 bg-background/30 text-red-300">
<c.icon className="h-4.5 w-4.5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-foreground">{c.label}</p>
<p className="truncate text-xs text-muted-foreground">{c.detail}</p>
</div>
</div>
{c.repair && (
<button
onClick={() => c.repair && onRepair(c.id, c.repair)}
disabled={!!busy[c.id]}
className="flex h-9 shrink-0 items-center gap-1.5 rounded-lg border border-red-500/40 bg-red-500/10 px-3 text-[11px] font-bold uppercase tracking-wide text-red-300 transition-all hover:bg-red-500/20 cursor-pointer disabled:opacity-60 w-full sm:w-auto justify-center"
>
{busy[c.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wrench className="h-3.5 w-3.5" />}
{c.repair.label}
</button>
)}
</div>
))}
{hasBlocked && (
<button
onClick={() => onNavigate("auftraege")}
className="flex w-full items-center justify-between gap-3 rounded-2xl border border-red-500/30 bg-red-500/5 p-4 text-left transition-all hover:bg-red-500/10 cursor-pointer"
>
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-red-500/30 bg-background/30 text-red-300">
<HelpCircle className="h-4.5 w-4.5" />
</span>
<div>
<p className="text-sm font-bold text-foreground">{blockedIdeen} Karte{blockedIdeen === 1 ? "" : "n"} braucht deine Antwort</p>
<p className="text-xs text-muted-foreground">Die Box hängt an einer Frage und wartet auf dich.</p>
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-red-300/70" />
</button>
)}
{hasAuftraege && (
<button
onClick={() => onNavigate("auftraege")}
className="flex w-full items-center justify-between gap-3 rounded-2xl border border-primary/30 bg-primary/5 p-4 text-left transition-all hover:bg-primary/10 cursor-pointer"
>
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-primary/30 bg-background/30 text-primary">
<Inbox className="h-4.5 w-4.5" />
</span>
<div>
<p className="text-sm font-bold text-foreground">{openAuftraege} Vorschl{openAuftraege === 1 ? "ag" : "äge"} im Auftragsbuch</p>
<p className="text-xs text-muted-foreground">Annehmen oder ablehnen.</p>
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-primary/70" />
</button>
)}
{hasUpdates && (
<button
onClick={() => openDrawer("maintenance")}
className="flex w-full items-center justify-between gap-3 rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-left transition-all hover:bg-amber-500/10 cursor-pointer"
>
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-amber-500/30 bg-background/30 text-amber-300">
<ShieldAlert className="h-4.5 w-4.5" />
</span>
<div>
<p className="text-sm font-bold text-foreground">{pendingCount} Update{pendingCount === 1 ? "" : "s"} bereit</p>
<p className="text-xs text-muted-foreground">Ansehen und entscheiden.</p>
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-amber-300/70" />
</button>
)}
</div>
)}
</div>
)
}
function ToolListCard({ agent, svcErrors, onNavigate }: {
agent: any
svcErrors: number
onNavigate: (v: string) => void
}) {
const tools = [
{
label: "Hermes Agent",
status: agent ? (agent.gateway_reachable ? "Bereit" : "Offline") : "…",
tone: !agent ? "loading" : agent.gateway_reachable ? "ok" : "alert",
action: () => onNavigate("agent"),
},
{
// EIN Eintrag für die Konsole (vorher standen "Direkte Box-Shell (SSH)" UND
// "Interaktives Terminal" in der Liste — zwei Namen, dasselbe Ziel).
label: "Konsole (Box-Shell)",
status: agent ? (agent.box_console_reachable ? "Bereit" : "Offline") : "…",
tone: !agent ? "loading" : agent.box_console_reachable ? "ok" : "warn",
action: () => onNavigate("konsole"),
},
{
label: "Systemlogs & Diagnose",
status: svcErrors > 0 ? `${svcErrors} Fehler` : "Keine Fehler",
tone: svcErrors > 0 ? "alert" : "ok",
action: () => openDrawer("logs"),
},
{
label: "Wissens-Vault",
status: "Öffnen",
tone: "muted",
action: () => onNavigate("wissen"),
},
{
label: "Chronik & Zeitmaschine",
status: "Öffnen",
tone: "muted",
action: () => onNavigate("chronik"),
},
]
return (
<div className="rounded-2xl border border-border/60 bg-card/30 p-4 space-y-3 shadow-sm">
<div className="divide-y divide-border/20">
{tools.map((t, idx) => {
const dotColor = {
loading: "bg-muted-foreground/45 animate-pulse",
ok: "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.4)]",
warn: "bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.4)]",
alert: "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.4)]",
muted: "bg-muted-foreground/30",
}[t.tone]
return (
<button
key={idx}
onClick={t.action}
className="w-full py-2.5 px-1.5 flex items-center justify-between text-left hover:bg-card/45 rounded-lg transition-all group cursor-pointer text-xs"
>
<span className="text-muted-foreground group-hover:text-foreground transition-colors font-medium">
{t.label}
</span>
<div className="flex items-center gap-2">
<span className={cn("text-[10px] font-semibold font-mono",
t.tone === "ok" ? "text-emerald-400" :
t.tone === "warn" ? "text-amber-400" :
t.tone === "alert" ? "text-red-400" : "text-muted-foreground/60"
)}>
{t.status}
</span>
<span className={cn("h-1.5 w-1.5 rounded-full", dotColor)} />
</div>
</button>
)
})}
</div>
</div>
)
}