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>
251 lines
12 KiB
TypeScript
251 lines
12 KiB
TypeScript
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)} />
|
||
</>
|
||
)
|
||
}
|