diff --git a/frontend/src/components/diagramme/Saeulen.test.tsx b/frontend/src/components/diagramme/Saeulen.test.tsx new file mode 100644 index 0000000..a03722b --- /dev/null +++ b/frontend/src/components/diagramme/Saeulen.test.tsx @@ -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() + 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() + 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() + 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() + expect(screen.getByText("Keine Anfragen in diesem Zeitraum")).toBeInTheDocument() + expect(screen.queryByRole("list", { name: "Legende" })).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/diagramme/Saeulen.tsx b/frontend/src/components/diagramme/Saeulen.tsx new file mode 100644 index 0000000..5e714cf --- /dev/null +++ b/frontend/src/components/diagramme/Saeulen.tsx @@ -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 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 ( +
+ {saeule.lang} + {reihen.map((r) => ( + + + {zahl.format(saeule.werte[r.name] ?? 0)} + {r.name} + + ))} + {reihen.length > 1 && ( + + {zahl.format(summe)} + zusammen + + )} +
+ ) +} + +/** + * 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(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) { + 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) { + 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 ( +
+ {sichtbar.length > 1 && ( +
    + {sichtbar.map((r) => ( +
  • + + {r.name} +
  • + ))} +
+ )} +
+ 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 && ( + + )} + {ticks.map((v) => ( + + + + {zahl.format(v)} + + + ))} + {saeulen.map((s, i) => { + const x = mitte(i) - dicke / 2 + return ( + + {stapeln(sichtbar.map((r) => s.werte[r.name] ?? 0), y).filter((t) => t.hoehe > 0).map((t) => + t.oben ? ( + + ) : ( + + ))} + {summenListe[i] > 0 && (summen === "alle" || i === hoechste) && ( + + {zahl.format(summenListe[i])} + + )} + {(s.raster ?? i) % jede === 0 && ( + + {s.achse} + {s.achse2 && {s.achse2}} + + )} + + ) + })} + {max === 0 && ( + + Keine Anfragen in diesem Zeitraum + + )} + {/* Trefferfläche: das ganze Band einer Säule samt Beschriftung — niemand muss ein schmales Stück treffen. */} + { if (e.pointerType !== "touch") setIndex(null) }} /> + + {aktiv && index !== null && schwebend && ( + + )} +
+ {aktiv && index !== null && !schwebend && } +
+ Werte als Tabelle +
+ + + + + {sichtbar.map((r) => ( + + ))} + {sichtbar.length > 1 && } + + + + {saeulen.map((s, i) => ( + + + {sichtbar.map((r) => ( + + ))} + {sichtbar.length > 1 && } + + ))} + +
{spalte}{r.name}Zusammen
{s.lang}{zahl.format(s.werte[r.name] ?? 0)}{zahl.format(summenListe[i])}
+
+
+
+ ) +} diff --git a/frontend/src/components/diagramme/Verlauf.tsx b/frontend/src/components/diagramme/Verlauf.tsx index 9411524..62b8bda 100644 --- a/frontend/src/components/diagramme/Verlauf.tsx +++ b/frontend/src/components/diagramme/Verlauf.tsx @@ -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(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 diff --git a/frontend/src/index.css b/frontend/src/index.css index 5a3fd4c..5b018e1 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; diff --git a/frontend/src/lib/breite.ts b/frontend/src/lib/breite.ts new file mode 100644 index 0000000..9b5c3fc --- /dev/null +++ b/frontend/src/lib/breite.ts @@ -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(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 +} diff --git a/frontend/src/lib/diagramm.test.ts b/frontend/src/lib/diagramm.test.ts new file mode 100644 index 0000000..081ac8a --- /dev/null +++ b/frontend/src/lib/diagramm.test.ts @@ -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([]) + }) +}) diff --git a/frontend/src/lib/diagramm.ts b/frontend/src/lib/diagramm.ts index 757d97c..0a5dfab 100644 --- a/frontend/src/lib/diagramm.ts +++ b/frontend/src/lib/diagramm.ts @@ -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 +} + +/** 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 +} diff --git a/frontend/src/lib/nutzung.test.ts b/frontend/src/lib/nutzung.test.ts new file mode 100644 index 0000000..13cda8d --- /dev/null +++ b/frontend/src/lib/nutzung.test.ts @@ -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) + }) +}) diff --git a/frontend/src/lib/nutzung.ts b/frontend/src/lib/nutzung.ts new file mode 100644 index 0000000..d7629c9 --- /dev/null +++ b/frontend/src/lib/nutzung.ts @@ -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 = { + "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 | 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 + /** 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 + 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 | 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 | undefined) => { + const w: Record = {} + 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, + } +} diff --git a/frontend/src/lib/typen.ts b/frontend/src/lib/typen.ts index 49d7a0b..5e1c787 100644 --- a/frontend/src/lib/typen.ts +++ b/frontend/src/lib/typen.ts @@ -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 + /** 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> + /** Stunden des Tages (00–23) der letzten 24 h — nicht chronologisch; dafür gibt es `stunden`. */ letzte_24h: { stunde: string; je_absender: Record }[] zuletzt_geladen: Record + // --- 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 }[] + /** Je Modell, wie oft es sich nach dem Laden bereit meldete — neueste zuerst. */ + ladevorgaenge?: { modell: string; anzahl: number; zeiten: string[] }[] } export interface Modell { diff --git a/frontend/src/test/nutzung.ts b/frontend/src/test/nutzung.ts new file mode 100644 index 0000000..6a185c8 --- /dev/null +++ b/frontend/src/test/nutzung.ts @@ -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 { + 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 = {} + 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"] }, + ], + } +} diff --git a/frontend/src/views/ModellNutzung.test.tsx b/frontend/src/views/ModellNutzung.test.tsx new file mode 100644 index 0000000..4857724 --- /dev/null +++ b/frontend/src/views/ModellNutzung.test.tsx @@ -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) => + render() + +const zeileMit = (text: string) => screen.getByText(text).closest('[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('[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('[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('[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() + expect(screen.getByText("Wird gelesen …")).toBeInTheDocument() + rerender() + expect(screen.getByRole("alert")).toHaveTextContent("Die Nutzung lässt sich gerade nicht lesen: MC2 ist nicht erreichbar.") + }) +}) diff --git a/frontend/src/views/ModellNutzung.tsx b/frontend/src/views/ModellNutzung.tsx new file mode 100644 index 0000000..86024f0 --- /dev/null +++ b/frontend/src/views/ModellNutzung.tsx @@ -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 ( +
+ {label} + {wert} + {zusatz && {zusatz}} +
+ ) +} + +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 ( + + {label} + {wert} + {zusatz && {zusatz}} + + ) +} + +function AbsenderTabelle({ zeilen }: { zeilen: AbsenderZeile[] }) { + return ( +
+ + {zeilen.map((a) => ( +
+ + + {a.name} + + + + {anzahlText(a.anfragen)} + · {anteilText(a.anteil)} aller Anfragen + + + + + + + + + +
+ ))} +
+ ) +} + +function Diagramm({ titel, zusatz, children }: { titel: string; zusatz: string; children: ReactNode }) { + return ( +
+
+

{titel}

+ {zusatz} +
+ {children} +
+ ) +} + +/** Wann welches Modell geladen wurde — eingeklappt, die Zusammenfassung steht in der Kopfzeile. */ +function Ladevorgaenge({ modelle, rollen }: { modelle: ModellLadungen[]; rollen: Record }) { + 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 ( +
+ + + + Wann welches Modell geladen wurde + + + {modelle.length === 1 ? "1 Modell" : `${modelle.length} Modelle`}{gesamt !== null ? ` · ${anzahlText(gesamt)}× geladen` : ""} + + +

+ 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. +

+
+
+ Modell + Geladen + Zuletzt + Davor +
+ {modelle.map((m) => ( +
+ + {m.modell} + {rollen[m.modell] && {rollen[m.modell]}} + + + {m.anzahl !== null ? `${anzahlText(m.anzahl)}×` : "–"} + + + zuletzt {flugplanZeit(m.zeiten[0])} + + + {m.zeiten.length > 1 && davor } + {m.zeiten.length > 1 ? m.zeiten.slice(1, 4).map((z) => flugplanZeit(z)).join(" · ") : –} + +
+ ))} +
+
+ ) +} + +/** + * 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 +}) { + if (!nutzung) { + return ( + + {laedt ? ( +

Wird gelesen …

+ ) : ( +

Die Nutzung lässt sich gerade nicht lesen{fehler ? `: ${fehler}` : "."}

+ )} +
+ ) + } + 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 ( + {bild.zeitraum}{bild.stand ? ` · Stand ${bild.stand}` : ""}} + > + {g.anfragen === 0 ? ( +

Keine Anfragen aufgezeichnet.

+ ) : ( + <> +
+ + + + +
+ +
+ + + + + + +
+ + )} + +

+ 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. +

+
+ ) +} diff --git a/frontend/src/views/Modelle.tsx b/frontend/src/views/Modelle.tsx index c9b9864..feb71a6 100644 --- a/frontend/src/views/Modelle.tsx +++ b/frontend/src/views/Modelle.tsx @@ -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 ( - - {laedt ? ( -

Wird gelesen …

- ) : ( -

Die Nutzung lässt sich gerade nicht lesen{fehler ? `: ${fehler}` : "."}

- )} -
- ) - } - 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 ( - letzte {nutzung.tage} Tage}> - {nutzung.absender.length === 0 &&

Keine Anfragen aufgezeichnet.

} -
    - {nutzung.absender.map((a) => ( -
  • -
    {a.name}{zahl(a.anfragen)}
    -
    -
  • - ))} -
-
- Anfragen je Stunde, letzte 24 h - - {stunden.map((n, i) => { - const h = n ? Math.max(3, (n / maxStunde) * 50) : 2 - return - })} - {[0, 6, 12, 18].map((s) => ( - {s} - ))} - -
-

Anfragen an llama-swap. NerdQuiz geht direkt dorthin, alles andere über MC2.

-
- ) -} - const STATUS_ART: Record = { 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 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 = {} + 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 ( <> @@ -524,10 +488,9 @@ export function ModelleSeite() { -
- - -
+ {/* Die Nutzung braucht mit ihren Diagrammen die volle Breite (vorher halbe Zeile neben dem Radar). */} + + setSuche(true)}> Modelle selbst suchen}