umbau(boxwart): neue Oberflaeche im Cockpit-Stil (Richtung A), Werkzeuge angehoben

Drei Seiten statt zehn: Start, Updates, Modelle — am PC mit Kopfnavigation, am Handy
mit Leiste unten. Umsetzung von Mockup A („Cockpit“), vom User am 23.09. gewaehlt.

- Start: Hauptwarnleuchte + 8 Warnlampen, Rundinstrumente mit Live-Werten aus dem
  Strom (Speicher, Temperatur, Platte) und Laufzeit-Zaehlwerk, Checkliste der
  Waechter-Hinweise mit ihren Knoepfen, Flugplan (heute gelaufen / geplant), Radar-Kasten.
- Updates: Bausteine mit „Laeuft → Neu“ und Zusammenfassung, laufende Auftraege,
  Verlauf der Sonntagslaeufe (neu: GET /api/updates/verlauf), Sicherungen samt
  Zurueckspielen mit Rueckfrage.
- Modelle: Speicherbalken, Rollen Hirn/Coder/Dritte Rolle, wer die Modelle nutzt
  (7 Tage + 24 h je Stunde), Modell-Radar, weitere Eintraege, Modelle selbst suchen.
- Schubladen: Dienste mit Protokoll und Neustart, Einstellungen (HF-Zugang), Hermes-Link.
- Werkzeuge: Vite 8, React 19.3 mit React Compiler 1.0 (Babel), vitest 5, Tailwind 4.3,
  shadcn 4 (Radix) fuer Dialog/Schublade/Knopf, Schriften Barlow/Barlow Condensed/
  JetBrains Mono. Entfernt: recharts, cmdk, Kraftgraph, zustand, Inter, Space Grotesk.
