modelle: „Wer nutzt die Modelle“ neu — Kennzahlen, Tabelle je Absender, lesbare Säulendiagramme

Der User: „Viel zu dünn. Und den Graphen kann ja kein Mensch lesen.“ Vorher drei Balken und ein winziges
Stunden-Histogramm ohne lesbare Achsen, halb so breit neben dem Radar.

- Eigene Ansicht views/ModellNutzung.tsx in voller Breite (Radar darunter, ebenfalls volle Breite).
- Kennzahlen: Anfragen mit Schnitt je vollem Tag, heute/gestern, typische Antwort mit „9 von 10 bis“, Fehler
  (ab 1 % bernstein, ab 5 % rot, immer mit Wort dazu, nie nur Farbe).
- Tabelle je Absender: Anfragen mit Anteil-Balken, Antwortzeiten, Rechenzeit mit Anteil (NerdQuiz stellt
  92 % der Anfragen, aber nur 75 % der Rechenzeit), Fehler in Worten („Modell nicht erreichbar (502)“),
  letzte Anfrage. Schmal als Karten mit Beschriftung.
- Neuer Baustein components/diagramme/Saeulen.tsx für „je Tag (7 Tage)“ und „je Stunde (24 h)“: gestapelt je
  Absender, glatte y-Achse, Wochentag/Datum bzw. Uhrzeit an der x-Achse (Stunden ausgedünnt auf 3 bzw. 6 h),
  Legende, Summen auf den Tagessäulen (bei den Stunden nur die höchste), Anzeige per Zeiger, Finger und
  Pfeiltasten, darunter „Werte als Tabelle“. Auf dem Handy steht die Anzeige unter dem Diagramm, sonst
  verdeckte sie das halbe Bild. Säulen höchstens 24 px, 2 px Luft zwischen den Stücken statt Rändern.
- Farben: neue Tokens --reihe-3 (Gelb) und --reihe-weitere (Grau; ab der vierten Farbe fasst „Weitere“
  zusammen). Mit dem Paletten-Prüfer gegen #15181b gemessen: jedes Paar auch bei Rot-Grün-Schwäche getrennt.
  Türkis fiel durch (neben Magenta bei Grünschwäche ΔE 1,6), Violett gleicht der KI-Box-Farbe.
  Die Farbe folgt dem Absender (feste Reihe der Box), nicht dem Rang.
- „Wann welches Modell geladen wurde“ eingeklappt darunter: Anzahl, zuletzt, davor — mit Rolle (Hirn, Coder,
  Bild-Zwilling) statt nur Dateiname.
- useBreite aus Verlauf.tsx nach lib/breite.ts, damit beide Diagramme ihn teilen.
- Ältere Stände der Box ohne die neuen Felder zeigen weiter Anzahlen und Verläufe, Zeiten als „–“ — die
  Homelab-Instanz fragt über den Partner und kann einen anderen Stand haben.

frontend/dist ist bewusst nicht gebaut (kommt beim Zusammenführen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 13:26:45 +02:00
co-authored by Claude Opus 5.5
parent 7834de9592
commit dfe0971180
14 changed files with 1275 additions and 65 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 { flaechenPfad, linienPfad } from "@/lib/diagramm"
import { type Einheit, obergrenze, wertText, zeitText } from "@/lib/messung" import { type Einheit, obergrenze, wertText, zeitText } from "@/lib/messung"
import type { Messpunkt } from "@/lib/typen" import type { Messpunkt } from "@/lib/typen"
@@ -12,20 +13,6 @@ export interface Reihe {
const RAND = { rechts: 12, oben: 10, unten: 24 } 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. * 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 * 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. */ erste Reihe Blau, zweite Magenta. Zustandsfarben bleiben für Zustände. */
--reihe-1: #3987e5; --reihe-1: #3987e5;
--reihe-2: #d55181; --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-grund: #171b1f;
--aus-rand: #2e343a; --aus-rand: #2e343a;
--aus-text: #9aa3ad; --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() schliessen()
return pfad 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 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 { export interface Nutzung {
tage: number 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>> 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> }[] letzte_24h: { stunde: string; je_absender: Record<string, number> }[]
zuletzt_geladen: Record<string, string> 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 { 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>
)
}
+11 -48
View File
@@ -14,9 +14,10 @@ import {
import { laeuftNoch, messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige" import { laeuftNoch, messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige"
import { ApiFehler } from "@/lib/api" import { ApiFehler } from "@/lib/api"
import { useLetzteMetrik } from "@/lib/strom" 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 { 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" import { ModellSuche } from "./ModellSuche"
/** Grenze aus der Erfahrung (Memory „llama-swap: nur EINE Gruppe resident“): darüber OOM. */ /** 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> = { const STATUS_ART: Record<string, ChipArt> = {
bestanden: "gruen", durchgefallen: "rot", wartet: "cyan", neu: "cyan", getestet: "grau", uebernommen: "gruen", verworfen: "grau", bestanden: "gruen", durchgefallen: "rot", wartet: "cyan", neu: "cyan", getestet: "grau", uebernommen: "gruen", verworfen: "grau",
} }
@@ -514,6 +472,12 @@ export function ModelleSeite() {
const coderZwilling = zwilling(coder, "coder-bild") const coderZwilling = zwilling(coder, "coder-bild")
const weitere = liste.filter((m) => ![hirn, coder, hirnZwilling, coderZwilling].includes(m)) const weitere = liste.filter((m) => ![hirn, coder, hirnZwilling, coderZwilling].includes(m))
const zuletzt = nutzung.data?.zuletzt_geladen ?? {} 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 ( return (
<> <>
@@ -524,10 +488,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="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="" /> <Rolle titel="Dritte Rolle" laeuft={false} nutzer="" />
</section> </section>
<div className="grid gap-5 md:gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)]"> {/* 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} /> <NutzungsPanel nutzung={nutzung.data} laedt={nutzung.isPending} fehler={nutzung.error?.message ?? null} rollen={rollen} />
<RadarPanel hirn={hirn?.name} coder={coder?.name} /> <RadarPanel hirn={hirn?.name} coder={coder?.name} />
</div>
<Panel <Panel
titel="Weitere Einträge" titel="Weitere Einträge"
rechts={<Button variant="outline" size="sm" onClick={() => setSuche(true)}><Search aria-hidden /> Modelle selbst suchen</Button>} rechts={<Button variant="outline" size="sm" onClick={() => setSuche(true)}><Search aria-hidden /> Modelle selbst suchen</Button>}