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:
+1
-1
File diff suppressed because one or more lines are too long
-664
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+673
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Mission Control 2.0</title>
|
||||
<script type="module" crossorigin src="/assets/index-Bcmk5MTW.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bwnys6u-.css">
|
||||
<script type="module" crossorigin src="/assets/index-DQC1USWi.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DfNbRJ2k.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -3,12 +3,12 @@ import { Command as CommandIcon, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { NAV, type ViewId } from "@/nav"
|
||||
import { CommandPalette } from "@/components/CommandPalette"
|
||||
import { ExpertToggle } from "@/components/ExpertToggle"
|
||||
import { DashboardView } from "@/views/DashboardView"
|
||||
import { ModelsView } from "@/views/ModelsView"
|
||||
import { ConnectView } from "@/views/ConnectView"
|
||||
import { MemoryView } from "@/views/MemoryView"
|
||||
import { AgentView } from "@/views/AgentView"
|
||||
import { TerminalView } from "@/views/TerminalView"
|
||||
import { KonsoleView } from "@/views/KonsoleView"
|
||||
import { GuideView } from "@/views/GuideView"
|
||||
import { Placeholder } from "@/views/Placeholder"
|
||||
import { SystemDrawer } from "@/components/SystemDrawer"
|
||||
@@ -16,6 +16,7 @@ import { useHealth, useSystemStatus } from "@/lib/queries"
|
||||
import { useMetricsFeeder } from "@/lib/metricsStore"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Mc3App } from "@/mc3/Mc3App"
|
||||
import { CockpitView } from "@/views/cockpit/CockpitView"
|
||||
|
||||
export default function App() {
|
||||
useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab
|
||||
@@ -228,14 +229,15 @@ export default function App() {
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto p-6 scrollbar-thin">
|
||||
{view === "dashboard" && <DashboardView />}
|
||||
{view === "dashboard" && <CockpitView onNavigate={(v) => navigate(v as ViewId)} />}
|
||||
{view === "models" && <ModelsView />}
|
||||
{view === "connect" && <ConnectView />}
|
||||
{view === "memory" && <MemoryView />}
|
||||
{view === "agent" && <AgentView />}
|
||||
{view === "terminal" && <TerminalView />}
|
||||
{view === "konsole" && <KonsoleView />}
|
||||
{view === "guide" && <GuideView />}
|
||||
{!["dashboard", "models", "connect", "memory", "agent", "terminal", "guide"].includes(view) && (
|
||||
{!["dashboard", "models", "connect", "memory", "agent", "terminal", "konsole", "guide"].includes(view) && (
|
||||
<Placeholder title={active.label} hint={active.hint} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -372,8 +372,10 @@ export interface DedupeResult {
|
||||
export interface AgentStatus {
|
||||
gateway_url: string
|
||||
terminal_url: string
|
||||
box_console_url?: string
|
||||
gateway_reachable: boolean
|
||||
terminal_reachable: boolean
|
||||
box_console_reachable?: boolean
|
||||
home_exists: boolean
|
||||
brain_model?: string
|
||||
has_config: boolean
|
||||
|
||||
+6
-4
@@ -5,11 +5,12 @@ import {
|
||||
Plug,
|
||||
Bot,
|
||||
TerminalSquare,
|
||||
SquareTerminal,
|
||||
HelpCircle,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export type ViewId = "dashboard" | "models" | "memory" | "connect" | "agent" | "terminal" | "guide"
|
||||
export type ViewId = "dashboard" | "models" | "memory" | "connect" | "agent" | "terminal" | "konsole" | "guide"
|
||||
|
||||
export interface NavItem {
|
||||
id: ViewId
|
||||
@@ -20,12 +21,13 @@ export interface NavItem {
|
||||
|
||||
// Eine Quelle der Wahrheit für Sidebar UND Command-Palette.
|
||||
export const NAV: NavItem[] = [
|
||||
{ id: "dashboard", label: "Zentrale", hint: "System- & Stack-Status", icon: LayoutDashboard },
|
||||
{ id: "models", label: "Modell-Manager", hint: "Verwalten, laden & Gateway-Routing", icon: Boxes },
|
||||
{ id: "dashboard", label: "Cockpit", hint: "Deine Box auf einen Blick", icon: LayoutDashboard },
|
||||
{ id: "models", label: "Modelle", hint: "Speicher, laden & Rollen", icon: Boxes },
|
||||
{ id: "memory", label: "Gedächtnis", hint: "Geteiltes Memory verwalten", icon: Brain },
|
||||
{ id: "connect", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug },
|
||||
{ id: "agent", label: "Hermes", hint: "Agent-Status & Verdrahtung", icon: Bot },
|
||||
{ id: "terminal", label: "Terminal", hint: "Interaktives Hermes-Agent-Terminal", icon: TerminalSquare },
|
||||
{ id: "terminal", label: "Hermes-Terminal", hint: "Interaktives Hermes-Agent-Terminal", icon: TerminalSquare },
|
||||
{ id: "konsole", label: "Konsole", hint: "Direkte Box-Shell (SSH-artig)", icon: SquareTerminal },
|
||||
{ id: "guide", label: "Anleitung", hint: "Einrichten & Vibe-Coding", icon: HelpCircle },
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { SquareTerminal, ExternalLink, AlertTriangle, Shield, Eye, EyeOff, Check, Trash2 } from "lucide-react"
|
||||
import { useAgentStatus } from "@/lib/queries"
|
||||
import { resolveExternalUrl, cn } from "@/lib/utils"
|
||||
|
||||
// Konsole = direkter, SSH-artiger Box-Zugriff (ttyd → Login-Shell auf :7682), eingebettet
|
||||
// per iframe — Schwester der Hermes-Terminal-Seite. Darunter „Box-Zugang": das Host-Sudo-
|
||||
// Passwort, das MC2 für System-Operationen (Neustarts, apt, Reboot) braucht, hier direkt
|
||||
// änder-/löschbar (nur lokal im Browser gespeichert, wie in den Einstellungen).
|
||||
export function KonsoleView() {
|
||||
const { data: agent } = useAgentStatus(5_000)
|
||||
const url = agent?.box_console_url ? resolveExternalUrl(agent.box_console_url) : undefined
|
||||
const ok = agent?.box_console_reachable
|
||||
|
||||
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 <code className="font-mono text-primary">box-console</code>-Dienst (ttyd) läuft nicht.
|
||||
Auf der Box: <code className="font-mono">systemctl --user restart box-console</code>.
|
||||
</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>
|
||||
)}
|
||||
|
||||
<BoxAccessCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// „Box-Zugang" — das Host-Sudo-Passwort direkt setzen/ändern/löschen. Reine Frontend-
|
||||
// Verwaltung des lokal gespeicherten Werts (localStorage `mc_sudo_password`), den api.ts
|
||||
// bei Bedarf mitschickt. KEINE Änderung des echten Linux-Kontopassworts.
|
||||
function BoxAccessCard() {
|
||||
const [pw, setPw] = useState("")
|
||||
const [stored, setStored] = useState(false)
|
||||
const [show, setShow] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const v = localStorage.getItem("mc_sudo_password") || ""
|
||||
setPw(v); setStored(!!v)
|
||||
}, [])
|
||||
|
||||
function save() {
|
||||
if (pw) localStorage.setItem("mc_sudo_password", pw)
|
||||
else localStorage.removeItem("mc_sudo_password")
|
||||
setStored(!!pw)
|
||||
setSaved(true); setTimeout(() => setSaved(false), 1800)
|
||||
}
|
||||
function clear() {
|
||||
localStorage.removeItem("mc_sudo_password"); setPw(""); setStored(false)
|
||||
setSaved(true); setTimeout(() => setSaved(false), 1800)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 p-5 shadow-lg shadow-black/15 backdrop-blur-md">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<Shield className="h-4.5 w-4.5 text-amber-400" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Box-Zugang</h2>
|
||||
<span className={cn("ml-1 rounded-full border px-2 py-0.5 text-[10px] font-semibold",
|
||||
stored ? "border-emerald-500/30 bg-emerald-500/5 text-emerald-400" : "border-border/40 bg-background/30 text-muted-foreground")}>
|
||||
{stored ? "gesetzt" : "nicht gesetzt"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mb-3 text-[11px] leading-normal text-muted-foreground">
|
||||
Das Box-Passwort (Sudo) braucht MC2 für System-Aktionen wie Dienst-Neustarts, OS-Updates und Neustart der Box.
|
||||
Es wird nur lokal in deinem Browser gespeichert. Hier kannst du es setzen, ändern oder löschen.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={show ? "text" : "password"} value={pw} onChange={(e) => setPw(e.target.value)}
|
||||
placeholder="Box-Passwort" autoComplete="off" spellCheck={false} name="box-password"
|
||||
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={() => setShow(!show)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label={show ? "Passwort verbergen" : "Passwort anzeigen"}>
|
||||
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={save}
|
||||
className="flex h-10 items-center justify-center gap-1.5 rounded-lg bg-primary px-4 text-xs font-semibold text-primary-foreground shadow shadow-primary/10 transition-colors hover:bg-primary/90 cursor-pointer">
|
||||
{saved ? <><Check className="h-4 w-4" /> gespeichert</> : "Speichern"}
|
||||
</button>
|
||||
<button onClick={clear} disabled={!stored && !pw}
|
||||
className="flex h-10 items-center justify-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent cursor-pointer disabled:opacity-40">
|
||||
<Trash2 className="h-3.5 w-3.5" /> Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { JobsBar } from "@/components/models/JobsBar"
|
||||
import { Cockpit } from "./models/Cockpit"
|
||||
import { ModelsWorkbench } from "./models/werkbank/ModelsWorkbench"
|
||||
import { Discover } from "./models/Discover"
|
||||
|
||||
export function ModelsView() {
|
||||
@@ -30,7 +30,7 @@ export function ModelsView() {
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t === "cockpit" ? "Cockpit" : "Modelle finden"}
|
||||
{t === "cockpit" ? "Werkbank" : "Modelle finden"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -39,7 +39,7 @@ export function ModelsView() {
|
||||
<JobsBar />
|
||||
|
||||
<div className="transition-all duration-300">
|
||||
{tab === "cockpit" ? <Cockpit /> : <Discover />}
|
||||
{tab === "cockpit" ? <ModelsWorkbench /> : <Discover />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ChevronRight, type LucideIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type TileTone = "ok" | "warn" | "alert" | "muted" | "loading"
|
||||
|
||||
const DOT: Record<TileTone, string> = {
|
||||
ok: "bg-emerald-500",
|
||||
warn: "bg-amber-500",
|
||||
alert: "bg-red-500",
|
||||
muted: "bg-muted-foreground/40",
|
||||
loading: "bg-muted-foreground/40 animate-pulse",
|
||||
}
|
||||
|
||||
const RING: Record<TileTone, string> = {
|
||||
ok: "hover:border-emerald-500/40",
|
||||
warn: "hover:border-amber-500/50",
|
||||
alert: "hover:border-red-500/50",
|
||||
muted: "hover:border-primary/40",
|
||||
loading: "hover:border-border/60",
|
||||
}
|
||||
|
||||
// Eine Bereichs-Kachel der Cockpit-Startseite: EIN Symbol, EINE große Live-Zahl,
|
||||
// EIN Status-Punkt, Klartext-Titel. Tiefe steckt hinter dem Klick (Werkbank/Drawer).
|
||||
export function CockpitTile({
|
||||
icon: Icon,
|
||||
title,
|
||||
value,
|
||||
unit,
|
||||
tone = "muted",
|
||||
hint,
|
||||
onClick,
|
||||
}: {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
value: React.ReactNode
|
||||
unit?: string
|
||||
tone?: TileTone
|
||||
hint?: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"group relative flex flex-col items-start rounded-2xl border border-border/60 bg-card/45 p-5 text-left shadow-lg shadow-black/15 backdrop-blur-md transition-all duration-200 cursor-pointer hover:bg-card/70 hover:shadow-xl",
|
||||
RING[tone],
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 flex w-full items-center justify-between">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/40 bg-background/30 text-foreground/80 transition-colors group-hover:text-primary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<span className={cn("h-2.5 w-2.5 rounded-full ring-2 ring-black/40", DOT[tone])} title={hint} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-2xl font-bold tracking-tight text-foreground font-space tabular-nums">{value}</span>
|
||||
{unit && <span className="text-xs font-medium text-muted-foreground">{unit}</span>}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex w-full items-center justify-between">
|
||||
<span className="text-sm font-semibold text-foreground/90">{title}</span>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/40 transition-all group-hover:translate-x-0.5 group-hover:text-primary" />
|
||||
</div>
|
||||
{hint && <span className="mt-0.5 text-[11px] text-muted-foreground">{hint}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { HardDrive } from "lucide-react"
|
||||
import type { ModelInfo } from "@/lib/api"
|
||||
import { fmtSize, gb } from "@/lib/format"
|
||||
import { roleMeta } from "@/lib/roleMeta"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { roleBarColor } from "./roleColors"
|
||||
|
||||
const B = 1024 ** 3
|
||||
const short = (name: string) => name.split("/").pop()?.replace(/\.gguf$/i, "") ?? name
|
||||
|
||||
// Maschinenraum-Speicherleiste — das Signatur-Element. EIN großer Balken „Arbeitsspeicher
|
||||
// · 124 GB", farbig aufgeteilt nach geladenem Modell (nach Rolle eingefärbt) + freier Rest.
|
||||
// Man sieht LIVE, wohin die GB gehen. Ein Klick auf ein Segment wählt das Modell rechts.
|
||||
export function MaschinenraumBar({
|
||||
running, capacityBytes, realUsedBytes, selected, onSelect,
|
||||
}: {
|
||||
running: ModelInfo[]
|
||||
capacityBytes: number
|
||||
realUsedBytes: number
|
||||
selected?: string | null
|
||||
onSelect?: (name: string) => void
|
||||
}) {
|
||||
const capacity = capacityBytes > 2 * B ? capacityBytes : 124 * B
|
||||
const weights = running.reduce((a, m) => a + (m.size_bytes || 0), 0)
|
||||
const freeBytes = Math.max(0, capacity - weights)
|
||||
const freePct = (freeBytes / capacity) * 100
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 p-5 shadow-lg shadow-black/15 backdrop-blur-md">
|
||||
<div className="mb-3 flex flex-wrap items-end justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Arbeitsspeicher</div>
|
||||
<div className="font-space text-xl font-bold tracking-tight text-foreground">{gb(capacity)} GB <span className="text-sm font-medium text-muted-foreground">gesamt</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-[11px] font-mono text-muted-foreground">
|
||||
<div><span className="font-bold text-foreground">{gb(weights)} GB</span> Modelle geladen</div>
|
||||
{realUsedBytes > 0 && <div>{gb(realUsedBytes)} GB real belegt (inkl. KV-Cache)</div>}
|
||||
<div className="text-emerald-400">{gb(freeBytes)} GB frei</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Der Balken */}
|
||||
<div className="flex h-12 w-full overflow-hidden rounded-xl border border-border/40 bg-background/50 p-1">
|
||||
{running.length === 0 ? (
|
||||
<div className="flex w-full items-center justify-center text-[11px] italic text-muted-foreground/60">
|
||||
Kein Modell geladen — Auto-Swap holt bei Anfrage automatisch das passende.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{running.map((m) => {
|
||||
const pct = ((m.size_bytes || 0) / capacity) * 100
|
||||
const c = roleBarColor(m.role)
|
||||
const isSel = selected === m.name
|
||||
return (
|
||||
<button
|
||||
key={m.name}
|
||||
onClick={() => onSelect?.(m.name)}
|
||||
style={{ width: `${pct}%` }}
|
||||
title={`${roleMeta(m.role).label} · ${short(m.name)} · ${fmtSize(m.size_bytes)}`}
|
||||
className={cn(
|
||||
"group flex h-full min-w-[2.5rem] shrink-0 flex-col justify-center gap-0.5 rounded-lg px-2 text-left text-white transition-all",
|
||||
c.bar,
|
||||
isSel ? "ring-2 ring-white/70" : "opacity-90 hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<span className="truncate font-space text-[10px] font-bold leading-tight tracking-wide">
|
||||
{roleMeta(m.role).short}
|
||||
</span>
|
||||
<span className="truncate font-mono text-[9px] leading-tight opacity-85">{fmtSize(m.size_bytes)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{freePct > 1.5 && (
|
||||
<div
|
||||
style={{ width: `${freePct}%` }}
|
||||
className="flex h-full min-w-[2rem] items-center justify-center rounded-lg text-[9px] font-mono text-muted-foreground/50"
|
||||
title={`${gb(freeBytes)} GB frei`}
|
||||
>
|
||||
frei
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legende */}
|
||||
{running.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5">
|
||||
{running.map((m) => {
|
||||
const c = roleBarColor(m.role)
|
||||
return (
|
||||
<button
|
||||
key={m.name}
|
||||
onClick={() => onSelect?.(m.name)}
|
||||
className="flex items-center gap-1.5 text-[11px] text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<span className={cn("h-2.5 w-2.5 rounded-sm", c.dot)} />
|
||||
<span className={cn("font-semibold", c.text)}>{roleMeta(m.role).short}</span>
|
||||
<span className="font-mono text-muted-foreground/70">{short(m.name)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground/60">
|
||||
<span className="h-2.5 w-2.5 rounded-sm border border-border/50 bg-background/50" /> frei · {gb(freeBytes)} GB
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import {
|
||||
Search, Power, PowerOff, Edit3, Trash2, Zap, HardDrive, Cpu, Lock, Brain, Check,
|
||||
} from "lucide-react"
|
||||
import { api, type ModelInfo } from "@/lib/api"
|
||||
import { useModels, useSystemStatus, useGroups, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { useExpertMode } from "@/lib/useExpertMode"
|
||||
import { CapsChips } from "@/components/CapsChips"
|
||||
import { RoleLabel, getBrandInfo } from "@/components/models/ModelBadges"
|
||||
import { SpecDraftModal } from "@/components/models/SpecDraftModal"
|
||||
import { ROLE_META, roleMeta } from "@/lib/roleMeta"
|
||||
import { fmtSize, fmtCtx } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { MaschinenraumBar } from "./MaschinenraumBar"
|
||||
import { roleBarColor } from "./roleColors"
|
||||
|
||||
const B = 1024 ** 3
|
||||
const short = (name: string) => name.split("/").pop()?.replace(/\.gguf$/i, "") ?? name
|
||||
|
||||
// Modelle-Werkbank (Frontend v3): Maschinenraum-Speicherleiste als Hero oben, darunter
|
||||
// Master/Detail — „links wählen, rechts verwalten". Reine Frontend-Neuordnung; alle
|
||||
// Aktionen laufen über die bestehenden /api/models-Endpoints (wie im IST-Cockpit).
|
||||
// Eingebettet als Haupt-Tab im Modell-Manager (Überschrift + Discover liefert ModelsView).
|
||||
export function ModelsWorkbench() {
|
||||
const qc = useQueryClient()
|
||||
const { data: modelsResp, isLoading, error } = useModels(4_000)
|
||||
const { data: sys } = useSystemStatus()
|
||||
const { data: groupsResp } = useGroups()
|
||||
const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog()
|
||||
const expert = useExpertMode()
|
||||
|
||||
const models = modelsResp?.models ?? []
|
||||
const running = modelsResp?.running ?? []
|
||||
const isProtected = (role?: string | null) => !!roleMeta(role).protected
|
||||
|
||||
const brainsGroup = groupsResp?.groups?.brains
|
||||
const brainsMembers = brainsGroup?.members ?? []
|
||||
const isCoresident = (name: string) => brainsMembers.includes(name)
|
||||
|
||||
const [selName, setSelName] = useState<string | null>(null)
|
||||
const [query, setQuery] = useState("")
|
||||
const [filter, setFilter] = useState<"all" | "in_use">("all")
|
||||
const [specModel, setSpecModel] = useState<ModelInfo | null>(null)
|
||||
|
||||
const reload = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.models })
|
||||
qc.invalidateQueries({ queryKey: qk.routing })
|
||||
qc.invalidateQueries({ queryKey: qk.groups })
|
||||
}
|
||||
|
||||
const runningInfo = models.filter((m) => running.includes(m.name))
|
||||
const gttTotal = sys?.gpu?.gtt_total || sys?.gpu?.vram_total || 0
|
||||
const gttUsed = sys?.gpu?.gtt_used || 0
|
||||
const capacity = gttTotal > 2 * B ? gttTotal : 124 * B
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
return models
|
||||
.filter((m) => (filter === "in_use" ? !!m.role || running.includes(m.name) : true))
|
||||
.filter((m) => (q ? m.name.toLowerCase().includes(q) : true))
|
||||
.sort((a, b) => {
|
||||
const aw = running.includes(a.name) ? 0 : 1
|
||||
const bw = running.includes(b.name) ? 0 : 1
|
||||
if (aw !== bw) return aw - bw
|
||||
return short(a.name).localeCompare(short(b.name))
|
||||
})
|
||||
}, [models, running, filter, query])
|
||||
|
||||
const selected = models.find((m) => m.name === selName) ?? null
|
||||
|
||||
// ── Aktionen (IST-Logik) ───────────────────────────────────────────────────
|
||||
async function doLoad(name: string) {
|
||||
try { await api(`/api/models/${encodeURIComponent(name)}/load`, { method: "POST" }); reload() }
|
||||
catch (e: any) { showAlert("Fehler", `Laden fehlgeschlagen: ${e.message || e}`) }
|
||||
}
|
||||
async function handleLoad(name: string) {
|
||||
const brainsWarm = brainsMembers.some((m) => running.includes(m))
|
||||
if (brainsGroup && brainsWarm && !isCoresident(name)) {
|
||||
const warm = brainsMembers.filter((m) => running.includes(m)).map(short).join(", ")
|
||||
showConfirm("Verdrängt das Hirn?",
|
||||
`„${short(name)}" ist nicht ko-resident. Beim Laden wirft es das warme Hirn (${warm}) raus — Lucy verliert Hirn bzw. Augen.\n\nTrotzdem exklusiv laden?`,
|
||||
() => doLoad(name))
|
||||
return
|
||||
}
|
||||
void doLoad(name)
|
||||
}
|
||||
async function handleUnload(name: string) {
|
||||
const m = models.find((x) => x.name === name)
|
||||
if (m && isProtected(m.role)) {
|
||||
showAlert("Geschützt", `„${roleMeta(m.role).label}" ist lebenswichtig und bleibt geladen.`)
|
||||
return
|
||||
}
|
||||
try { await api(`/api/models/${encodeURIComponent(name)}/unload`, { method: "POST" }); reload() }
|
||||
catch (e: any) { showAlert("Fehler", `Entladen fehlgeschlagen: ${e.message || e}`) }
|
||||
}
|
||||
async function handleRole(role: string, modelName: string) {
|
||||
try {
|
||||
const res = await api<{ warning?: string | null }>(`/api/models/${encodeURIComponent(modelName)}/role`, {
|
||||
method: "POST", body: JSON.stringify({ role: role || null }),
|
||||
})
|
||||
reload()
|
||||
if (res?.warning) showAlert("Rolle gesetzt — Hinweis", res.warning)
|
||||
} catch (e: any) { showAlert("Fehler", `Rolle zuweisen fehlgeschlagen: ${e.message || e}`) }
|
||||
}
|
||||
async function handleCtx(name: string, cur: number | null) {
|
||||
let auto: { ctx: number; gtt_gb: number; reserved_gb: number; budget_gb: number; mode: string } | null = null
|
||||
try { auto = await api(`/api/models/${encodeURIComponent(name)}/ctx/auto`) } catch { /* optional */ }
|
||||
const hint = auto
|
||||
? `Optimal für dein Setup: ${(auto.ctx / 1024).toFixed(0)}k (${auto.ctx}). »Auto« trägt diesen Wert ein.`
|
||||
: "Gib die gewünschte Kontextlänge in Tokens an:"
|
||||
showPrompt("Kontextlänge anpassen", hint, String(cur || 32768), async (v) => {
|
||||
if (!v) return
|
||||
try { await api(`/api/models/${encodeURIComponent(name)}/ctx`, { method: "POST", body: JSON.stringify({ ctx: parseInt(v, 10) }) }); reload() }
|
||||
catch (e: any) { showAlert("Fehler", `Kontext setzen fehlgeschlagen: ${e.message || e}`) }
|
||||
}, undefined, auto ? { autoValue: String(auto.ctx), autoLabel: `Auto (${(auto.ctx / 1024).toFixed(0)}k)` } : undefined)
|
||||
}
|
||||
async function handleDelete(name: string) {
|
||||
const m = models.find((x) => x.name === name)
|
||||
if (m && isProtected(m.role)) {
|
||||
showAlert("Geschützt", `„${roleMeta(m.role).label}" kann nicht gelöscht werden. Weise die Rolle zuerst einem anderen Modell zu.`)
|
||||
return
|
||||
}
|
||||
showConfirm("Modell löschen?", `„${short(name)}" und alle GGUF-Dateien unwiderruflich von der Box löschen?`, async () => {
|
||||
try { await api(`/api/models/${encodeURIComponent(name)}`, { method: "DELETE" }); if (selName === name) setSelName(null); reload() }
|
||||
catch (e: any) { showAlert("Fehler", `Löschen fehlgeschlagen: ${e.message || e}`) }
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="py-16 text-center text-xs text-muted-foreground">Werkbank wird geladen …</div>
|
||||
if (error) return <div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">Gateway oder Engine nicht erreichbar ({String(error)}).</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<MaschinenraumBar
|
||||
running={runningInfo} capacityBytes={capacity} realUsedBytes={gttUsed}
|
||||
selected={selName} onSelect={setSelName}
|
||||
/>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)]">
|
||||
{/* Master — Liste */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 p-4 shadow-lg shadow-black/15 backdrop-blur-md">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<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/60" />
|
||||
<input
|
||||
value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Modell suchen …"
|
||||
className="h-8 w-full rounded-lg border border-border/40 bg-background/40 pl-8 pr-2 text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-primary/50 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex rounded-lg border border-border/40 bg-background/30 p-0.5">
|
||||
{(["all", "in_use"] as const).map((f) => (
|
||||
<button key={f} onClick={() => setFilter(f)}
|
||||
className={cn("rounded-md px-2 py-1 text-[9px] font-bold uppercase transition-all cursor-pointer",
|
||||
filter === f ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>
|
||||
{f === "all" ? "Alle" : "In Benutzung"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto scrollbar-thin pr-1">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border/50 p-8 text-center text-xs text-muted-foreground">Keine Modelle.</div>
|
||||
) : filtered.map((m) => {
|
||||
const warm = running.includes(m.name)
|
||||
const sel = selName === m.name
|
||||
const brand = getBrandInfo(m.name)
|
||||
const c = roleBarColor(m.role)
|
||||
return (
|
||||
<button key={m.name} onClick={() => setSelName(m.name)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 rounded-xl border p-2.5 text-left transition-all cursor-pointer",
|
||||
sel ? "border-primary/60 bg-primary/10" : "border-border/40 bg-background/20 hover:border-primary/30 hover:bg-background/40",
|
||||
)}>
|
||||
<span className={cn("flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border text-[10px] font-bold", brand.color)}>{brand.initial}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-mono text-xs font-semibold text-foreground">{short(m.name)}</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5">
|
||||
{m.role && <span className={cn("h-2 w-2 rounded-sm", c.dot)} />}
|
||||
<span className="text-[10px] text-muted-foreground">{m.role ? roleMeta(m.role).short : "keine Rolle"}</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">· {fmtSize(m.size_bytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{warm
|
||||
? <span className="flex shrink-0 items-center gap-1 text-[9px] font-bold uppercase text-emerald-400"><span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />warm</span>
|
||||
: m.incomplete
|
||||
? <span className="shrink-0 text-[9px] font-bold uppercase text-amber-400">⚠ fehlt</span>
|
||||
: <span className="shrink-0 text-[9px] font-semibold uppercase text-muted-foreground/50">bereit</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail — verwalten */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 p-5 shadow-lg shadow-black/15 backdrop-blur-md">
|
||||
{!selected ? (
|
||||
<div className="flex h-full min-h-[16rem] flex-col items-center justify-center gap-2 text-center text-muted-foreground">
|
||||
<Cpu className="h-8 w-8 opacity-40" />
|
||||
<p className="text-sm font-semibold">Links ein Modell wählen</p>
|
||||
<p className="text-xs">Dann verwaltest du es hier — laden, Rolle, Kontext, löschen.</p>
|
||||
</div>
|
||||
) : (
|
||||
<Detail
|
||||
m={selected} warm={running.includes(selected.name)} expert={expert}
|
||||
protectedRole={isProtected(selected.role)} coresident={isCoresident(selected.name)}
|
||||
onLoad={() => handleLoad(selected.name)} onUnload={() => handleUnload(selected.name)}
|
||||
onRole={(r) => handleRole(r, selected.name)} onCtx={() => handleCtx(selected.name, selected.ctx)}
|
||||
onSpec={() => setSpecModel(selected)} onDelete={() => handleDelete(selected.name)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{specModel && <SpecDraftModal model={specModel} onClose={() => setSpecModel(null)} onChanged={reload} />}
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Detail({
|
||||
m, warm, expert, protectedRole, coresident, onLoad, onUnload, onRole, onCtx, onSpec, onDelete,
|
||||
}: {
|
||||
m: ModelInfo; warm: boolean; expert: boolean; protectedRole: boolean; coresident: boolean
|
||||
onLoad: () => void; onUnload: () => void; onRole: (r: string) => void; onCtx: () => void; onSpec: () => void; onDelete: () => void
|
||||
}) {
|
||||
const brand = getBrandInfo(m.name)
|
||||
const lockUnload = warm && protectedRole
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
{/* Kopf */}
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={cn("flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border text-sm font-bold", brand.color)}>{brand.initial}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="break-all font-mono text-sm font-bold text-foreground">{short(m.name)}</h2>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
{m.role ? <RoleLabel role={m.role} /> : <span className="rounded border border-dashed border-border/50 px-1.5 py-0.5 text-[10px] text-muted-foreground">keine Rolle</span>}
|
||||
{protectedRole && <span className="rounded bg-background/40 px-1.5 py-0.5 text-[10px] text-muted-foreground" title="Lebenswichtig — geschützt"><Lock className="mr-0.5 inline h-3 w-3" />geschützt</span>}
|
||||
{coresident && <span className="rounded border border-fuchsia-500/30 bg-fuchsia-500/10 px-1.5 py-0.5 text-[10px] font-bold uppercase text-fuchsia-300"><Brain className="mr-0.5 inline h-3 w-3" />Ko-resident</span>}
|
||||
{warm && <span className="flex items-center gap-1 text-[10px] font-bold uppercase text-emerald-400"><span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />warm</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Primär-Aktion: laden / entladen */}
|
||||
<button
|
||||
onClick={warm ? onUnload : onLoad}
|
||||
disabled={(m.incomplete && !warm) || lockUnload}
|
||||
className={cn(
|
||||
"flex h-11 w-full items-center justify-center gap-2 rounded-xl border text-sm font-bold transition-all cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
warm ? "border-amber-500/40 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20"
|
||||
: "border-primary/40 bg-primary/10 text-primary hover:bg-primary/20",
|
||||
)}
|
||||
title={lockUnload ? `„${roleMeta(m.role).label}" bleibt geladen (geschützt).` : undefined}
|
||||
>
|
||||
{warm ? <PowerOff className="h-4 w-4" /> : <Power className="h-4 w-4" />}
|
||||
{lockUnload ? "🔒 bleibt geladen" : warm ? "Entladen" : "Ins Warm-Set laden"}
|
||||
</button>
|
||||
|
||||
{/* Rolle wählen */}
|
||||
<div className="rounded-xl border border-border/30 bg-background/20 p-3">
|
||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Rolle</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<RoleChip active={!m.role} onClick={() => onRole("")} dot="bg-slate-400" label="keine" />
|
||||
{ROLE_META.map((r) => (
|
||||
<RoleChip key={r.role} active={m.role === r.role} onClick={() => onRole(r.role)}
|
||||
dot={roleBarColor(r.role).dot} label={r.short} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kennzahlen */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<Metric icon={HardDrive} label="Größe" value={fmtSize(m.size_bytes)} />
|
||||
<button onClick={onCtx} className="group flex items-center gap-2 rounded-lg border border-border/30 bg-background/20 p-2.5 text-left transition-colors hover:border-primary/40 cursor-pointer">
|
||||
<Edit3 className="h-4 w-4 text-primary/80" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/60">Kontext · ändern</div>
|
||||
<div className="font-semibold text-foreground">{fmtCtx(m.ctx)}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Beschleuniger */}
|
||||
<div className="rounded-xl border border-border/30 bg-background/20 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Beschleuniger</span>
|
||||
{expert && (
|
||||
<button onClick={onSpec}
|
||||
className={cn("flex h-6 items-center gap-1 rounded-lg border px-2 text-[9px] font-bold uppercase transition-colors cursor-pointer",
|
||||
m.spec_active ? "border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10"
|
||||
: m.spec_draft_model ? "border-amber-500/30 text-amber-400 hover:bg-amber-500/10"
|
||||
: "border-border/40 text-muted-foreground hover:bg-accent")}>
|
||||
<Zap className="h-3 w-3" /> Spec
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{m.spec_active && <Tag tone="emerald" label="Spec-Decoding aktiv" />}
|
||||
{!m.spec_active && m.spec_draft_model && <Tag tone="amber" label="Spec inaktiv" />}
|
||||
{m.prompt_cache && <Tag tone="cyan" label="Prompt-Cache" />}
|
||||
{m.parallel_slots > 1 && <Tag tone="violet" label={`${m.parallel_slots} Slots`} />}
|
||||
{!m.spec_active && !m.spec_draft_model && !m.prompt_cache && m.parallel_slots <= 1 && (
|
||||
<span className="text-[11px] text-muted-foreground/60">keine aktiv</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fähigkeiten */}
|
||||
<div className="flex flex-wrap gap-1"><CapsChips caps={m.capabilities} /></div>
|
||||
|
||||
{/* Löschen */}
|
||||
<button onClick={onDelete} disabled={protectedRole}
|
||||
className={cn("mt-auto flex h-9 items-center justify-center gap-1.5 rounded-lg border text-xs font-semibold transition-colors",
|
||||
protectedRole ? "cursor-not-allowed border-border/30 text-muted-foreground/40"
|
||||
: "border-red-500/30 text-red-400 hover:bg-red-500/10 cursor-pointer")}>
|
||||
<Trash2 className="h-3.5 w-3.5" /> {protectedRole ? "geschützt — nicht löschbar" : "Modell löschen"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RoleChip({ active, onClick, dot, label }: { active: boolean; onClick: () => void; dot: string; label: string }) {
|
||||
return (
|
||||
<button onClick={onClick}
|
||||
className={cn("flex items-center gap-1.5 rounded-lg border px-2 py-1 text-[10px] font-semibold transition-all cursor-pointer",
|
||||
active ? "border-primary/60 bg-primary/15 text-foreground" : "border-border/40 bg-background/20 text-muted-foreground hover:border-primary/30")}>
|
||||
<span className={cn("h-2 w-2 rounded-sm", dot)} />
|
||||
{label}
|
||||
{active && <Check className="h-3 w-3 text-primary" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({ icon: Icon, label, value }: { icon: typeof HardDrive; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border/30 bg-background/20 p-2.5">
|
||||
<Icon className="h-4 w-4 text-primary/80" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/60">{label}</div>
|
||||
<div className="font-semibold text-foreground">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Tag({ tone, label }: { tone: "emerald" | "amber" | "cyan" | "violet"; label: string }) {
|
||||
const cls = {
|
||||
emerald: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
|
||||
amber: "bg-amber-500/10 text-amber-400 border-amber-500/20",
|
||||
cyan: "bg-cyan-500/10 text-cyan-400 border-cyan-500/20",
|
||||
violet: "bg-violet-500/10 text-violet-400 border-violet-500/20",
|
||||
}[tone]
|
||||
return <span className={cn("rounded border px-1.5 py-0.5 text-[10px] font-semibold", cls)}>{label}</span>
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Voll deckende Balken-/Punkt-Farben je Rolle — abgeleitet aus den Badge-Tönen in
|
||||
// lib/roleMeta.ts, aber solide (für die Maschinenraum-Speicherleiste). Design-Sprache:
|
||||
// Hirn=Indigo, Gedächtnis=Smaragd, Augen=Pink, Groß=Bernstein, Coder=Violett/Fuchsia.
|
||||
export interface RoleBarColor {
|
||||
bar: string // Segment-Füllung (solide)
|
||||
dot: string // Legenden-Punkt
|
||||
text: string // Akzent-Text
|
||||
}
|
||||
|
||||
const BY_ROLE: Record<string, RoleBarColor> = {
|
||||
hermes: { bar: "bg-indigo-500", dot: "bg-indigo-500", text: "text-indigo-400" },
|
||||
embed: { bar: "bg-emerald-500", dot: "bg-emerald-500", text: "text-emerald-400" },
|
||||
vision: { bar: "bg-pink-500", dot: "bg-pink-500", text: "text-pink-400" },
|
||||
heavy: { bar: "bg-amber-500", dot: "bg-amber-500", text: "text-amber-400" },
|
||||
"coder-lite": { bar: "bg-violet-500", dot: "bg-violet-500", text: "text-violet-400" },
|
||||
coder: { bar: "bg-fuchsia-500", dot: "bg-fuchsia-500", text: "text-fuchsia-400" },
|
||||
scout: { bar: "bg-teal-500", dot: "bg-teal-500", text: "text-teal-400" },
|
||||
}
|
||||
|
||||
const ALIAS: Record<string, string> = { fast: "hermes", coder_lite: "coder-lite" }
|
||||
|
||||
const UNASSIGNED: RoleBarColor = { bar: "bg-slate-400", dot: "bg-slate-400", text: "text-slate-300" }
|
||||
|
||||
export function roleBarColor(role?: string | null): RoleBarColor {
|
||||
if (!role) return UNASSIGNED
|
||||
return BY_ROLE[role] || BY_ROLE[ALIAS[role]] || UNASSIGNED
|
||||
}
|
||||
Reference in New Issue
Block a user