- Startbuendel 115 KB gzip (Budget 140); Updates/Modelle/Schubladen laden bei Bedarf.
- 16 Oberflaechen-Tests (Instrument-Bogen, Hauptleuchte, Zeitformate, Versionen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-23 21:39:07 +02:00
co-authored by Claude Opus 5.5
parent 3d2c9549fb
commit 907289d7dc
223 changed files with 8572 additions and 15393 deletions
+250
View File
@@ -0,0 +1,250 @@
import { useState } from "react"
import { Search } from "lucide-react"
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel"
import { Button } from "@/components/ui/button"
import {
useModellLaden, useModelle, useNutzung, useRadar, useRadarAktion, useRadarSuche, useStart,
} from "@/lib/abfragen"
import { rolleVon } from "@/lib/anzeige"
import { useLetzteMetrik } from "@/lib/strom"
import type { Modell, Nutzung, RadarKandidat } from "@/lib/typen"
import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit"
import { ModellSuche } from "./ModellSuche"
/** Grenze aus der Erfahrung (Memory „llama-swap: nur EINE Gruppe resident“): darüber OOM. */
const GRENZE_GB = 115
function Speicher({ modelle, laufend }: { modelle: Modell[]; laufend: string[] }) {
const m = useLetzteMetrik()
const start = useStart()
const gesamt = gb(m?.ram_total ?? start.data?.box.ram_total) ?? 122.7
const belegt = gb(m?.ram_used ?? start.data?.box.ram_used) ?? 0
const geladen = modelle.filter((x) => laufend.includes(x.name))
const dateien = geladen.reduce((s, x) => s + (gb(x.size_bytes) ?? 0), 0)
const rest = Math.max(0, belegt - dateien)
const pct = (g: number) => `${Math.min(100, (g / gesamt) * 100)}%`
return (
<Panel titel="Speicher" rechts={<span className="ziffern text-sm text-text-2">{Math.round(belegt)} von {Math.round(gesamt)} GB belegt · Grenze ~{GRENZE_GB} GB</span>}>
<div className="relative h-8 rounded-lg bg-erhaben" role="img" aria-label={`Speicher: ${Math.round(belegt)} von ${Math.round(gesamt)} GB belegt`}>
<div className="absolute inset-y-0 left-0 flex overflow-hidden rounded-lg" style={{ width: pct(belegt) }}>
<div className="h-full bg-cyan" style={{ width: `${belegt ? (dateien / belegt) * 100 : 0}%` }} />
<div className="h-full bg-cyan/35" style={{ width: `${belegt ? (rest / belegt) * 100 : 0}%` }} />
</div>
<div className="absolute -top-1.5 -bottom-1.5 w-0.5 bg-bernstein" style={{ left: pct(GRENZE_GB) }} />
</div>
<div className="flex flex-wrap gap-x-6 gap-y-2 text-sm text-text-2">
<span className="flex items-center gap-2"><span className="size-2.5 rounded-sm bg-cyan" /> Geladene Modelle: {Math.round(dateien)} GB Dateien</span>
<span className="flex items-center gap-2"><span className="size-2.5 rounded-sm bg-cyan/35" /> Cache, Dienste, System: {Math.round(rest)} GB</span>
<span className="flex items-center gap-2"><span className="h-3 w-0.5 bg-bernstein" /> Ab hier wird es eng</span>
</div>
</Panel>
)
}
function Wert({ name, wert }: { name: string; wert: string }) {
return (
<div className="flex flex-col gap-1">
<dt className="text-xs text-text-3">{name}</dt>
<dd className="ziffern m-0 text-lg">{wert}</dd>
</div>
)
}
function Rolle({ titel, modell, laeuft, nutzer }: { titel: string; modell?: Modell; laeuft: boolean; nutzer: string }) {
const laden = useModellLaden()
if (!modell) {
return (
<article className="flex flex-col gap-3.5 rounded-2xl border border-dashed border-linie-stark p-5">
<div className="flex items-center justify-between"><span className="schild text-sm text-text-3">{titel}</span><Chip>Frei</Chip></div>
<h3 className="font-anzeige text-xl font-semibold text-text-2">Nicht belegt</h3>
<p className="text-sm text-text-2">Nur belegen, wenn ein Modell hier echten Mehrwert bringt. Das Radar meldet Kandidaten.</p>
</article>
)
}
const kontext = modell.ctx ? Math.round(modell.ctx / Math.max(1, modell.parallel_slots) / 1024) : null
const bilder = Boolean(modell.capabilities?.vision)
return (
<article className="flex flex-col gap-3.5 rounded-2xl border border-linie bg-panel p-5">
<div className="flex items-center justify-between gap-2">
<span className="schild text-sm text-text-3">{titel}</span>
<Chip art={laeuft ? "gruen" : "grau"}>{laeuft ? "Geladen" : "Auf Abruf"}</Chip>
</div>
<h3 className="ziffern m-0 text-lg font-medium break-all">{modell.name}</h3>
<dl className="m-0 grid grid-cols-3 gap-3">
<Wert name="Größe" wert={gbText(modell.size_bytes)} />
<Wert name="Kontext" wert={kontext ? `${kontext}k` : "–"} />
<Wert name="Bilder" wert={bilder ? "ja" : "nein"} />
</dl>
<p className="text-sm text-text-2">Genutzt von {nutzer}.{modell.spec_active ? " Mit Draft-Beschleunigung." : ""}</p>
{!laeuft && (
<Button variant="outline" size="sm" className="mt-auto self-start" onClick={() => laden.mutate(modell.name)} disabled={laden.isPending}>
Jetzt laden
</Button>
)}
</article>
)
}
function NutzungsPanel({ nutzung }: { nutzung: Nutzung | undefined }) {
if (!nutzung) return null
const max = Math.max(1, ...nutzung.absender.map((a) => a.anfragen))
const stunden = nutzung.letzte_24h.map((s) => Object.values(s.je_absender).reduce((a, b) => a + b, 0))
const maxStunde = Math.max(1, ...stunden)
return (
<Panel titel="Wer nutzt die Modelle" rechts={<span className="ziffern text-xs text-text-3">letzte {nutzung.tage} Tage</span>}>
{nutzung.absender.length === 0 && <p className="text-[15px] text-text-2">Keine Anfragen aufgezeichnet.</p>}
<ul className="m-0 flex list-none flex-col gap-3.5 p-0">
{nutzung.absender.map((a) => (
<li key={a.name} className="flex flex-col gap-1.5">
<div className="flex justify-between gap-3 text-sm"><span>{a.name}</span><span className="ziffern">{zahl(a.anfragen)}</span></div>
<div className="h-2 rounded-full bg-erhaben"><div className="h-2 rounded-full bg-cyan" style={{ width: `${(a.anfragen / max) * 100}%` }} /></div>
</li>
))}
</ul>
<div className="flex flex-col gap-1.5">
<span className="schild text-xs text-text-3">Anfragen je Stunde, letzte 24 h</span>
<svg viewBox="0 0 240 64" className="h-16 w-full" role="img" aria-label="Anfragen je Stunde in den letzten 24 Stunden">
{stunden.map((n, i) => {
const h = n ? Math.max(3, (n / maxStunde) * 50) : 2
return <rect key={i} x={i * 10 + 1.5} y={52 - h} width={7} height={h} rx={1.5} fill={n ? "var(--cyan)" : "var(--linie)"} />
})}
{[0, 6, 12, 18].map((s) => (
<text key={s} x={s * 10 + 5} y={63} textAnchor="middle" style={{ fontSize: 9, fill: "var(--text-3)", fontFamily: "var(--font-mono)" }}>{s}</text>
))}
</svg>
</div>
<p className="text-xs text-text-3">Anfragen an llama-swap. NerdQuiz geht direkt dorthin, alles andere über MC2.</p>
</Panel>
)
}
const STATUS_ART: Record<string, ChipArt> = {
bestanden: "gruen", durchgefallen: "rot", wartet: "cyan", neu: "cyan", getestet: "grau", uebernommen: "gruen", verworfen: "grau",
}
function Kandidat({ k }: { k: RadarKandidat }) {
const aktion = useRadarAktion()
const offen = ["bestanden", "wartet", "neu"].includes(k.status)
return (
<li className="flex flex-col gap-2 border-t border-linie py-3.5">
<div className="flex flex-wrap items-center gap-2">
<span className="ziffern text-[15px] font-medium">{k.name}</span>
<Chip art="cyan">{k.rolle === "hirn" ? "fürs Hirn" : "fürs Coden"}</Chip>
<Chip art={k.eng ? "bernstein" : k.passt ? "gruen" : "rot"}>{k.eng ? "passt knapp" : k.passt ? "passt" : "passt nicht"}</Chip>
<Chip art={STATUS_ART[k.status] ?? "grau"}>{k.status}</Chip>
</div>
<p className="text-sm text-text-2">
{k.groesse_gb != null ? `${k.groesse_gb.toLocaleString("de-DE")} GB. ` : ""}
{k.begruendung}
</p>
{offen && (
<div className="flex flex-wrap gap-2">
{k.status === "bestanden" && (
<Button size="sm" disabled={aktion.isPending} onClick={() => aktion.mutate({ id: k.id, aktion: "uebernehmen", name: k.name })}>
Übernehmen
</Button>
)}
<Button variant="outline" size="sm" disabled={aktion.isPending} onClick={() => aktion.mutate({ id: k.id, aktion: "verwerfen", name: k.name })}>
Verwerfen
</Button>
</div>
)}
</li>
)
}
function RadarPanel() {
const radar = useRadar()
const suche = useRadarSuche()
if (radar.isError) {
return (
<Panel titel="Modell-Radar" id="radar">
<p className="text-[15px] text-text-2">Das Radar ist auf dieser Box noch nicht eingerichtet.</p>
</Panel>
)
}
const d = radar.data
return (
<Panel
titel="Modell-Radar"
id="radar"
rechts={<Button variant="info" size="sm" onClick={() => suche.mutate(undefined)} disabled={suche.isPending}>Jetzt suchen</Button>}
>
<p className="-mt-2 text-sm text-text-2">
Testet nachts zwischen 00:30 und 02:30 selbst, höchstens einen Kandidaten pro Woche.
{d?.naechster_test ? ` Nächster Test: ${flugplanZeit(d.naechster_test)}.` : ""}
</p>
{!d ? (
<p className="text-[15px] text-text-2">Wird gelesen …</p>
) : d.kandidaten.length === 0 ? (
<p className="text-[15px] text-text-2">Nichts Besseres in Sicht.</p>
) : (
<ul className="m-0 flex list-none flex-col p-0">
{d.kandidaten.map((k) => <Kandidat key={k.id} k={k} />)}
</ul>
)}
{d && d.tests.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="schild text-xs text-text-3">Letzte Tests</h3>
{d.tests.slice(0, 3).map((t) => (
<p key={t.id} className="text-sm text-text-2">
<span className="ziffern text-text-3">{flugplanZeit(t.datum)}</span> · {t.zusammenfassung}
</p>
))}
</div>
)}
</Panel>
)
}
export function ModelleSeite() {
const modelle = useModelle()
const nutzung = useNutzung()
const [suche, setSuche] = useState(false)
if (modelle.isPending) return <Zustandsfeld text="Modelle werden gelesen …" />
if (modelle.error || !modelle.data) return <Zustandsfeld fehler text={`Die Modell-Liste fehlt: ${modelle.error?.message ?? "keine Daten"}`} />
const liste = modelle.data.models
const laufend = modelle.data.running
const hirn = liste.find((m) => rolleVon(m) === "hirn")
const coder = liste.find((m) => rolleVon(m) === "coder")
const weitere = liste.filter((m) => m !== hirn && m !== coder)
const zuletzt = nutzung.data?.zuletzt_geladen ?? {}
return (
<>
<Speicher modelle={liste} laufend={laufend} />
<section aria-label="Rollen" className="grid gap-5 md:grid-cols-3 md:gap-6">
<Rolle titel="Hirn" modell={hirn} laeuft={!!hirn && laufend.includes(hirn.name)} nutzer="Lucy, NerdQuiz und OpenChamber für Kleinkram" />
<Rolle titel="Coder" modell={coder} laeuft={!!coder && laufend.includes(coder.name)} nutzer="OpenChamber zum Planen und Bauen und Lucys Delegation" />
<Rolle titel="Dritte Rolle" laeuft={false} nutzer="" />
</section>
<div className="grid gap-5 md:gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)]">
<NutzungsPanel nutzung={nutzung.data} />
<RadarPanel />
</div>
<Panel
titel="Weitere Einträge"
rechts={<Button variant="outline" size="sm" onClick={() => setSuche(true)}><Search aria-hidden /> Modelle selbst suchen</Button>}
>
<ul className="m-0 flex list-none flex-col p-0">
{weitere.map((m) => (
<li key={m.name} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-t border-linie py-2.5 sm:grid-cols-[minmax(0,1fr)_120px_160px]">
<span className="min-w-0">
<span className="ziffern block text-sm break-all">{m.name}</span>
<span className="text-xs text-text-3">{m.aliases.length ? m.aliases.join(", ") : "ohne Rolle"}</span>
</span>
<span className="ziffern text-sm text-text-2">{gbText(m.size_bytes)}</span>
<span className="ziffern hidden text-xs text-text-3 sm:block">
{laufend.includes(m.name) ? "geladen" : zuletzt[m.name] ? `zuletzt ${flugplanZeit(zuletzt[m.name])}` : "länger nicht geladen"}
</span>
</li>
))}
</ul>
</Panel>
<ModellSuche offen={suche} onSchliessen={() => setSuche(false)} />
</>
)
}