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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user