Merge branch 'worktree-agent-a7ad50f83f2d4236b' into wartung/helfer-nutzung-dienste

This commit is contained in:
Hitonabi
2026-09-25 13:31:28 +02:00
16 changed files with 1552 additions and 101 deletions
@@ -0,0 +1,58 @@
import { fireEvent, render, screen, within } from "@testing-library/react"
import type { Saeule } from "@/lib/diagramm"
import { Saeulen } from "./Saeulen"
const REIHEN = [{ name: "A", farbe: "var(--reihe-1)" }, { name: "B", farbe: "var(--reihe-2)" }, { name: "C", farbe: "var(--reihe-3)" }]
const stunden = Array.from({ length: 24 }, (_, i): Saeule => {
const h = (15 + i) % 24
return {
schluessel: String(i), achse: `${String(h).padStart(2, "0")}:00`, raster: h, lang: `${h} Uhr`,
werte: h === 3 ? { A: 139 } : { A: 5, B: i % 2 },
}
})
/** Der Behälter meldet eine feste Breite, wie ein Handy (jsdom kennt keinen ResizeObserver). */
function breiteVortaeuschen(px: number) {
vi.stubGlobal("ResizeObserver", class {
constructor(private melden: (e: { contentRect: { width: number } }[]) => void) {}
observe() { this.melden([{ contentRect: { width: px } }]) }
disconnect() {}
})
}
afterEach(() => vi.unstubAllGlobals())
describe("Saeulen", () => {
it("dünnt die Stundenbeschriftung aus und nennt nur die höchste Summe", () => {
const { container } = render(<Saeulen saeulen={stunden} reihen={REIHEN} titel="Je Stunde" summen="hoechste" />)
const texte = [...container.querySelectorAll("svg text")].map((t) => t.textContent)
expect(texte).toEqual(expect.arrayContaining(["15:00", "18:00", "00:00", "03:00", "139"]))
expect(texte).not.toContain("16:00")
expect(texte.filter((t) => t === "6")).toHaveLength(0) // die übrigen Summen stehen nur in Anzeige und Tabelle
})
it("zeigt in der Legende nur Reihen mit Werten", () => {
render(<Saeulen saeulen={stunden} reihen={REIHEN} titel="Je Stunde" />)
expect(within(screen.getByRole("list", { name: "Legende" })).getAllByRole("listitem").map((l) => l.textContent))
.toEqual(["A", "B"])
})
it("stellt die Anzeige auf dem Handy unter das Diagramm statt darüber", () => {
breiteVortaeuschen(300)
render(<Saeulen saeulen={stunden} reihen={REIHEN} titel="Je Stunde" />)
const bild = screen.getByRole("img", { name: "Je Stunde" })
expect(bild).toHaveAttribute("width", "300")
fireEvent.focus(bild)
const anzeige = screen.getByRole("status")
expect(anzeige).not.toHaveClass("absolute")
expect(anzeige).toHaveTextContent("14 Uhr")
expect(bild.compareDocumentPosition(anzeige) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it("sagt es, wenn es nichts zu zeigen gibt", () => {
render(<Saeulen saeulen={[{ schluessel: "x", achse: "Sa", lang: "Samstag", werte: {} }]} reihen={REIHEN} titel="Leer" />)
expect(screen.getByText("Keine Anfragen in diesem Zeitraum")).toBeInTheDocument()
expect(screen.queryByRole("list", { name: "Legende" })).not.toBeInTheDocument()
})
})
@@ -0,0 +1,225 @@
import { type KeyboardEvent, type PointerEvent, useState } from "react"
import { cn } from "cn"
import { useBreite } from "@/lib/breite"
import { type Saeule, saeulenPfad, stapeln, zaehlAchse } from "@/lib/diagramm"
export interface SaeulenReihe {
name: string
/** CSS-Farbe der Säulenstücke, z. B. var(--reihe-1). Text trägt sie nie — nur die Marke daneben. */
farbe: string
}
const zahl = new Intl.NumberFormat("de-DE")
/** Breite der Anzeige in px (w-60) — fest, damit sie sich ohne Messen im Diagramm halten lässt. */
const TIPP_BREITE = 240
/** Säulen bleiben schmal; der Rest des Platzes ist Luft. */
const SAEULE_MAX = 24
const SCHRITT: Record<string, (i: number, n: number) => number> = {
ArrowLeft: (i) => Math.max(0, i - 1),
ArrowRight: (i, n) => Math.min(n - 1, i + 1),
Home: () => 0,
End: (_, n) => n - 1,
}
/** Die Werte einer Säule: Zahl vorn, Name dahinter, die Farbe nur als kurzer Strich. */
function Anzeige({ saeule, reihen, summe, className, links }: {
saeule: Saeule
reihen: SaeulenReihe[]
summe: number
className?: string
links?: number
}) {
return (
<div role="status" className={cn("flex flex-col gap-1 rounded-lg border border-linie-stark bg-erhaben px-3 py-2", className)}
style={links === undefined ? undefined : { left: links }}>
<span className="ziffern text-xs text-text-3">{saeule.lang}</span>
{reihen.map((r) => (
<span key={r.name} className="flex items-baseline gap-2 text-sm">
<span aria-hidden className="h-0.5 w-3 shrink-0 self-center rounded-full" style={{ background: r.farbe }} />
<span className="ziffern font-semibold text-foreground">{zahl.format(saeule.werte[r.name] ?? 0)}</span>
<span className="min-w-0 text-text-3">{r.name}</span>
</span>
))}
{reihen.length > 1 && (
<span className="flex items-baseline gap-2 border-t border-linie pt-1 text-sm">
<span className="ziffern font-semibold text-foreground">{zahl.format(summe)}</span>
<span className="text-text-3">zusammen</span>
</span>
)}
</div>
)
}
/**
* Gestapelte Säulen für Zählwerte (Anfragen je Tag, je Stunde): eine Säule je Zeitraum, darin ein Stück je Reihe in
* fester Farbe, 2 px Luft zwischen den Stücken. Zeiger, Finger oder Pfeiltasten wählen eine Säule; die Anzeige nennt
* dann alle Reihen und die Summe. Darunter stehen die Werte zum Aufklappen als Tabelle — die Anzeige ist nie der
* einzige Weg zu einer Zahl.
*/
export function Saeulen({ saeulen, reihen, titel, summen = "alle", hoehe = 200, spalte = "Zeitraum" }: {
saeulen: Saeule[]
/** In Stapelfolge, unten zuerst. */
reihen: SaeulenReihe[]
titel: string
/** Summe über jeder Säule (bei wenigen Säulen) oder nur über der höchsten. */
summen?: "alle" | "hoechste"
hoehe?: number
/** Kopf der ersten Tabellenspalte, z. B. „Tag“ oder „Stunde“. */
spalte?: string
}) {
const [ref, breite] = useBreite()
const [index, setIndex] = useState<number | null>(null)
const n = saeulen.length
// Nur Reihen, die im Zeitraum vorkommen — sonst stünden Farben ohne Säulen in der Legende.
const sichtbar = reihen.filter((r) => saeulen.some((s) => (s.werte[r.name] ?? 0) > 0))
const summenListe = saeulen.map((s) => sichtbar.reduce((a, r) => a + (s.werte[r.name] ?? 0), 0))
const max = Math.max(0, ...summenListe)
const ticks = zaehlAchse(max)
const oben = ticks[ticks.length - 1]
const rand = {
links: Math.max(28, zahl.format(oben).length * 7 + 12),
rechts: 4,
oben: 20,
unten: saeulen.some((s) => s.achse2) ? 34 : 22,
}
const innenB = Math.max(1, breite - rand.links - rand.rechts)
const innenH = hoehe - rand.oben - rand.unten
const band = innenB / Math.max(1, n)
const dicke = Math.min(SAEULE_MAX, Math.max(3, band * 0.72))
const y = (v: number) => rand.oben + innenH - (v / oben) * innenH
const grund = rand.oben + innenH
const mitte = (i: number) => rand.links + (i + 0.5) * band
// Beschriftung ausdünnen, bis sie sich nicht berührt; bei Stunden landet sie so auf 0, 3, 6 … bzw. 0, 6, 12, 18 Uhr.
const noetig = Math.max(1, ...saeulen.map((s) => s.achse.length)) * 6.6 + 8
const jede = [1, 3, 6, 12].find((k) => k * band >= noetig) ?? 24
const hoechste = summenListe.indexOf(max)
const eng = band < 34
function zeigen(e: PointerEvent<SVGRectElement>) {
if (n === 0) return
const r = e.currentTarget.getBoundingClientRect()
setIndex(Math.min(n - 1, Math.max(0, Math.floor(((e.clientX - r.left) / r.width) * n))))
}
function taste(e: KeyboardEvent<SVGSVGElement>) {
const schritt = SCHRITT[e.key]
if (n === 0 || !schritt) return
e.preventDefault()
setIndex((i) => schritt(i ?? n - 1, n))
}
const aktiv = index !== null ? saeulen[index] : undefined
// Ab Tablet-Breite schwebt die Anzeige neben der Säule, auf der Seite mit mehr Platz und immer ganz im Diagramm.
// Auf dem Handy bedeckte sie das halbe Diagramm — dort steht sie nach dem Antippen darunter.
const schwebend = breite >= 360
const tippLinks = index === null ? 0 : Math.max(0, Math.min(breite - TIPP_BREITE,
mitte(index) + 12 + TIPP_BREITE > breite ? mitte(index) - 12 - TIPP_BREITE : mitte(index) + 12))
return (
<div className="flex flex-col gap-2">
{sichtbar.length > 1 && (
<ul className="m-0 flex list-none flex-wrap gap-x-4 gap-y-1 p-0 text-sm text-text-2" aria-label="Legende">
{sichtbar.map((r) => (
<li key={r.name} className="flex items-center gap-2">
<span aria-hidden className="size-2.5 shrink-0 rounded-[2px]" style={{ background: r.farbe }} />
{r.name}
</li>
))}
</ul>
)}
<div ref={ref} className="relative w-full">
<svg
width={breite}
height={hoehe}
viewBox={`0 0 ${breite} ${hoehe}`}
role="img"
aria-label={titel}
tabIndex={0}
onKeyDown={taste}
onFocus={() => setIndex((i) => i ?? (n ? n - 1 : null))}
onBlur={() => setIndex(null)}
className="block h-auto max-w-full outline-none focus-visible:rounded-md focus-visible:ring-2 focus-visible:ring-cyan/60"
>
{aktiv && index !== null && (
<rect x={rand.links + index * band} y={rand.oben - 4} width={band} height={innenH + 4} rx={4} fill="var(--erhaben)" />
)}
{ticks.map((v) => (
<g key={v}>
<line x1={rand.links} x2={breite - rand.rechts} y1={y(v)} y2={y(v)} strokeWidth={1}
stroke={v === 0 ? "var(--linie-stark)" : "var(--linie)"} />
<text x={rand.links - 8} y={y(v) + 4} textAnchor="end" className="fill-text-3 font-mono text-[11px]">
{zahl.format(v)}
</text>
</g>
))}
{saeulen.map((s, i) => {
const x = mitte(i) - dicke / 2
return (
<g key={s.schluessel}>
{stapeln(sichtbar.map((r) => s.werte[r.name] ?? 0), y).filter((t) => t.hoehe > 0).map((t) =>
t.oben ? (
<path key={t.index} d={saeulenPfad(x, t.y, dicke, t.hoehe)} fill={sichtbar[t.index].farbe} />
) : (
<rect key={t.index} x={x} y={t.y} width={dicke} height={t.hoehe} fill={sichtbar[t.index].farbe} />
))}
{summenListe[i] > 0 && (summen === "alle" || i === hoechste) && (
<text x={mitte(i)} y={y(summenListe[i]) - 6} textAnchor="middle"
className={cn("fill-text-2 font-mono", eng ? "text-[10px]" : "text-[11px]")}>
{zahl.format(summenListe[i])}
</text>
)}
{(s.raster ?? i) % jede === 0 && (
<text x={mitte(i)} y={grund + 15} textAnchor="middle" className="fill-text-3 font-mono text-[11px]">
{s.achse}
{s.achse2 && <tspan x={mitte(i)} dy={13} className="text-[10px]">{s.achse2}</tspan>}
</text>
)}
</g>
)
})}
{max === 0 && (
<text x={rand.links + innenB / 2} y={rand.oben + innenH / 2} textAnchor="middle" className="fill-text-3 text-sm">
Keine Anfragen in diesem Zeitraum
</text>
)}
{/* Trefferfläche: das ganze Band einer Säule samt Beschriftung — niemand muss ein schmales Stück treffen. */}
<rect x={rand.links} y={0} width={innenB} height={hoehe} fill="transparent"
onPointerMove={zeigen} onPointerDown={zeigen}
onPointerLeave={(e) => { if (e.pointerType !== "touch") setIndex(null) }} />
</svg>
{aktiv && index !== null && schwebend && (
<Anzeige saeule={aktiv} reihen={sichtbar} summe={summenListe[index]} links={tippLinks}
className="pointer-events-none absolute top-1 z-10 w-60 shadow-lg" />
)}
</div>
{aktiv && index !== null && !schwebend && <Anzeige saeule={aktiv} reihen={sichtbar} summe={summenListe[index]} />}
<details className="text-sm">
<summary className="inline-flex cursor-pointer items-center text-text-3 hover:text-text-2">Werte als Tabelle</summary>
<div className="mt-2 overflow-x-auto">
<table className="w-full border-collapse text-xs">
<thead>
<tr className="text-text-3">
<th scope="col" className="py-1 pr-3 text-left font-normal">{spalte}</th>
{sichtbar.map((r) => (
<th key={r.name} scope="col" className="py-1 pr-3 text-right font-normal [overflow-wrap:anywhere]">{r.name}</th>
))}
{sichtbar.length > 1 && <th scope="col" className="py-1 text-right font-normal">Zusammen</th>}
</tr>
</thead>
<tbody>
{saeulen.map((s, i) => (
<tr key={s.schluessel} className="border-t border-linie">
<th scope="row" className="py-1 pr-3 text-left font-normal text-text-3">{s.lang}</th>
{sichtbar.map((r) => (
<td key={r.name} className="ziffern py-1 pr-3 text-right text-text-2">{zahl.format(s.werte[r.name] ?? 0)}</td>
))}
{sichtbar.length > 1 && <td className="ziffern py-1 text-right text-foreground">{zahl.format(summenListe[i])}</td>}
</tr>
))}
</tbody>
</table>
</div>
</details>
</div>
)
}
+2 -15
View File
@@ -1,4 +1,5 @@
import { type KeyboardEvent, type PointerEvent, useEffect, useRef, useState } from "react"
import { type KeyboardEvent, type PointerEvent, useState } from "react"
import { useBreite } from "@/lib/breite"
import { flaechenPfad, linienPfad } from "@/lib/diagramm"
import { type Einheit, obergrenze, wertText, zeitText } from "@/lib/messung"
import type { Messpunkt } from "@/lib/typen"
@@ -12,20 +13,6 @@ export interface Reihe {
const RAND = { rechts: 12, oben: 10, unten: 24 }
/** Breite des Behälters; ohne ResizeObserver (Tests) bleibt es beim Standard. */
function useBreite(standard = 640) {
const ref = useRef<HTMLDivElement>(null)
const [breite, setBreite] = useState(standard)
useEffect(() => {
const el = ref.current
if (!el || typeof ResizeObserver === "undefined") return
const beobachter = new ResizeObserver(([e]) => setBreite(Math.max(240, Math.round(e.contentRect.width))))
beobachter.observe(el)
return () => beobachter.disconnect()
}, [])
return [ref, breite] as const
}
/**
* Zeitverlauf einer oder zweier Messreihen: dünne Linien, bei einer Reihe eine zarte Fläche, haarfeines Raster.
* Ein Fadenkreuz folgt Zeiger oder Pfeiltasten und zeigt alle Reihen am selben Zeitpunkt. Darunter liegen die
+6
View File
@@ -146,6 +146,12 @@
erste Reihe Blau, zweite Magenta. Zustandsfarben bleiben für Zustände. */
--reihe-1: #3987e5;
--reihe-2: #d55181;
/* Dritte Reihe Gelb (Modell-Nutzung, 25.09.2026): mit Blau und Magenta auch bei Rot-Grün-Schwäche jedes Paar
sicher getrennt (ΔE ≥ 13 in OKLab, Kontrast ≥ 4,5:1). Türkis fiel durch (neben Magenta bei Grünschwäche ΔE 1,6),
Violett gleicht der KI-Box-Farbe und neben Blau (ΔE 1,9). Ab der vierten Reihe fasst „Weitere“ grau zusammen —
hell genug, dass es sich auch bei Farbfehlsichtigkeit von Magenta abhebt (ΔE 8,6). */
--reihe-3: #c98500;
--reihe-weitere: #9aa3ad;
--aus-grund: #171b1f;
--aus-rand: #2e343a;
--aus-text: #9aa3ad;
+15
View File
@@ -0,0 +1,15 @@
import { useEffect, useRef, useState } from "react"
/** Breite des Behälters für die Diagramme; ohne ResizeObserver (Tests) bleibt es beim Standard. */
export function useBreite(standard = 640) {
const ref = useRef<HTMLDivElement>(null)
const [breite, setBreite] = useState(standard)
useEffect(() => {
const el = ref.current
if (!el || typeof ResizeObserver === "undefined") return
const beobachter = new ResizeObserver(([e]) => setBreite(Math.max(240, Math.round(e.contentRect.width))))
beobachter.observe(el)
return () => beobachter.disconnect()
}, [])
return [ref, breite] as const
}
+49
View File
@@ -0,0 +1,49 @@
import { saeulenPfad, stapeln, zaehlAchse } from "@/lib/diagramm"
describe("zaehlAchse", () => {
it("teilt in glatte, ganzzahlige Schritte mit höchstens fünf Strichen", () => {
expect(zaehlAchse(774)).toEqual([0, 200, 400, 600, 800])
expect(zaehlAchse(139)).toEqual([0, 50, 100, 150])
expect(zaehlAchse(68)).toEqual([0, 20, 40, 60, 80])
expect(zaehlAchse(12)).toEqual([0, 5, 10, 15])
expect(zaehlAchse(5)).toEqual([0, 2, 4, 6])
expect(zaehlAchse(4)).toEqual([0, 1, 2, 3, 4])
expect(zaehlAchse(1)).toEqual([0, 1])
})
it("hält auch ohne Werte eine Achse", () => {
expect(zaehlAchse(0)).toEqual([0, 1])
expect(zaehlAchse(Number.NaN)).toEqual([0, 1])
expect(zaehlAchse(Number.POSITIVE_INFINITY)).toEqual([0, 1])
})
})
describe("saeulenPfad", () => {
it("rundet nur die oberen Ecken", () => {
expect(saeulenPfad(10, 20, 24, 100)).toBe("M10,120V24Q10,20 14,20H30Q34,20 34,24V120Z")
})
it("kappt den Radius bei flachen Säulen", () => {
expect(saeulenPfad(0, 50, 24, 2)).toBe("M0,52V52Q0,50 2,50H22Q24,50 24,52V52Z")
})
})
describe("stapeln", () => {
const y = (v: number) => 100 - v // 1 px je Einheit, Grundlinie bei 100
it("stapelt von unten nach oben mit 2 px Luft und lässt Nullen aus", () => {
expect(stapeln([50, 0, 10], y)).toEqual([
{ index: 0, y: 50, hoehe: 50, oben: false },
{ index: 2, y: 40, hoehe: 8, oben: true },
])
})
it("rundet das oberste sichtbare Stück, auch wenn darüber ein zu kleines liegt", () => {
const stuecke = stapeln([50, 10, 1], y)
expect(stuecke.map((s) => [s.index, s.hoehe, s.oben])).toEqual([[0, 50, false], [1, 8, true], [2, 0, false]])
})
it("liefert für eine leere Säule nichts", () => {
expect(stapeln([0, 0], y)).toEqual([])
})
})
+60
View File
@@ -37,3 +37,63 @@ export function flaechenPfad(
schliessen()
return pfad
}
// --- Gestapelte Säulen (Modell-Nutzung, seit 25.09.2026) ------------------------------------------------
/** Eine Säule eines gestapelten Säulendiagramms: ein Tag, eine Stunde … mit einem Wert je Reihe. */
export interface Saeule {
schluessel: string
/** Beschriftung an der Achse, z. B. „Sa“ oder „15:00“. */
achse: string
/** Zweite Zeile an der Achse, z. B. „19.“ oder „heute“. */
achse2?: string
/** Ausführlich für Tooltip und Tabelle, z. B. „Samstag, 19.09.“. */
lang: string
/** Wonach die Beschriftungen ausgedünnt werden (die Stunde 0–23); ohne: die Nummer der Säule. */
raster?: number
/** Reihenname → Wert */
werte: Record<string, number>
}
/** Glatte Teilung der y-Achse für Zählwerte: höchstens fünf Striche in Schritten von 1, 2, 2,5 oder 5 × 10ⁿ,
* immer ganzzahlig; der oberste liegt auf oder knapp über dem Größten. */
export function zaehlAchse(max: number): number[] {
if (!Number.isFinite(max) || max <= 0) return [0, 1]
for (let e = 0; ; e++) {
for (const f of [1, 2, 2.5, 5]) {
const schritt = f * 10 ** e
if (!Number.isInteger(schritt)) continue
const anzahl = Math.ceil(max / schritt)
if (anzahl <= 4) return Array.from({ length: anzahl + 1 }, (_, i) => i * schritt)
}
}
}
const px = (v: number) => Number(v.toFixed(1))
/** Umriss einer Säule mit runden oberen Ecken — die Daten enden oben, unten steht sie gerade auf der Grundlinie. */
export function saeulenPfad(x: number, y: number, breite: number, hoehe: number, radius = 4): string {
const r = Math.max(0, Math.min(radius, breite / 2, hoehe))
return `M${px(x)},${px(y + hoehe)}V${px(y + r)}Q${px(x)},${px(y)} ${px(x + r)},${px(y)}` +
`H${px(x + breite - r)}Q${px(x + breite)},${px(y)} ${px(x + breite)},${px(y + r)}V${px(y + hoehe)}Z`
}
/**
* Die Stücke einer gestapelten Säule von unten nach oben (y wächst nach unten). Jedes Stück über einem anderen
* hält 2 px Abstand — die Lücke trennt die Farben, kein Rand. Nur Werte > 0; `oben` trägt das oberste sichtbare
* Stück, es bekommt die runden Ecken.
*/
export function stapeln(werte: number[], y: (v: number) => number, luecke = 2) {
const stuecke: { index: number; y: number; hoehe: number; oben: boolean }[] = []
let summe = 0
werte.forEach((wert, index) => {
if (!(wert > 0)) return
const unten = y(summe)
summe += wert
const oben = y(summe)
stuecke.push({ index, y: oben, hoehe: Math.max(0, unten - oben - (stuecke.length ? luecke : 0)), oben: false })
})
const oberstes = stuecke.filter((s) => s.hoehe >= 1).at(-1)
if (oberstes) oberstes.oben = true
return stuecke
}
+123
View File
@@ -0,0 +1,123 @@
import { anteilText, aufbereiten, dauerText, fehlerStufe, fehlerText } from "@/lib/nutzung"
import type { Nutzung } from "@/lib/typen"
import { nutzungBeispiel } from "@/test/nutzung"
const NQ = "NerdQuiz auf Arcane"
const LUCY = "Lucy und OpenChamber über MC2"
describe("Texte der Nutzung", () => {
it("schreibt Dauern so, wie man sie liest", () => {
expect(dauerText(0.388)).toBe("388 ms")
expect(dauerText(1.231)).toBe("1,2 s")
expect(dauerText(4.037)).toBe("4 s")
expect(dauerText(15.422)).toBe("15 s")
expect(dauerText(62)).toBe("1 min 2 s")
expect(dauerText(120)).toBe("2 min")
expect(dauerText(6517.5, true)).toBe("1 h 49 min")
expect(dauerText(2136.7, true)).toBe("36 min")
expect(dauerText(37.1, true)).toBe("37 s")
expect(dauerText(null)).toBe("–")
})
it("schreibt Anteile deutsch und nie als 0 %, wenn es welche gibt", () => {
expect(anteilText(4032 / 4364)).toBe("92 %")
expect(anteilText(325 / 4364)).toBe("7,4 %")
expect(anteilText(1 / 4364)).toBe("unter 0,1 %")
expect(anteilText(0)).toBe("0 %")
})
it("nennt Fehler in Worten, häufigste zuerst", () => {
expect(fehlerText({ "502": 1, "499": 2 })).toBe("2× vom Absender abgebrochen (499) · 1× Modell nicht erreichbar (502)")
expect(fehlerText({ "418": 1 })).toBe("1× Status 418")
expect(fehlerText(undefined)).toBe("")
})
it("färbt die Fehlerquote erst ab 1 % und sagt es dazu", () => {
expect(fehlerStufe(null)).toEqual({ art: "grau", wort: null })
expect(fehlerStufe(1 / 4364)).toEqual({ art: "grau", wort: null })
expect(fehlerStufe(0.02)).toEqual({ art: "bernstein", wort: "auffällig" })
expect(fehlerStufe(0.08)).toEqual({ art: "rot", wort: "hoch" })
})
})
describe("aufbereiten", () => {
it("rechnet Anteile an Anfragen und Rechenzeit je Absender", () => {
const bild = aufbereiten(nutzungBeispiel())
const [nq, lucy, pc] = bild.absender
expect(nq).toMatchObject({ name: NQ, farbe: "var(--reihe-1)", anfragen: 4032, fehler: 1, typisch: 1.231, p90: 3.305 })
expect(nq.anteil).toBeCloseTo(0.924, 3)
expect(nq.zeitAnteil).toBeCloseTo(0.75, 2)
expect(lucy.farbe).toBe("var(--reihe-2)")
expect(pc.farbe).toBe("var(--reihe-3)")
expect(bild.reihen.map((r) => r.name)).toEqual([NQ, LUCY, "NerdQuiz-Tests vom PC"])
expect(bild.gesamt).toMatchObject({ anfragen: 4364, fehler: 1, typisch: 1.27, p90: 4.037 })
})
it("macht aus den Tagen sieben Säulen, heute als letzte", () => {
const bild = aufbereiten(nutzungBeispiel())
expect(bild.tage.map((t) => t.achse)).toEqual(["Sa", "So", "Mo", "Di", "Mi", "Do", "Fr"])
expect(bild.tage.map((t) => t.achse2)).toEqual(["19.", "20.", "21.", "22.", "23.", "24.", "heute"])
expect(bild.tage[0]).toMatchObject({ lang: "Samstag, 19.09.", werte: { [NQ]: 535, [LUCY]: 15 } })
expect(bild.tage[6].lang).toBe("Freitag, 25.09. (heute bis 13:03)")
expect(bild.heute).toBe(444)
expect(bild.gestern).toBe(774)
expect(bild.schnitt).toBeCloseTo(3920 / 6)
expect(bild.zeitraum).toBe("seit Sa 19.09.")
expect(bild.stand).toBe("13:03")
})
it("legt die Stunden von gestern nach heute, die laufende rechts", () => {
const bild = aufbereiten(nutzungBeispiel())
expect(bild.stunden).toHaveLength(24)
expect(bild.stunden[0]).toMatchObject({ achse: "14:00", raster: 14, lang: "Gestern 14:00–15:00" })
expect(bild.stunden[13]).toMatchObject({ achse: "03:00", lang: "Heute 03:00–04:00", werte: { [NQ]: 139 } })
expect(bild.stunden[23].lang).toBe("Heute 13:00 bis jetzt")
})
it("lässt die Farbe beim Absender, auch wenn sich die Rangfolge dreht", () => {
const n = nutzungBeispiel()
n.absender = [{ ...n.absender[1], anfragen: 5000 }, n.absender[0], n.absender[2]]
const bild = aufbereiten(n)
expect(bild.absender.map((a) => [a.name, a.farbe])).toEqual([
[LUCY, "var(--reihe-2)"], [NQ, "var(--reihe-1)"], ["NerdQuiz-Tests vom PC", "var(--reihe-3)"],
])
// Gestapelt bleibt NerdQuiz unten.
expect(bild.reihen.map((r) => r.name)).toEqual([NQ, LUCY, "NerdQuiz-Tests vom PC"])
})
it("fasst Absender ab der vierten Farbe als „Weitere“ zusammen", () => {
const n = nutzungBeispiel()
n.absender.push({ name: "10.0.0.9", anfragen: 3, reihe: 3, zuletzt: "2026-09-25T09:00:00+02:00" })
n.je_tag["2026-09-25"] = { ...n.je_tag["2026-09-25"], "10.0.0.9": 3 }
const bild = aufbereiten(n)
expect(bild.absender[3]).toMatchObject({ name: "10.0.0.9", reihe: "Weitere", farbe: "var(--reihe-weitere)" })
expect(bild.reihen.at(-1)).toEqual({ name: "Weitere", farbe: "var(--reihe-weitere)" })
expect(bild.tage[6].werte.Weitere).toBe(3)
})
it("kommt mit dem Stand der Box vor dem 25.09. zurecht", () => {
const alt: Nutzung = {
tage: 7,
absender: [{ name: NQ, anfragen: 10 }, { name: LUCY, anfragen: 5 }],
je_tag: { "2026-09-24": { [NQ]: 6 }, "2026-09-25": { [NQ]: 4, [LUCY]: 5 } },
letzte_24h: Array.from({ length: 24 }, (_, h): Nutzung["letzte_24h"][number] => ({
stunde: String(h).padStart(2, "0"),
je_absender: h === 3 ? { [NQ]: 4 } : h === 13 ? { [LUCY]: 5 } : h === 14 ? { [NQ]: 6 } : {},
})),
zuletzt_geladen: { "Qwen3-Reranker-0.6B": "2026-09-24T14:52:19+02:00", "Qwen3.6-35B-A3B": "2026-09-25T11:25:46+02:00" },
}
const bild = aufbereiten(alt, new Date("2026-09-25T13:10:00+02:00"))
expect(bild.absender[0]).toMatchObject({ farbe: "var(--reihe-1)", typisch: null, fehler: null, zeitAnteil: null })
expect(bild.gesamt).toMatchObject({ anfragen: 15, fehler: null, typisch: null })
expect(bild.stunden[0]).toMatchObject({ achse: "14:00", werte: { [NQ]: 6 } })
expect(bild.stunden[23]).toMatchObject({ achse: "13:00", werte: { [LUCY]: 5 } })
expect(bild.modelle).toEqual([
{ modell: "Qwen3.6-35B-A3B", anzahl: null, zeiten: ["2026-09-25T11:25:46+02:00"] },
{ modell: "Qwen3-Reranker-0.6B", anzahl: null, zeiten: ["2026-09-24T14:52:19+02:00"] },
])
expect(bild.zeitraum).toBe("letzte 7 Tage")
expect(bild.stand).toBeNull()
expect(bild.heute).toBe(9)
expect(bild.gestern).toBe(6)
})
})
+264
View File
@@ -0,0 +1,264 @@
import type { Saeule } from "@/lib/diagramm"
import type { Nutzung } from "@/lib/typen"
// Aufbereitung von GET /api/modelle/nutzung für das Panel „Wer nutzt die Modelle“ — ohne DOM, damit sie sich
// testen lässt. Ältere Stände der Box liefern nur Anzahlen (vor dem 25.09.2026); dann bleiben Antwortzeiten
// und Fehler leer („–“), alles andere geht wie gewohnt.
const ZONE = "Europe/Berlin"
/** Farben der Absender in fester Reihe (Tokens in index.css). Ab der vierten fasst „Weitere“ sie grau zusammen. */
export const FARBEN = ["var(--reihe-1)", "var(--reihe-2)", "var(--reihe-3)"] as const
export const WEITERE = { name: "Weitere", farbe: "var(--reihe-weitere)" } as const
const ganzzahl = new Intl.NumberFormat("de-DE")
const eineStelle = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 })
const tagIso = new Intl.DateTimeFormat("en-CA", { timeZone: ZONE })
const uhr = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: ZONE })
const stundeTeile = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", hourCycle: "h23", timeZone: ZONE })
// Kalendertage kommen als „2026-09-19“; mittags in UTC gelesen, damit keine Zone den Tag verschiebt.
const wochentagKurz = new Intl.DateTimeFormat("de-DE", { weekday: "short", timeZone: "UTC" })
const wochentagLang = new Intl.DateTimeFormat("de-DE", { weekday: "long", timeZone: "UTC" })
/** Deutsche Zahl mit Tausenderpunkt: „4.364“. */
export const anzahlText = (n: number) => ganzzahl.format(n)
/** Anteil (0…1) in Prozent: „92 %“, „7,4 %“, „unter 0,1 %“. */
export function anteilText(anteil: number): string {
if (!(anteil > 0)) return "0 %"
const p = anteil * 100
if (p < 0.1) return "unter 0,1 %"
return `${p < 10 ? eineStelle.format(p) : ganzzahl.format(Math.round(p))} %`
}
/** Sekunden lesbar: „388 ms“, „1,2 s“, „15 s“, „1 min 2 s“, „1 h 49 min“. `grob` lässt die Sekunden ab einer
* Minute weg (für Summen wie die Rechenzeit). */
export function dauerText(s: number | null | undefined, grob = false): string {
if (s == null || !Number.isFinite(s) || s < 0) return "–"
if (Math.round(s * 1000) < 1000) return `${Math.round(s * 1000)} ms`
if (s < 9.95) return `${eineStelle.format(s)} s`
const sek = Math.round(s)
if (sek < 60) return `${sek} s`
if (sek < 3600 && !grob) return sek % 60 ? `${Math.floor(sek / 60)} min ${sek % 60} s` : `${sek / 60} min`
const min = Math.round(s / 60)
if (min < 60) return `${min} min`
return min % 60 ? `${Math.floor(min / 60)} h ${min % 60} min` : `${min / 60} h`
}
/** Was ein Status von llama-swap heißt — die häufigen in Worten, der Rest als Zahl. */
const STATUS_TEXT: Record<string, string> = {
"400": "fehlerhafte Anfrage",
"404": "nicht gefunden",
"408": "Zeitüberschreitung",
"413": "Anfrage zu groß",
"429": "zu viele Anfragen",
"499": "vom Absender abgebrochen",
"500": "Fehler beim Modell",
"502": "Modell nicht erreichbar",
"503": "gerade nicht verfügbar",
"504": "Zeitüberschreitung",
}
/** „1× Modell nicht erreichbar (502) · 1× vom Absender abgebrochen (499)“ — häufigste zuerst. */
export function fehlerText(codes: Record<string, number> | undefined): string {
return Object.entries(codes ?? {})
.sort(([a, x], [b, y]) => y - x || a.localeCompare(b))
.map(([code, n]) => (STATUS_TEXT[code] ? `${anzahlText(n)}× ${STATUS_TEXT[code]} (${code})` : `${anzahlText(n)}× Status ${code}`))
.join(" · ")
}
/** Wie auffällig eine Fehlerquote ist: ab 1 % bernstein, ab 5 % rot — mit einem Wort dazu, nie nur Farbe. */
export function fehlerStufe(quote: number | null): { art: "grau" | "bernstein" | "rot"; wort: string | null } {
if (quote === null || quote < 0.01) return { art: "grau", wort: null }
return quote >= 0.05 ? { art: "rot", wort: "hoch" } : { art: "bernstein", wort: "auffällig" }
}
export interface AbsenderZeile {
name: string
/** CSS-Farbe der Marke; der Text bleibt in Textfarbe. */
farbe: string
/** Die Diagramm-Reihe, in der der Absender steckt: sein Name oder „Weitere“. */
reihe: string
anfragen: number
/** Anteil an allen Anfragen, 0…1 */
anteil: number
fehler: number | null
fehlerCodes: Record<string, number>
/** Sekunden */
typisch: number | null
p90: number | null
summe: number | null
/** Anteil an der Rechenzeit, 0…1 */
zeitAnteil: number | null
/** ISO-Zeit */
zuletzt: string | null
}
export interface DiagrammReihe {
name: string
farbe: string
}
export interface ModellLadungen {
modell: string
/** null bei älteren Ständen der Box: Die kannten nur den letzten Ladezeitpunkt. */
anzahl: number | null
/** ISO-Zeiten, neueste zuerst */
zeiten: string[]
}
export interface NutzungsBild {
absender: AbsenderZeile[]
/** Reihen der Diagramme in Stapelfolge (unten → oben), feste Farben. */
reihen: DiagrammReihe[]
tage: Saeule[]
stunden: Saeule[]
gesamt: {
anfragen: number
fehler: number | null
fehlerCodes: Record<string, number>
typisch: number | null
p90: number | null
summe: number | null
}
heute: number
gestern: number
/** Durchschnitt der vollen Tage (ohne heute) oder null, wenn es keinen gibt. */
schnitt: number | null
/** „seit Sa 19.09.“ bzw. „letzte 7 Tage“ */
zeitraum: string
/** Uhrzeit der Auswertung, z. B. „13:03“ (die Box rechnet alle 10 Minuten neu). */
stand: string | null
modelle: ModellLadungen[]
}
const zwei = (h: number) => String(h).padStart(2, "0")
const summe = (werte: Record<string, number> | undefined) => Object.values(werte ?? {}).reduce((s, v) => s + v, 0)
const kurzerTag = (d: Date) => wochentagKurz.format(d).replace(".", "")
/** Stunde 0–23 in Box-Zeit. */
function stundeIn(d: Date): number {
return Number(stundeTeile.formatToParts(d).find((t) => t.type === "hour")?.value ?? 0) % 24
}
/** „Samstag, 19.09.“ aus „2026-09-19“. */
function tagLang(tag: string): string {
const [, monat, tagImMonat] = tag.split("-")
return `${wochentagLang.format(new Date(`${tag}T12:00:00Z`))}, ${tagImMonat}.${monat}.`
}
/** Alles, was das Panel zeigt, aus der Antwort der Box. `jetzt` gilt nur, wenn die Box keinen Stand mitschickt. */
export function aufbereiten(n: Nutzung, jetzt = new Date()): NutzungsBild {
const stand = n.stand ? new Date(n.stand) : jetzt
const heute = tagIso.format(stand)
// Über den Kalender statt „minus 24 Stunden“ — sonst verrutscht gestern in der Nacht nach der Zeitumstellung.
const gestern = new Date(Date.parse(`${heute}T12:00:00Z`) - 86_400_000).toISOString().slice(0, 10)
const anfragen = n.gesamt?.anfragen ?? n.absender.reduce((s, a) => s + a.anfragen, 0)
const zeitGesamt = n.gesamt?.dauer_summe_s ?? null
// Die Farbe folgt dem Absender (feste Reihe der Box), nicht seinem Rang. Ältere Stände: nach Rang.
const platz = new Map(n.absender.map((a, i) => [a.name, a.reihe ?? i]))
const absender: AbsenderZeile[] = n.absender.map((a) => {
const p = platz.get(a.name) ?? FARBEN.length
const eigene = p < FARBEN.length
return {
name: a.name,
farbe: eigene ? FARBEN[p] : WEITERE.farbe,
reihe: eigene ? a.name : WEITERE.name,
anfragen: a.anfragen,
anteil: anfragen ? a.anfragen / anfragen : 0,
fehler: a.fehler ?? null,
fehlerCodes: a.fehler_codes ?? {},
typisch: a.dauer_typisch_s ?? null,
p90: a.dauer_p90_s ?? null,
summe: a.dauer_summe_s ?? null,
zeitAnteil: a.dauer_summe_s != null && zeitGesamt ? a.dauer_summe_s / zeitGesamt : null,
zuletzt: a.zuletzt ?? null,
}
})
const reiheVon = new Map(absender.map((a) => [a.name, a.reihe]))
const reihen: DiagrammReihe[] = []
for (const a of [...absender].sort((x, y) => (platz.get(x.name) ?? 99) - (platz.get(y.name) ?? 99))) {
if (!reihen.some((r) => r.name === a.reihe)) reihen.push({ name: a.reihe, farbe: a.farbe })
}
/** Absender → Reihe; wer in der Liste fehlt, landet bei „Weitere“. */
const werte = (je: Record<string, number> | undefined) => {
const w: Record<string, number> = {}
for (const [name, anzahl] of Object.entries(je ?? {})) {
const r = reiheVon.get(name) ?? WEITERE.name
w[r] = (w[r] ?? 0) + anzahl
if (r === WEITERE.name && !reihen.some((x) => x.name === r)) reihen.push({ ...WEITERE })
}
return w
}
const tagListe = Object.keys(n.je_tag ?? {}).sort()
const tage: Saeule[] = tagListe.map((tag) => {
const istHeute = tag === heute
return {
schluessel: tag,
achse: kurzerTag(new Date(`${tag}T12:00:00Z`)),
achse2: istHeute ? "heute" : `${Number(tag.slice(8))}.`,
lang: `${tagLang(tag)}${istHeute ? ` (heute bis ${uhr.format(stand)})` : ""}`,
werte: werte(n.je_tag[tag]),
}
})
let stunden: Saeule[]
if (n.stunden?.length) {
stunden = n.stunden.map((s, i, alle) => {
const d = new Date(s.beginn)
const h = stundeIn(d)
const tag = tagIso.format(d)
const tagText = tag === heute ? "Heute" : tag === gestern ? "Gestern" : tagLang(tag)
const bis = i === alle.length - 1 ? " bis jetzt" : `–${zwei((h + 1) % 24)}:00`
return {
schluessel: s.beginn, achse: `${zwei(h)}:00`, raster: h,
lang: `${tagText} ${zwei(h)}:00${bis}`,
werte: werte(s.je_absender),
}
})
} else {
// Älterer Stand der Box: nur Stunden des Tages (00–23). Die laufende Stunde kommt nach rechts.
const jetztStunde = stundeIn(stand)
const liste = n.letzte_24h ?? []
stunden = [...liste.slice(jetztStunde + 1), ...liste.slice(0, jetztStunde + 1)].map((s) => {
const h = Number(s.stunde)
return {
schluessel: s.stunde, achse: `${zwei(h)}:00`, raster: h,
lang: `${zwei(h)}:00–${zwei((h + 1) % 24)}:00`, werte: werte(s.je_absender),
}
})
}
const volle = tagListe.filter((t) => t !== heute)
const seit = n.seit ? tagIso.format(new Date(n.seit)) : null // Kalendertag in Box-Zeit, „2026-09-19“
const modelle: ModellLadungen[] = n.ladevorgaenge ??
Object.entries(n.zuletzt_geladen ?? {})
.sort(([, a], [, b]) => Date.parse(b) - Date.parse(a))
.map(([modell, zeit]) => ({ modell, anzahl: null, zeiten: [zeit] }))
return {
absender,
reihen,
tage,
stunden,
gesamt: {
anfragen,
fehler: n.gesamt?.fehler ?? null,
fehlerCodes: n.gesamt?.fehler_codes ?? {},
typisch: n.gesamt?.dauer_typisch_s ?? null,
p90: n.gesamt?.dauer_p90_s ?? null,
summe: zeitGesamt,
},
heute: summe(n.je_tag?.[heute]),
gestern: summe(n.je_tag?.[gestern]),
schnitt: volle.length ? volle.reduce((s, t) => s + summe(n.je_tag[t]), 0) / volle.length : null,
zeitraum: seit
? `seit ${kurzerTag(new Date(`${seit}T12:00:00Z`))} ${seit.slice(8)}.${seit.slice(5, 7)}.`
: `letzte ${n.tage} Tage`,
stand: n.stand ? uhr.format(stand) : null,
modelle,
}
}
+34 -1
View File
@@ -135,12 +135,45 @@ export interface UpdateDetails {
action_text?: string
}
/** Antwortzeiten und Fehler aus dem Journal von llama-swap (seit 25.09.2026; ältere Stände der Box liefern sie nicht). */
export interface NutzungKennwerte {
/** Anfragen mit einem Status außerhalb von 2xx, je Code („502“ → 1). */
fehler?: number
fehler_codes?: Record<string, number>
/** Median der erfolgreichen Antworten in Sekunden. */
dauer_typisch_s?: number | null
/** 9 von 10 erfolgreichen Antworten dauerten höchstens so lange (Sekunden). */
dauer_p90_s?: number | null
/** Summe aller Antwortzeiten in Sekunden („Rechenzeit“; gleichzeitige Anfragen zählen einzeln). */
dauer_summe_s?: number
}
export interface NutzungAbsender extends NutzungKennwerte {
name: string
anfragen: number
/** Feste Reihenfolge aus der Namensliste der Box: bestimmt die Farbe, nicht der Rang. */
reihe?: number
/** ISO-Zeit der letzten Anfrage */
zuletzt?: string
}
export interface Nutzung {
tage: number
absender: { name: string; anfragen: number }[]
absender: NutzungAbsender[]
/** ISO-Datum → Absender → Anfragen; seit 25.09.2026 genau die letzten `tage` Kalendertage, auch leere. */
je_tag: Record<string, Record<string, number>>
/** Stunden des Tages (00–23) der letzten 24 h — nicht chronologisch; dafür gibt es `stunden`. */
letzte_24h: { stunde: string; je_absender: Record<string, number> }[]
zuletzt_geladen: Record<string, string>
// --- seit 25.09.2026 ---
/** Beginn des Fensters (ISO, Mitternacht vor `tage` − 1 Tagen) und Zeitpunkt der Auswertung. */
seit?: string
stand?: string
gesamt?: NutzungKennwerte & { anfragen: number }
/** Die letzten 24 Stunden chronologisch; die letzte ist die laufende. */
stunden?: { beginn: string; je_absender: Record<string, number> }[]
/** Je Modell, wie oft es sich nach dem Laden bereit meldete — neueste zuerst. */
ladevorgaenge?: { modell: string; anzahl: number; zeiten: string[] }[]
}
export interface Modell {
+69
View File
@@ -0,0 +1,69 @@
import type { Nutzung } from "@/lib/typen"
// Testdaten für „Wer nutzt die Modelle“: die echten Zahlen der Box vom 25.09.2026, 13:03 (aus dem Journal von
// llama-swap), bei den Stunden und Ladezeiten gekürzt.
const NQ = "NerdQuiz auf Arcane"
const LUCY = "Lucy und OpenChamber über MC2"
const PC = "NerdQuiz-Tests vom PC"
/** 24 Stunden ab gestern 14:00; NerdQuiz nachts viel, Lucy tagsüber etwas. */
function stunden(): NonNullable<Nutzung["stunden"]> {
const beginn = Date.parse("2026-09-24T14:00:00+02:00")
return Array.from({ length: 24 }, (_, i) => {
const d = new Date(beginn + i * 3_600_000)
const h = (14 + i) % 24
const je: Record<string, number> = {}
if (h === 3) je[NQ] = 139
else if (h === 4) je[NQ] = 81
else if (h !== 1 && h !== 2 && h !== 5) je[NQ] = 5
if (h === 10) je[LUCY] = 8
if (h === 11) je[LUCY] = 12
// ISO mit Versatz wie von der Box (im September gilt +02:00)
const iso = new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit",
second: "2-digit",
}).format(d).replace(" ", "T")
return { beginn: `${iso}+02:00`, je_absender: je }
})
}
export function nutzungBeispiel(): Nutzung {
return {
tage: 7,
seit: "2026-09-19T00:00:00+02:00",
stand: "2026-09-25T13:03:59+02:00",
absender: [
{ name: NQ, anfragen: 4032, reihe: 0, fehler: 1, fehler_codes: { "502": 1 }, dauer_typisch_s: 1.231,
dauer_p90_s: 3.305, dauer_summe_s: 6517.5, zuletzt: "2026-09-25T12:55:04+02:00" },
{ name: LUCY, anfragen: 325, reihe: 1, fehler: 0, fehler_codes: {}, dauer_typisch_s: 4.469,
dauer_p90_s: 15.422, dauer_summe_s: 2136.7, zuletzt: "2026-09-25T12:53:24+02:00" },
{ name: PC, anfragen: 7, reihe: 2, fehler: 0, fehler_codes: {}, dauer_typisch_s: 2.465,
dauer_p90_s: 23.433, dauer_summe_s: 37.1, zuletzt: "2026-09-21T17:46:47+02:00" },
],
gesamt: { anfragen: 4364, fehler: 1, fehler_codes: { "502": 1 }, dauer_typisch_s: 1.27, dauer_p90_s: 4.037,
dauer_summe_s: 8691.3 },
je_tag: {
"2026-09-19": { [NQ]: 535, [LUCY]: 15 },
"2026-09-20": { [PC]: 1, [NQ]: 589, [LUCY]: 37 },
"2026-09-21": { [NQ]: 520, [LUCY]: 14, [PC]: 6 },
"2026-09-22": { [NQ]: 659, [LUCY]: 15 },
"2026-09-23": { [NQ]: 678, [LUCY]: 77 },
"2026-09-24": { [NQ]: 657, [LUCY]: 117 },
"2026-09-25": { [NQ]: 394, [LUCY]: 50 },
},
stunden: stunden(),
letzte_24h: Array.from({ length: 24 }, (_, h) => ({ stunde: String(h).padStart(2, "0"), je_absender: {} })),
zuletzt_geladen: {
"Qwen3.6-35B-A3B": "2026-09-25T11:25:46+02:00",
"Qwen3.6-35B-A3B-Bild": "2026-09-25T00:35:29+02:00",
"Qwen3.8-27B": "2026-09-24T13:03:13+02:00",
},
ladevorgaenge: [
{ modell: "Qwen3.6-35B-A3B", anzahl: 14,
zeiten: ["2026-09-25T11:25:46+02:00", "2026-09-25T11:25:09+02:00", "2026-09-25T06:09:02+02:00", "2026-09-24T16:03:40+02:00"] },
{ modell: "Qwen3.6-35B-A3B-Bild", anzahl: 8, zeiten: ["2026-09-25T00:35:29+02:00", "2026-09-24T13:18:34+02:00"] },
{ modell: "Qwen3.8-27B", anzahl: 3, zeiten: ["2026-09-24T13:03:13+02:00"] },
],
}
}
+143
View File
@@ -0,0 +1,143 @@
import { fireEvent, render, screen, within } from "@testing-library/react"
import type { Nutzung } from "@/lib/typen"
import { nutzungBeispiel } from "@/test/nutzung"
import { NutzungsPanel } from "./ModellNutzung"
const NQ = "NerdQuiz auf Arcane"
const LUCY = "Lucy und OpenChamber über MC2"
const PC = "NerdQuiz-Tests vom PC"
// „HEUTE 12:55“ hängt am heutigen Datum — die Uhr steht auf dem Stand der Testdaten.
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] })
vi.setSystemTime(new Date("2026-09-25T13:05:00+02:00"))
})
afterEach(() => vi.useRealTimers())
const zeigen = (n: Nutzung = nutzungBeispiel(), rollen?: Record<string, string>) =>
render(<NutzungsPanel nutzung={n} laedt={false} fehler={null} rollen={rollen} />)
const zeileMit = (text: string) => screen.getByText(text).closest<HTMLElement>('[role="row"]')!
describe("Wer nutzt die Modelle", () => {
it("zeigt oben die Kennzahlen mit Zeitraum und Stand", () => {
zeigen()
expect(screen.getByText("seit Sa 19.09. · Stand 13:03")).toBeInTheDocument()
expect(screen.getByText("4.364")).toBeInTheDocument()
expect(screen.getByText("im Schnitt 653 am Tag")).toBeInTheDocument()
expect(screen.getByText("bis 13:03 · gestern 774")).toBeInTheDocument()
expect(screen.getByText("1,3 s")).toBeInTheDocument()
expect(screen.getByText("9 von 10 bis 4 s")).toBeInTheDocument()
expect(screen.getByText("unter 0,1 % der Anfragen")).toBeInTheDocument()
})
it("nennt je Absender Anteil, Antwortzeiten, Rechenzeit, Fehler und die letzte Anfrage", () => {
zeigen()
const tabelle = screen.getByRole("table", { name: "Nutzung je Absender" })
const nq = within(tabelle).getByText(NQ).closest<HTMLElement>('[role="row"]')!
expect(within(nq).getByText("4.032")).toBeInTheDocument()
expect(within(nq).getByText("· 92 % aller Anfragen")).toBeInTheDocument()
expect(within(nq).getByText("1,2 s")).toBeInTheDocument()
expect(within(nq).getByText("9 von 10 bis 3,3 s")).toBeInTheDocument()
expect(within(nq).getByText("1 h 49 min")).toBeInTheDocument()
expect(within(nq).getByText("75 % aller Rechenzeit")).toBeInTheDocument()
expect(within(nq).getByText("1 · unter 0,1 %")).toBeInTheDocument()
expect(within(nq).getByText("1× Modell nicht erreichbar (502)")).toBeInTheDocument()
expect(within(nq).getByText("HEUTE 12:55")).toBeInTheDocument()
const lucy = within(tabelle).getByText(LUCY).closest<HTMLElement>('[role="row"]')!
expect(within(lucy).getByText("keine")).toBeInTheDocument()
expect(within(lucy).getByText("36 min")).toBeInTheDocument()
expect(within(lucy).getByText("25 % aller Rechenzeit")).toBeInTheDocument()
})
it("zeichnet die Tage gestapelt, mit Legende, Anzeige per Tastatur und Tabelle", () => {
zeigen()
const tag = screen.getByRole("region", { name: "Anfragen je Tag" })
const legende = within(tag).getByRole("list", { name: "Legende" })
expect(within(legende).getAllByRole("listitem").map((l) => l.textContent)).toEqual([NQ, LUCY, PC])
const bild = within(tag).getByRole("img", { name: /Anfragen je Tag der letzten 7 Tage/ })
fireEvent.focus(bild)
let anzeige = within(tag).getByRole("status")
expect(within(anzeige).getByText("Freitag, 25.09. (heute bis 13:03)")).toBeInTheDocument()
expect(within(anzeige).getByText("394")).toBeInTheDocument()
expect(within(anzeige).getByText("444")).toBeInTheDocument()
fireEvent.keyDown(bild, { key: "ArrowLeft" })
anzeige = within(tag).getByRole("status")
expect(within(anzeige).getByText("Donnerstag, 24.09.")).toBeInTheDocument()
expect(within(anzeige).getByText("117")).toBeInTheDocument()
expect(within(anzeige).getByText("774")).toBeInTheDocument()
fireEvent.keyDown(bild, { key: "Home" })
expect(within(tag).getByRole("status")).toHaveTextContent("Samstag, 19.09.")
fireEvent.blur(bild)
expect(within(tag).queryByRole("status")).not.toBeInTheDocument()
const tabelle = within(tag).getByRole("table")
const samstag = within(tabelle).getByRole("rowheader", { name: "Samstag, 19.09." }).closest("tr")!
expect(within(samstag).getAllByRole("cell").map((c) => c.textContent)).toEqual(["535", "15", "0", "550"])
})
it("zeigt je Stunde nur, wer in den letzten 24 Stunden gefragt hat", () => {
zeigen()
const stunde = screen.getByRole("region", { name: "Anfragen je Stunde" })
const legende = within(stunde).getByRole("list", { name: "Legende" })
expect(within(legende).getAllByRole("listitem").map((l) => l.textContent)).toEqual([NQ, LUCY])
const tabelle = within(stunde).getByRole("table")
expect(within(tabelle).getAllByRole("rowheader")).toHaveLength(24)
const nacht = within(tabelle).getByRole("rowheader", { name: "Heute 03:00–04:00" }).closest("tr")!
expect(within(nacht).getAllByRole("cell").map((c) => c.textContent)).toEqual(["139", "0", "139"])
})
it("klappt auf, wann welches Modell geladen wurde — mit Rolle", () => {
zeigen(nutzungBeispiel(), { "Qwen3.6-35B-A3B": "Hirn" })
expect(screen.getByText("3 Modelle · 25× geladen")).toBeInTheDocument()
const hirn = zeileMit("Hirn")
expect(within(hirn).getByText("Qwen3.6-35B-A3B")).toBeInTheDocument()
expect(within(hirn).getByText("14×")).toBeInTheDocument()
expect(hirn).toHaveTextContent("zuletzt HEUTE 11:25")
expect(hirn).toHaveTextContent("davor HEUTE 11:25 · HEUTE 06:09 · GESTERN 16:03")
})
it("färbt eine auffällige Fehlerquote und sagt es in Worten", () => {
const n = nutzungBeispiel()
n.gesamt = { ...n.gesamt!, fehler: 131, fehler_codes: { "502": 131 } }
zeigen(n)
expect(screen.getByText("3 % der Anfragen · auffällig")).toBeInTheDocument()
expect(screen.getByText("131")).toHaveClass("text-bernstein")
})
it("bleibt ohne Anfragen beim Hinweis", () => {
const n = nutzungBeispiel()
n.absender = []
n.gesamt = { anfragen: 0, fehler: 0, fehler_codes: {}, dauer_typisch_s: null, dauer_p90_s: null, dauer_summe_s: 0 }
zeigen(n)
expect(screen.getByText("Keine Anfragen aufgezeichnet.")).toBeInTheDocument()
expect(screen.queryByRole("img")).not.toBeInTheDocument()
})
it("kommt mit dem Stand der Box vor dem 25.09. zurecht", () => {
zeigen({
tage: 7,
absender: [{ name: NQ, anfragen: 10 }],
je_tag: { "2026-09-25": { [NQ]: 10 } },
letzte_24h: Array.from({ length: 24 }, (_, h): Nutzung["letzte_24h"][number] => ({
stunde: String(h).padStart(2, "0"), je_absender: h === 3 ? { [NQ]: 10 } : {},
})),
zuletzt_geladen: { "Qwen3.6-35B-A3B": "2026-09-25T11:25:46+02:00" },
})
expect(screen.getByText("letzte 7 Tage")).toBeInTheDocument()
const nq = within(screen.getByRole("table", { name: "Nutzung je Absender" })).getByText(NQ).closest<HTMLElement>('[role="row"]')!
expect(within(nq).getAllByText("–")).toHaveLength(4) // Antwortzeit, Rechenzeit, Fehler, zuletzt
expect(screen.getByText("1 Modell")).toBeInTheDocument()
})
it("sagt, wenn die Nutzung noch lädt oder fehlt", () => {
const { rerender } = render(<NutzungsPanel nutzung={undefined} laedt fehler={null} />)
expect(screen.getByText("Wird gelesen …")).toBeInTheDocument()
rerender(<NutzungsPanel nutzung={undefined} laedt={false} fehler="MC2 ist nicht erreichbar. Läuft die Box?" />)
expect(screen.getByRole("alert")).toHaveTextContent("Die Nutzung lässt sich gerade nicht lesen: MC2 ist nicht erreichbar.")
})
})
+215
View File
@@ -0,0 +1,215 @@
import type { ReactNode } from "react"
import { ChevronRight } from "lucide-react"
import { cn } from "cn"
import { Panel } from "@/components/cockpit/Panel"
import { Saeulen } from "@/components/diagramme/Saeulen"
import {
type AbsenderZeile, anteilText, anzahlText, aufbereiten, dauerText, fehlerStufe, fehlerText, type ModellLadungen,
} from "@/lib/nutzung"
import type { Nutzung } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
// „Wer nutzt die Modelle“ auf der Seite Modelle (neu am 25.09.2026): Kennzahlen, je Absender Anfragen, Antwortzeiten,
// Rechenzeit und Fehler, der Verlauf je Tag und je Stunde als gestapelte Säulen und wann welches Modell geladen wurde.
// Die Zahlen rechnet die Box aus dem Journal von llama-swap (services/modell_nutzung.py), hier wird nur angezeigt.
const WERT_FARBE = { grau: "text-foreground", bernstein: "text-bernstein", rot: "text-rot-text" } as const
/** Eine Zahl mit Beschriftung, wie die Kacheln im Monitoring. */
function Kachel({ label, wert, zusatz, art = "grau" }: {
label: string
wert: string
zusatz?: string
art?: keyof typeof WERT_FARBE
}) {
return (
<div className="flex min-w-0 flex-col gap-1.5 rounded-xl border border-linie bg-erhaben/50 px-3.5 py-3">
<span className="schild text-xs tracking-[0.18em] text-text-3">{label}</span>
<span className={cn("font-anzeige text-[28px] leading-none font-bold", WERT_FARBE[art])}>{wert}</span>
{zusatz && <span className="text-xs text-text-3">{zusatz}</span>}
</div>
)
}
const SPALTEN = "lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1.4fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.2fr)_minmax(0,0.8fr)]"
/** Eine Zelle der Absender-Tabelle; schmal steht die Beschriftung darüber, breit im Tabellenkopf. */
function Zelle({ label, wert, zusatz, leise }: { label: string; wert: string; zusatz?: string; leise?: boolean }) {
return (
<span role="cell" className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs text-text-3 lg:hidden">{label}</span>
<span className={cn("ziffern text-sm", leise ? "text-text-3" : "text-foreground")}>{wert}</span>
{zusatz && <span className="text-xs text-text-3">{zusatz}</span>}
</span>
)
}
function AbsenderTabelle({ zeilen }: { zeilen: AbsenderZeile[] }) {
return (
<div role="table" aria-label="Nutzung je Absender" className="flex flex-col">
<div role="row" className={cn("schild hidden gap-x-4 pb-2 text-xs text-text-3 lg:grid", SPALTEN)}>
<span role="columnheader">Absender</span>
<span role="columnheader">Anfragen</span>
<span role="columnheader">Typische Antwort</span>
<span role="columnheader">Rechenzeit</span>
<span role="columnheader">Fehler</span>
<span role="columnheader">Zuletzt</span>
</div>
{zeilen.map((a) => (
<div key={a.name} role="row" className={cn("grid grid-cols-2 gap-x-4 gap-y-2.5 border-t border-linie py-3 lg:items-start", SPALTEN)}>
<span role="rowheader" className="col-span-2 flex min-w-0 items-center gap-2 text-[15px] font-medium lg:col-span-1">
<span aria-hidden className="size-2.5 shrink-0 rounded-[2px]" style={{ background: a.farbe }} />
<span className="min-w-0 break-words">{a.name}</span>
</span>
<span role="cell" className="col-span-2 flex min-w-0 flex-col gap-1.5 lg:col-span-1">
<span className="text-sm">
<span className="ziffern">{anzahlText(a.anfragen)}</span>
<span className="text-text-3"> · {anteilText(a.anteil)} aller Anfragen</span>
</span>
<span className="h-1.5 w-full max-w-56 overflow-hidden rounded-full bg-erhaben" aria-hidden>
<span className="block h-full rounded-full" style={{ width: `${Math.max(1, a.anteil * 100)}%`, background: a.farbe }} />
</span>
</span>
<Zelle label="Typische Antwort" wert={dauerText(a.typisch)}
zusatz={a.p90 !== null ? `9 von 10 bis ${dauerText(a.p90)}` : undefined} />
<Zelle label="Rechenzeit" wert={dauerText(a.summe, true)}
zusatz={a.zeitAnteil !== null ? `${anteilText(a.zeitAnteil)} aller Rechenzeit` : undefined} />
<Zelle label="Fehler" leise={a.fehler === 0}
wert={a.fehler === null ? "–" : a.fehler === 0 ? "keine" : `${anzahlText(a.fehler)} · ${anteilText(a.fehler / a.anfragen)}`}
zusatz={a.fehler ? fehlerText(a.fehlerCodes) : undefined} />
<Zelle label="Zuletzt" wert={a.zuletzt ? flugplanZeit(a.zuletzt) : "–"} />
</div>
))}
</div>
)
}
function Diagramm({ titel, zusatz, children }: { titel: string; zusatz: string; children: ReactNode }) {
return (
<section aria-label={titel} className="flex min-w-0 flex-col gap-3 rounded-xl border border-linie p-4">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<h3 className="schild text-sm tracking-[0.18em] text-text-2">{titel}</h3>
<span className="text-xs text-text-3">{zusatz}</span>
</div>
{children}
</section>
)
}
/** Wann welches Modell geladen wurde — eingeklappt, die Zusammenfassung steht in der Kopfzeile. */
function Ladevorgaenge({ modelle, rollen }: { modelle: ModellLadungen[]; rollen: Record<string, string> }) {
if (modelle.length === 0) return null
const gesamt = modelle.every((m) => m.anzahl !== null) ? modelle.reduce((s, m) => s + (m.anzahl ?? 0), 0) : null
return (
<details className="group rounded-xl border border-linie bg-erhaben/40 px-4 open:pb-2">
<summary className="flex min-h-12 cursor-pointer list-none flex-wrap items-center justify-between gap-x-3 gap-y-1 py-2 [&::-webkit-details-marker]:hidden">
<span className="flex items-center gap-2 text-[15px] font-medium">
<ChevronRight className="size-4 shrink-0 text-text-3 transition-transform group-open:rotate-90" aria-hidden />
Wann welches Modell geladen wurde
</span>
<span className="ziffern text-sm text-text-2">
{modelle.length === 1 ? "1 Modell" : `${modelle.length} Modelle`}{gesamt !== null ? ` · ${anzahlText(gesamt)}× geladen` : ""}
</span>
</summary>
<p className="pb-1 text-sm text-text-2">
Gezählt wird, wie oft sich ein Modell nach dem Laden bereit meldete: nach einem Neustart von llama-swap oder
wenn es auf Abruf gebraucht wurde.
</p>
<div role="table" aria-label="Ladevorgänge je Modell" className="flex flex-col">
<div role="row" className="schild hidden gap-x-4 py-2 text-xs text-text-3 sm:grid sm:grid-cols-[minmax(0,1.5fr)_70px_130px_minmax(0,1.5fr)]">
<span role="columnheader">Modell</span>
<span role="columnheader">Geladen</span>
<span role="columnheader">Zuletzt</span>
<span role="columnheader">Davor</span>
</div>
{modelle.map((m) => (
<div key={m.modell} role="row"
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1 border-t border-linie py-2.5 sm:grid-cols-[minmax(0,1.5fr)_70px_130px_minmax(0,1.5fr)] sm:items-baseline">
<span role="rowheader" className="flex min-w-0 flex-col">
<span className="ziffern text-sm break-all">{m.modell}</span>
{rollen[m.modell] && <span className="text-xs text-text-3">{rollen[m.modell]}</span>}
</span>
<span role="cell" className="ziffern text-right text-sm text-text-2 sm:text-left">
{m.anzahl !== null ? `${anzahlText(m.anzahl)}×` : "–"}
</span>
<span role="cell" className="ziffern col-span-2 text-xs text-text-2 sm:col-span-1">
<span className="font-sans text-text-3 sm:hidden">zuletzt </span>{flugplanZeit(m.zeiten[0])}
</span>
<span role="cell" className="ziffern col-span-2 text-xs text-text-3 sm:col-span-1">
{m.zeiten.length > 1 && <span className="font-sans sm:hidden">davor </span>}
{m.zeiten.length > 1 ? m.zeiten.slice(1, 4).map((z) => flugplanZeit(z)).join(" · ") : <span className="hidden sm:inline">–</span>}
</span>
</div>
))}
</div>
</details>
)
}
/**
* Wer die Modelle nutzt, wie viel, wie schnell sie antworten und wann welches Modell geladen wurde.
* `rollen` nennt zu Modellnamen ihre Rolle („Hirn“, „Coder“ …), damit die Ladeliste lesbar bleibt.
*/
export function NutzungsPanel({ nutzung, laedt, fehler, rollen = {} }: {
nutzung: Nutzung | undefined
laedt: boolean
fehler: string | null
rollen?: Record<string, string>
}) {
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 bild = aufbereiten(nutzung)
const g = bild.gesamt
const quote = g.fehler !== null && g.anfragen ? g.fehler / g.anfragen : null
const stufe = fehlerStufe(quote)
return (
<Panel
titel="Wer nutzt die Modelle"
rechts={<span className="ziffern text-sm text-text-3">{bild.zeitraum}{bild.stand ? ` · Stand ${bild.stand}` : ""}</span>}
>
{g.anfragen === 0 ? (
<p className="text-[15px] text-text-2">Keine Anfragen aufgezeichnet.</p>
) : (
<>
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-4">
<Kachel label="Anfragen" wert={anzahlText(g.anfragen)}
zusatz={bild.schnitt !== null ? `im Schnitt ${anzahlText(Math.round(bild.schnitt))} am Tag` : undefined} />
<Kachel label="Heute" wert={anzahlText(bild.heute)}
zusatz={`${bild.stand ? `bis ${bild.stand} · ` : ""}gestern ${anzahlText(bild.gestern)}`} />
<Kachel label="Typische Antwort" wert={dauerText(g.typisch)}
zusatz={g.p90 !== null ? `9 von 10 bis ${dauerText(g.p90)}` : undefined} />
<Kachel label="Fehler" wert={g.fehler === null ? "–" : anzahlText(g.fehler)} art={stufe.art}
zusatz={g.fehler === null ? undefined : g.fehler === 0 ? "alle beantwortet"
: `${anteilText(quote ?? 0)} der Anfragen${stufe.wort ? ` · ${stufe.wort}` : ""}`} />
</div>
<AbsenderTabelle zeilen={bild.absender} />
<div className="grid gap-4 xl:grid-cols-2">
<Diagramm titel="Anfragen je Tag" zusatz="letzte 7 Tage, heute bis jetzt">
<Saeulen saeulen={bild.tage} reihen={bild.reihen} spalte="Tag"
titel="Anfragen je Tag der letzten 7 Tage, gestapelt nach Absender" />
</Diagramm>
<Diagramm titel="Anfragen je Stunde" zusatz="letzte 24 Stunden">
<Saeulen saeulen={bild.stunden} reihen={bild.reihen} spalte="Stunde" summen="hoechste"
titel="Anfragen je Stunde der letzten 24 Stunden, gestapelt nach Absender" />
</Diagramm>
</div>
</>
)}
<Ladevorgaenge modelle={bild.modelle} rollen={rollen} />
<p className="text-xs text-text-3">
Chat-Anfragen an llama-swap, gelesen aus seinem Protokoll und alle 10 Minuten neu gerechnet. NerdQuiz fragt direkt
dort an, Lucy und OpenChamber über MC2. Antwortzeit: bis die Antwort ganz da war. Rechenzeit: alle Antwortzeiten
zusammen, gleichzeitige Anfragen zählen einzeln.
</p>
</Panel>
)
}
+12 -49
View File
@@ -14,9 +14,10 @@ import {
import { laeuftNoch, messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige"
import { ApiFehler } from "@/lib/api"
import { useLetzteMetrik } from "@/lib/strom"
import type { Altrest, AufraeumKandidat, Modell, Nutzung, RadarKandidat, RadarTest } from "@/lib/typen"
import type { Altrest, AufraeumKandidat, Modell, RadarKandidat, RadarTest } from "@/lib/typen"
import { letzteMeldung } from "@/lib/wartung"
import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit"
import { flugplanZeit, gb, gbText } from "@/lib/zeit"
import { NutzungsPanel } from "./ModellNutzung"
import { ModellSuche } from "./ModellSuche"
/** Grenze aus der Erfahrung (Memory „llama-swap: nur EINE Gruppe resident“): darüber OOM. */
@@ -103,49 +104,6 @@ function Rolle({ titel, modell, zwilling, laeuft, nutzer }: {
)
}
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",
}
@@ -537,6 +495,12 @@ export function ModelleSeite() {
const coderZwilling = zwilling(coder, "coder-bild")
const weitere = liste.filter((m) => ![hirn, coder, hirnZwilling, coderZwilling].includes(m))
const zuletzt = nutzung.data?.zuletzt_geladen ?? {}
// Für die Ladeliste der Nutzung: Modellname → Rolle, damit dort nicht nur Dateinamen stehen.
const rollen: Record<string, string> = {}
for (const [m, rolle] of [[hirn, "Hirn"], [coder, "Coder"], [hirnZwilling, "Bild-Zwilling des Hirns"],
[coderZwilling, "Bild-Zwilling des Coders"]] as const) {
if (m) rollen[m.name] = rolle
}
return (
<>
@@ -547,10 +511,9 @@ export function ModelleSeite() {
<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>
{/* Die Nutzung braucht mit ihren Diagrammen die volle Breite (vorher halbe Zeile neben dem Radar). */}
<NutzungsPanel nutzung={nutzung.data} laedt={nutzung.isPending} fehler={nutzung.error?.message ?? null} rollen={rollen} />
<RadarPanel hirn={hirn?.name} coder={coder?.name} />
<Panel
titel="Weitere Einträge"
rechts={<Button variant="outline" size="sm" onClick={() => setSuche(true)}><Search aria-hidden /> Modelle selbst suchen</Button>}