a2bfe0e480
Die 'Box gesund'-Speicher-Pille zeigte den GTT-Pool (GPU-Sicht auf den Unified-Memory der APU), die System-Status-Kachel daneben den System-RAM — zwei verschiedene Zahlen fuer denselben Speicher (z.B. 73/124 vs 90/122). useLucyHealth: Speicher-Ampel jetzt aus sys.ram (dieselbe Quelle wie die RAM-Zeile), Schwellen am RAM-Charakter geeicht (warn 85%, full 93%; 4 Modelle warm ~78% bleibt gruen). CockpitView: Pille auf 1 Nachkommastelle wie gb(). Live verifiziert: beide zeigen jetzt identisch 95.9/122.7 GB. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
427 lines
19 KiB
TypeScript
427 lines
19 KiB
TypeScript
import { useState } from "react"
|
|
import {
|
|
Boxes, Brain, Server, ShieldAlert, Plug,
|
|
HeartPulse, AlertTriangle, Loader2, Wrench, Check, HardDrive, ChevronRight,
|
|
Inbox,
|
|
} from "lucide-react"
|
|
import { api } from "@/lib/api"
|
|
import {
|
|
useModels, useServices, useUpdates, useMemory, useAgentStatus, useConnectHealth,
|
|
useAuftragsbuch, 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 { MorgenlageCard } from "./MorgenlageCard"
|
|
import { ErinnerungenCard } from "./ErinnerungenCard"
|
|
import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard"
|
|
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
|
|
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 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 connectOk = connect?.gateway.ok && connect?.memory.ok
|
|
const openAuftraege = auftraege?.available ? auftraege.open_count : 0
|
|
|
|
// 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} />
|
|
|
|
{/* Morgenlage — das jüngste Nacht-Gutachten (verschwindet, wenn keines da ist) */}
|
|
<MorgenlageCard />
|
|
|
|
{/* Leistung — die Live-Graphen */}
|
|
<section>
|
|
<ZoneLabel>Leistung</ZoneLabel>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<SystemStatusCard />
|
|
<TokenPerformanceCard />
|
|
</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" : openAuftraege > 0 ? "warn" : "ok"}
|
|
hint={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="Zed" unit={connectOk ? "· bereit" : ""}
|
|
tone={!connect ? "loading" : connectOk ? "ok" : "warn"}
|
|
hint={agent?.pc_executor_reachable ? "IDE & PC online (:7777)" : "IDE-Kopplung (PC offline)"}
|
|
onClick={() => onNavigate("connect")}
|
|
/>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
{/* Seitenspalte Rechts */}
|
|
<div className="space-y-6">
|
|
{/* Handlungsbedarf (NeedsYou) */}
|
|
<NeedsYou
|
|
problems={health.problems}
|
|
pendingCount={pendingCount}
|
|
openAuftraege={openAuftraege}
|
|
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 }: {
|
|
verdict: ReturnType<typeof useLucyHealth>["verdict"]
|
|
memory: ReturnType<typeof useLucyHealth>["memory"]
|
|
warns: ReturnType<typeof useLucyHealth>["warns"]
|
|
}) {
|
|
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 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: "Ein wichtiger Dienst hakt." },
|
|
}[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, busy, onRepair, onNavigate,
|
|
}: {
|
|
problems: ReturnType<typeof useLucyHealth>["problems"]
|
|
pendingCount: number
|
|
openAuftraege: number
|
|
busy: Record<string, boolean>
|
|
onRepair: (id: string, r: Repair) => void
|
|
onNavigate: (v: string) => void
|
|
}) {
|
|
const hasUpdates = pendingCount > 0
|
|
const hasAuftraege = openAuftraege > 0
|
|
const nothing = problems.length === 0 && !hasUpdates && !hasAuftraege
|
|
|
|
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>
|
|
))}
|
|
{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"),
|
|
},
|
|
{
|
|
label: "Direkte Box-Shell (SSH)",
|
|
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: "Interaktives Terminal",
|
|
status: "Öffnen",
|
|
tone: "muted",
|
|
action: () => onNavigate("konsole"),
|
|
},
|
|
{
|
|
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>
|
|
)
|
|
}
|
|
|