18afa37412
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>
114 lines
5.1 KiB
TypeScript
114 lines
5.1 KiB
TypeScript
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>
|
|
)
|
|
}
|