UI v3: Cockpit-Startseite + Modelle-Werkbank als Default, neue Box-Konsole

Kompletter Frontend-Umbau auf das abgenommene v3-Konzept, dazu zwei neue Funktionen.

Frontend (Neuordnung bestehender IST-Views, kein Backend-Umbau):
- Cockpit ist die neue Startseite: ruhige Bereichs-Kacheln (je 1 Live-Zahl +
  Status-Punkt), ehrliche Status-Zeile "Box gesund" + Speicher-Pille, "Braucht dich"
  (kritische Probleme mit 1-Klick-Reparatur + bereitliegende Updates).
- Modelle-Werkbank: Maschinenraum-Speicherleiste als Hero (Arbeitsspeicher-Balken,
  nach Rolle eingefaerbt + frei), darunter Master/Detail. Eingebettet als Haupt-Tab
  im Modell-Manager ("Werkbank"), "Modelle finden" + JobsBar bleiben.
- Sidebar/Nav neu strukturiert; alle Aktionen ueber die bestehenden /api-Endpoints.

Neue Features:
- Box-Konsole: zweites ttyd-Web-Terminal mit echter Login-Shell auf :7682 (direkter,
  SSH-artiger Box-Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie hermes-terminal).
  Neuer Dienst deploy/box-console.service + agent_status-Felder box_console_url/-reachable.
- Box-Zugang: das Host-Sudo-Passwort laesst sich jetzt direkt in der Konsole-Seite
  setzen/aendern/loeschen (lokal im Browser), statt nur versteckt im System-Drawer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-04 20:41:19 +02:00
parent 854393ce5f
commit 18afa37412
20 changed files with 1692 additions and 679 deletions
+272
View File
@@ -0,0 +1,272 @@
import { useState } from "react"
import {
Boxes, Brain, Server, ShieldAlert, Plug, Bot, TerminalSquare, ScrollText,
HeartPulse, AlertTriangle, Loader2, Wrench, Check, HardDrive, ChevronRight,
} from "lucide-react"
import { api } from "@/lib/api"
import {
useModels, useServices, useUpdates, useMemory, useAgentStatus, useConnectHealth,
useQueryClient, qk,
} from "@/lib/queries"
import { SquareTerminal } from "lucide-react"
import { useLucyHealth, type Repair } from "@/lib/useLucyHealth"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
import { CockpitTile } from "./CockpitTile"
// Ö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(4_000)
const { data: svc } = useServices(4_000)
const { data: updates } = useUpdates(8_000)
const { data: memory } = useMemory()
const { data: agent } = useAgentStatus()
const { data: connect } = useConnectHealth()
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
return (
<div className="space-y-7">
{/* 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>
{/* Ehrliche Status-Zeile */}
<StatusLine verdict={health.verdict} memory={health.memory} />
{/* „Braucht dich" — offene Aktionen, sonst Ruhe */}
<NeedsYou
problems={health.problems}
pendingCount={pendingCount}
busy={busy}
onRepair={runRepair}
/>
{/* Bereichs-Kacheln */}
<section>
<ZoneLabel>Bereiche</ZoneLabel>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<CockpitTile
icon={Boxes} title="Modelle" value={running} unit="warm"
tone={running > 0 ? "ok" : "muted"} hint="Speicher, laden & Rollen"
onClick={() => onNavigate("models")}
/>
<CockpitTile
icon={Brain} title="Gedächtnis" value={memory ? notes : "…"} unit="Notizen"
tone={memory ? "ok" : "loading"} hint="Lucys geteiltes Wissen"
onClick={() => onNavigate("memory")}
/>
<CockpitTile
icon={Server} title="Dienste" value={svc ? `${svcOk}/${svcTotal}` : "…"} unit="laufen"
tone={!svc ? "loading" : svcErrors > 0 ? "warn" : "ok"} hint="Status & Neustart"
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="Prüfen & einspielen"
onClick={() => openDrawer("maintenance")}
/>
<CockpitTile
icon={Plug} title="Verbinden" value="Zed" unit={connectOk ? "· bereit" : ""}
tone={!connect ? "loading" : connectOk ? "ok" : "warn"} hint="IDE- & Agent-Configs"
onClick={() => onNavigate("connect")}
/>
<CockpitTile
icon={ScrollText} title="Logs" value={svc ? svcErrors : "…"} unit={svcErrors === 1 ? "Fehler" : "Fehler"}
tone={!svc ? "loading" : svcErrors > 0 ? "alert" : "ok"} hint="Verlauf & Diagnose"
onClick={() => openDrawer("logs")}
/>
</div>
</section>
{/* Sekundäre Bereiche */}
<section>
<ZoneLabel>Mehr</ZoneLabel>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<CockpitTile
icon={Bot} title="Hermes" value={agent ? (agent.gateway_reachable ? "bereit" : "offline") : "…"}
tone={!agent ? "loading" : agent.gateway_reachable ? "ok" : "alert"} hint="Agent-Status & Verdrahtung"
onClick={() => onNavigate("agent")}
/>
<CockpitTile
icon={TerminalSquare} title="Terminal" value="öffnen"
tone="muted" hint="Interaktives Agent-Terminal"
onClick={() => onNavigate("terminal")}
/>
<CockpitTile
icon={SquareTerminal} title="Konsole" value={agent ? (agent.box_console_reachable ? "bereit" : "offline") : "…"}
tone={!agent ? "loading" : agent.box_console_reachable ? "ok" : "warn"} hint="Direkte Box-Shell (SSH)"
onClick={() => onNavigate("konsole")}
/>
</div>
</section>
{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 }: { verdict: ReturnType<typeof useLucyHealth>["verdict"]; memory: ReturnType<typeof useLucyHealth>["memory"] }) {
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: "Nichts Schlimmes — nur ein Hinweis." },
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", 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(0)} / ${memory.totalGb.toFixed(0)} GB`}
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full border border-border/30 bg-background/50">
<div className={cn("h-full rounded-full transition-all duration-500", memBar)} style={{ width: `${memory.pct}%` }} />
</div>
</div>
</div>
)
}
// „Braucht dich": kritische Probleme (mit 1-Klick-Reparatur) + bereitliegende Updates.
// Nichts offen → ruhige Bestätigung statt leerer Fläche.
function NeedsYou({
problems, pendingCount, busy, onRepair,
}: {
problems: ReturnType<typeof useLucyHealth>["problems"]
pendingCount: number
busy: Record<string, boolean>
onRepair: (id: string, r: Repair) => void
}) {
const hasUpdates = pendingCount > 0
const nothing = problems.length === 0 && !hasUpdates
return (
<section>
<ZoneLabel>Braucht dich</ZoneLabel>
{nothing ? (
<div className="flex items-center gap-3 rounded-2xl border border-emerald-500/25 bg-emerald-500/5 p-4">
<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">
{problems.map((c) => (
<div key={c.id} className="flex 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">
<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">
<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"
>
{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>
))}
{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, ob du es einspielst.</p>
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-amber-300/70" />
</button>
)}
</div>
)}
</section>
)
}