Files
mission-control-v2/frontend/src/views/Modelle.tsx
T
HitonabiandClaude Opus 5.5 bc1106ddc8 durchsicht: Rueckfragen passen aufs kleine Handy, ehrlichere Texte, Abbrechen nur wo es stoppt
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>
2026-09-24 17:44:40 +02:00

449 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}}
/>
</>
)
}