Befunde der Schlussdurchsicht (Attrappe des Backends, 320 x 568 bis 1440 px):
- Rueckfragen: Bei 320 x 568 lagen "Installieren"/"Abbrechen" unter dem Bildschirmrand
und liessen sich nicht erreichen (Dialog 791 px hoch, kein Scrollen). Jetzt hoechstens
Bildschirmhoehe, der Text scrollt, die Knoepfe bleiben unten stehen. Gilt fuer alle.
- Installieren: Die Rueckfrage sagt jetzt, dass llama-swap beim Eintragen sofort neu laedt
(--watch-config): laufende Antworten von Lucy/OpenChamber brechen ab, mit Rolle nach dem
Download noch einmal. Der Ersatz-Hinweis nennt, dass das bisherige Modell "heavy" behaelt.
- Abbrechen nur noch bei Downloads (eigener Prozess). Updates und die Suche laufen als
Shell-Kette, ein Abbruch stoppt dort nicht sicher alles.
- Auftragstitel in den Worten der Oberflaeche ("Update Motor (llama.cpp)" statt
"Engine-Update (llama.cpp Vulkan)"); "Laeuft seit ... min" zaehlt weiter, auch wenn der
Auftrag nichts schreibt; Ueberschriftenebene passend zum Panel.
- Updates: "Alles jetzt aktualisieren" richtet sich wie die Zeilen nach den frischen
Details (vorher nur nach dem bis zu 10 min alten Start-Stand); "Nach Neuem suchen"
sperrt, solange eine Suche laeuft (zwei apt-get update stolpern ueber die Sperre);
Rueckfragetext sagt genau, was gesichert wird.
- Nach Auftragsende nur nachladen, was betroffen ist (Download: Modelle/Platte; sonst
Versionen/Verlauf/Sicherungen) - die Update-Pruefung fragt GitHub, apt und git.
- Radar "Uebernehmen"/"Verwerfen" und "Endgueltig loeschen": Ja-Knopf gesperrt, solange
die Aktion laeuft, und ein zweiter Klick waehrend des Schliessens loest nichts mehr aus.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
449 lines
21 KiB
TypeScript
449 lines
21 KiB
TypeScript
import { useEffect, useState } from "react"
|
||
import { useLocation } from "@tanstack/react-router"
|
||
import { Search } from "lucide-react"
|
||
import { Auftraege } from "@/components/cockpit/Auftraege"
|
||
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
|
||
import { Panel } from "@/components/cockpit/Panel"
|
||
import { Button } from "@/components/ui/button"
|
||
import {
|
||
useAufraeumen, useAufraeumenLoeschen, useJobs, useModellLaden, useModelle, useNutzung, useRadar, useRadarAktion,
|
||
useRadarSuche, useStart,
|
||
} from "@/lib/abfragen"
|
||
import { messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige"
|
||
import { ApiFehler } from "@/lib/api"
|
||
import { useLetzteMetrik } from "@/lib/strom"
|
||
import type { AufraeumKandidat, Modell, Nutzung, RadarKandidat, RadarTest } 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, zwilling, laeuft, nutzer }: {
|
||
titel: string
|
||
modell?: Modell
|
||
zwilling?: 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
|
||
// Seit 24.09.: Hirn und Coder sehen über ihren Bild-Zwilling (gleiche Gewichte, ohne Draft).
|
||
const bilder = Boolean(modell.capabilities?.vision) || Boolean(zwilling)
|
||
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." : ""}
|
||
{zwilling ? " Bilder sieht es über seinen Bild-Zwilling, der nur bei Bedarf lädt." : ""}
|
||
</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, laedt, fehler }: { nutzung: Nutzung | undefined; laedt: boolean; fehler: string | null }) {
|
||
if (!nutzung) {
|
||
return (
|
||
<Panel titel="Wer nutzt die Modelle">
|
||
{laedt ? (
|
||
<p className="text-[15px] text-text-2">Wird gelesen …</p>
|
||
) : (
|
||
<p role="alert" className="text-[15px] text-rot-text">Die Nutzung lässt sich gerade nicht lesen{fehler ? `: ${fehler}` : "."}</p>
|
||
)}
|
||
</Panel>
|
||
)
|
||
}
|
||
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",
|
||
}
|
||
const URTEIL_FARBE: Record<string, string> = { besser: "text-gruen", schlechter: "text-rot-text", gleich: "text-text-2" }
|
||
|
||
type Vergleichszeile = { kandidat: unknown; baseline: unknown; einheit?: string; urteil?: string }
|
||
|
||
/** Die Messwerte hinter dem Urteil: Kandidat gegen das heutige Modell der Rolle. */
|
||
function Vergleich({ test }: { test: RadarTest }) {
|
||
const zeilen = Object.entries(test.vergleich ?? {}) as [string, Vergleichszeile][]
|
||
if (zeilen.length === 0) return null
|
||
return (
|
||
<div role="table" aria-label="Messwerte gegen das heutige Modell" className="rounded-lg border border-linie bg-erhaben/50 px-3 py-2 text-sm">
|
||
<div role="row" className="schild grid grid-cols-[minmax(0,1fr)_auto_auto] gap-x-4 pb-1 text-xs text-text-3">
|
||
<span role="columnheader">Messwert</span>
|
||
<span role="columnheader" className="text-right">Kandidat</span>
|
||
<span role="columnheader" className="text-right">Heute</span>
|
||
</div>
|
||
{zeilen.map(([name, z]) => (
|
||
<div key={name} role="row" className="grid grid-cols-[minmax(0,1fr)_auto_auto] gap-x-4 border-t border-linie py-1">
|
||
<span role="rowheader" className="text-text-2">{name}</span>
|
||
<span role="cell" className={`ziffern text-right ${URTEIL_FARBE[z.urteil ?? ""] ?? "text-text-2"}`}>
|
||
{messwert(z.kandidat, z.einheit)}
|
||
</span>
|
||
<span role="cell" className="ziffern text-right text-text-3">{messwert(z.baseline, z.einheit)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest | undefined; heute: string | undefined }) {
|
||
const aktion = useRadarAktion()
|
||
const [frage, setFrage] = useState<null | "uebernehmen" | "verwerfen">(null)
|
||
const offen = ["bestanden", "wartet", "neu", "getestet"].includes(k.status)
|
||
const hirn = k.rolle === "hirn"
|
||
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">{hirn ? "fürs Hirn" : "fürs Coden"}</Chip>
|
||
{k.groesse_gb != null && ( // übersprungene Einträge wurden nie vermessen
|
||
<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"}>{RADAR_STATUS_TEXT[k.status] ?? 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>
|
||
{test && ["bestanden", "durchgefallen"].includes(k.status) && <Vergleich test={test} />}
|
||
{offen && (
|
||
<div className="flex flex-wrap gap-2">
|
||
{k.status === "bestanden" && (
|
||
<Button size="sm" disabled={aktion.isPending} onClick={() => setFrage("uebernehmen")}>
|
||
Übernehmen
|
||
</Button>
|
||
)}
|
||
<Button variant="outline" size="sm" disabled={aktion.isPending} onClick={() => setFrage("verwerfen")}>
|
||
Verwerfen
|
||
</Button>
|
||
</div>
|
||
)}
|
||
<Bestaetigen
|
||
offen={frage === "uebernehmen"}
|
||
titel={`${k.name} übernehmen?`}
|
||
text={hirn
|
||
? `${k.name} wird Lucys Hirn${heute ? ` und löst ${heute} ab` : ""}. NerdQuiz und OpenChamber nutzen ab dann ebenfalls das neue Modell. Hermes startet dafür kurz neu, Lucy ist etwa eine Minute nicht erreichbar. Das alte Modell bleibt auf der Platte.`
|
||
: `${k.name} wird der Coder${heute ? ` und löst ${heute} ab` : ""}. OpenChamber plant und baut ab dann damit. Das alte Modell bleibt auf der Platte.`}
|
||
knopf="Übernehmen"
|
||
gesperrt={aktion.isPending}
|
||
onNein={() => setFrage(null)}
|
||
onJa={() => {
|
||
if (frage !== "uebernehmen") return // zweiter Klick eines Doppelklicks, der Dialog schließt schon
|
||
aktion.mutate({ id: k.id, aktion: "uebernehmen", name: k.name })
|
||
setFrage(null)
|
||
}}
|
||
/>
|
||
<Bestaetigen
|
||
offen={frage === "verwerfen"}
|
||
gefahr
|
||
titel={`${k.name} verwerfen?`}
|
||
text="Die heruntergeladenen Dateien werden gelöscht, und das Radar testet dieses Modell nicht noch einmal."
|
||
knopf="Verwerfen"
|
||
gesperrt={aktion.isPending}
|
||
onNein={() => setFrage(null)}
|
||
onJa={() => {
|
||
if (frage !== "verwerfen") return
|
||
aktion.mutate({ id: k.id, aktion: "verwerfen", name: k.name })
|
||
setFrage(null)
|
||
}}
|
||
/>
|
||
</li>
|
||
)
|
||
}
|
||
|
||
function RadarPanel({ hirn, coder }: { hirn: string | undefined; coder: string | undefined }) {
|
||
const radar = useRadar()
|
||
const suche = useRadarSuche()
|
||
if (radar.isError && !radar.data) {
|
||
// 404 heißt: Diese Box hat kein Radar. Alles andere ist ein Fehler, kein fehlendes Radar.
|
||
const fehlt = radar.error instanceof ApiFehler && radar.error.status === 404
|
||
return (
|
||
<Panel titel="Modell-Radar" id="radar" className="scroll-mt-4">
|
||
{fehlt ? (
|
||
<p className="text-[15px] text-text-2">Das Radar ist auf dieser Box noch nicht eingerichtet.</p>
|
||
) : (
|
||
<p role="alert" className="text-[15px] text-rot-text">Das Radar antwortet gerade nicht: {radar.error.message}</p>
|
||
)}
|
||
</Panel>
|
||
)
|
||
}
|
||
const d = radar.data
|
||
return (
|
||
<Panel
|
||
titel="Modell-Radar"
|
||
id="radar"
|
||
className="scroll-mt-4"
|
||
rechts={
|
||
<Button variant="info" size="sm" onClick={() => suche.mutate(undefined)} disabled={suche.isPending}>
|
||
{suche.isPending ? "Sucht …" : "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}
|
||
test={d.tests.find((t) => t.kandidat === k.id)}
|
||
heute={k.rolle === "hirn" ? hirn : coder}
|
||
/>
|
||
))}
|
||
</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>
|
||
)
|
||
}
|
||
|
||
/** Was ungenutzt Platz belegt. Gelöscht wird nur auf Knopfdruck, mit Rückfrage — Löschen ist endgültig. */
|
||
function AufraeumenPanel() {
|
||
const liste = useAufraeumen()
|
||
const loeschen = useAufraeumenLoeschen()
|
||
const [frage, setFrage] = useState<AufraeumKandidat | null>(null)
|
||
const kandidaten = liste.data?.kandidaten ?? []
|
||
const summe = kandidaten.reduce((s, k) => s + k.groesse_gb, 0)
|
||
if (liste.isError && !liste.data) {
|
||
return (
|
||
<Panel titel="Aufräumen">
|
||
<p role="alert" className="text-[15px] text-rot-text">Was sich aufräumen ließe, lässt sich gerade nicht lesen: {liste.error.message}</p>
|
||
</Panel>
|
||
)
|
||
}
|
||
return (
|
||
<Panel
|
||
titel="Aufräumen"
|
||
rechts={kandidaten.length > 0
|
||
? <span className="ziffern text-sm text-text-2">bis zu {summe.toLocaleString("de-DE", { maximumFractionDigits: 0 })} GB frei</span>
|
||
: undefined}
|
||
>
|
||
<p className="-mt-2 text-sm text-text-2">
|
||
Modelle und Ordner, die gerade niemand nutzt. Nichts davon wird von selbst gelöscht, das entscheidest du je Eintrag.
|
||
</p>
|
||
{liste.isPending ? (
|
||
<p className="text-[15px] text-text-2">Wird gelesen …</p>
|
||
) : kandidaten.length === 0 ? (
|
||
<p className="text-[15px] text-text-2">Nichts aufzuräumen.</p>
|
||
) : (
|
||
<ul className="m-0 flex list-none flex-col p-0">
|
||
{kandidaten.map((k) => (
|
||
<li key={`${k.art}-${k.name}`} className="flex flex-wrap items-center justify-between gap-3 border-t border-linie py-3">
|
||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||
<span className="flex flex-wrap items-center gap-2">
|
||
<span className="ziffern text-sm break-all">{k.name}</span>
|
||
<Chip>{k.art === "eintrag" ? "Eintrag" : "Ordner"}</Chip>
|
||
</span>
|
||
<span className="text-sm text-text-2">{k.hinweis}</span>
|
||
</span>
|
||
<span className="flex items-center gap-3">
|
||
<span className="ziffern text-sm text-text-2">{k.groesse_gb.toLocaleString("de-DE")} GB</span>
|
||
<Button variant="gefahr" size="sm" disabled={loeschen.isPending} onClick={() => setFrage(k)}>
|
||
Löschen
|
||
</Button>
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
<Bestaetigen
|
||
offen={frage !== null}
|
||
gefahr
|
||
titel={`${frage?.name ?? ""} löschen?`}
|
||
text={`${frage?.hinweis ?? ""} Es werden ${frage?.groesse_gb.toLocaleString("de-DE") ?? ""} GB frei. Das lässt sich nicht rückgängig machen — zurück ginge es nur mit einem neuen Download.`}
|
||
knopf="Endgültig löschen"
|
||
gesperrt={loeschen.isPending}
|
||
onNein={() => setFrage(null)}
|
||
onJa={() => {
|
||
if (frage) loeschen.mutate(frage)
|
||
setFrage(null)
|
||
}}
|
||
/>
|
||
</Panel>
|
||
)
|
||
}
|
||
|
||
export function ModelleSeite() {
|
||
const modelle = useModelle()
|
||
const nutzung = useNutzung()
|
||
const radar = useRadar()
|
||
const jobs = useJobs()
|
||
const [suche, setSuche] = useState(false)
|
||
|
||
// „Ansehen“ im Radar-Kasten der Startseite führt nach /modelle#radar. Der Router springt beim
|
||
// Rendern selbst zum Anker — da steht hier aber noch „wird gelesen“. Also springen wir, sobald
|
||
// alles über dem Radar geladen ist und die Seite nicht mehr wächst.
|
||
const hash = useLocation({ select: (l) => l.hash })
|
||
const bereit = !!modelle.data && !nutzung.isPending && !radar.isPending && !jobs.isPending
|
||
useEffect(() => {
|
||
if (bereit && hash === "radar") document.getElementById("radar")?.scrollIntoView({ block: "start" })
|
||
}, [bereit, hash])
|
||
|
||
if (modelle.isPending) return <Zustandsfeld text="Modelle werden gelesen …" />
|
||
// Scheitert nur ein späteres Nachladen, bleibt der letzte Stand stehen.
|
||
if (!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")
|
||
// Bild-Zwilling: gleiche Gewichte, eigener Alias (vision bzw. coder-bild).
|
||
const zwilling = (rolle: Modell | undefined, alias: string) =>
|
||
rolle ? liste.find((m) => m !== rolle && m.aliases.includes(alias) && m.filename === rolle.filename) : undefined
|
||
const hirnZwilling = zwilling(hirn, "vision")
|
||
const coderZwilling = zwilling(coder, "coder-bild")
|
||
const weitere = liste.filter((m) => ![hirn, coder, hirnZwilling, coderZwilling].includes(m))
|
||
const zuletzt = nutzung.data?.zuletzt_geladen ?? {}
|
||
|
||
return (
|
||
<>
|
||
<Speicher modelle={liste} laufend={laufend} />
|
||
<Auftraege alsPanel />
|
||
<section aria-label="Rollen" className="grid gap-5 md:grid-cols-3 md:gap-6">
|
||
<Rolle titel="Hirn" modell={hirn} zwilling={hirnZwilling} laeuft={!!hirn && laufend.includes(hirn.name)} nutzer="Lucy, NerdQuiz und OpenChamber für Kleinkram" />
|
||
<Rolle titel="Coder" modell={coder} zwilling={coderZwilling} 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} laedt={nutzung.isPending} fehler={nutzung.error?.message ?? null} />
|
||
<RadarPanel hirn={hirn?.name} coder={coder?.name} />
|
||
</div>
|
||
<Panel
|
||
titel="Weitere Einträge"
|
||
rechts={<Button variant="outline" size="sm" onClick={() => setSuche(true)}><Search aria-hidden /> Modelle selbst suchen</Button>}
|
||
>
|
||
{weitere.length === 0 && <p className="text-[15px] text-text-2">Keine weiteren Einträge.</p>}
|
||
<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>
|
||
<AufraeumenPanel />
|
||
<ModellSuche
|
||
offen={suche}
|
||
onSchliessen={() => setSuche(false)}
|
||
rollenJetzt={{
|
||
coder: coder
|
||
? { name: coder.name, zwilling: !!coderZwilling, weitere: coder.aliases.filter((a) => a.toLowerCase() !== "coder") }
|
||
: undefined,
|
||
}}
|
||
/>
|
||
</>
|
||
)
|
||
}
|