diff --git a/backend/services/modell_nutzung.py b/backend/services/modell_nutzung.py index 7ff0c30..288fd49 100644 --- a/backend/services/modell_nutzung.py +++ b/backend/services/modell_nutzung.py @@ -1,40 +1,62 @@ """ -Wer nutzt die Modelle? (Box-Wart, Umbau 09/2026) +Wer nutzt die Modelle? (Box-Wart, Umbau 09/2026; ausgebaut am 25.09.2026) -llama-swap protokolliert jede Anfrage mit Absender-IP im Journal. Daraus zählen wir je -Absender und Tag die Chat-Anfragen, je Stunde der letzten 24 h, und wann welches Modell -zuletzt geladen wurde ("Health check passed"). +llama-swap schreibt jede Anfrage ins Journal — Absender-IP, Pfad, Status, Antwortgröße, Programm und +am Zeilenende die Dauer als Go-Dauer ("388.09ms", "3.41s", "1m2.3s"): + + ... [INFO] Request 192.168.178.28 "POST /v1/chat/completions HTTP/1.1" 200 806 "Go-http-client/1.1" 388.09ms + +Daraus rechnen wir für die letzten TAGE Kalendertage (heute zählt bis jetzt): + +- je Absender die Chat-Anfragen, die Fehler (Status nicht 2xx, je Code), die typische Antwortzeit + (Median), den Wert, unter dem 9 von 10 Antworten blieben (90-%-Wert), die Summe aller Antwortzeiten + ("Rechenzeit"; gleichzeitige Anfragen zählen einzeln) und die letzte Anfrage; +- den Verlauf je Tag und je Stunde der letzten 24 h, beides je Absender; +- wann welches Modell geladen wurde ("Health check passed"). + +Welches Modell eine Anfrage bedient hat, steht nicht in diesen Zeilen — das zeigen wir darum nicht. Warum das Journal und nicht /api/metrics/activity: Letzteres hält nur die letzten rund 25 Anfragen. NerdQuiz schickt nachts in einer Stunde fast 600 — das wäre längst überschrieben, bevor jemand nachsieht (gemessen am 23.09.2026). Die Zuordnung IP → Name kommt aus MC_NUTZER_NAMEN ("ip=Name;ip=Name"); Loopback ist alles, -was über MC2 und den Gateway kommt (Lucy, OpenChamber, MC2 selbst). +was über MC2 und den Gateway kommt (Lucy, OpenChamber, MC2 selbst). Die Reihenfolge der Namen +legt die Farbe im Cockpit fest ("reihe"): Ein Absender behält seine Farbe, auch wenn sich die +Rangfolge nach Anfragen ändert. """ +import math import os import re +import statistics import subprocess import threading import time from collections import Counter, defaultdict -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone +from datetime import time as uhrzeit from kern.zeit import LOCAL_TZ CACHE_S = 600 TAGE = 7 +LADEZEITEN = 10 # so viele Ladezeitpunkte je Modell (neueste zuerst) reichen der Anzeige +# Die Reihenfolge ist die Farbe (Blau, Magenta, Gelb): Der größte Verbraucher bekommt das ruhige Blau. _STANDARD_NAMEN = { + "192.168.178.28": "NerdQuiz auf Arcane", "127.0.0.1": "Lucy und OpenChamber über MC2", "::1": "Lucy und OpenChamber über MC2", - "192.168.178.28": "NerdQuiz auf Arcane", "192.168.178.22": "NerdQuiz-Tests vom PC", } -_ANFRAGE = re.compile(r'\] Request (\S+) "POST (/v1/[a-z/_]+)') +# Status ist optional: Fehlt er (anderes Zeilenformat), zählt die Anfrage, aber nicht als Fehler. +_ANFRAGE = re.compile(r'\] Request (\S+) "POST (/v1/[a-z/_]+)[^"]*"(?: (\d{3}))?') _GELADEN = re.compile(r"<([^>]+)> Health check passed") +# Go schreibt Mikrosekunden mit dem Mikro-Zeichen (U+00B5); das griechische My und "us" gehen auch. +_DAUER_TEIL = re.compile(r"(\d+(?:\.\d+)?)(h|ms|m|s|µs|μs|us|ns)") +_DAUER_FAKTOR = {"h": 3600.0, "m": 60.0, "s": 1.0, "ms": 1e-3, "µs": 1e-6, "μs": 1e-6, "us": 1e-6, "ns": 1e-9} _lock = threading.Lock() _cache: dict = {"ts": 0.0, "daten": None} @@ -61,13 +83,44 @@ def _journal(seit: str) -> list[str]: return out.stdout.splitlines() +def dauer_s(text: str) -> float | None: + """Go-Dauer → Sekunden: "388.09ms" → 0.38809, "1m2.5s" → 62.5, "850µs" → 0.00085. Sonst None.""" + teile = list(_DAUER_TEIL.finditer(text)) + if not teile or "".join(t.group(0) for t in teile) != text: + return None + return sum(float(t.group(1)) * _DAUER_FAKTOR[t.group(2)] for t in teile) + + +def _kennwerte(dauern: list[float], summe: float) -> dict: + """Typisch (Median) und 9 von 10 höchstens (90-%-Wert als nächster Rang, also eine echte + Antwortzeit), dazu die Summe — alles in Sekunden.""" + werte = sorted(dauern) + return { + "dauer_typisch_s": round(statistics.median(werte), 3) if werte else None, + "dauer_p90_s": round(werte[math.ceil(0.9 * len(werte)) - 1], 3) if werte else None, + "dauer_summe_s": round(summe, 1), + } + + def werte_aus(zeilen: list[str], jetzt: datetime, namen: dict[str, str]) -> dict: - """Reine Auswertung (testbar): Journal-Zeilen → Nutzung je Absender, Stunde, Modell.""" - je_absender: Counter = Counter() - je_tag: dict[str, Counter] = defaultdict(Counter) - stunden: dict[str, Counter] = defaultdict(Counter) # "HH" → Absender → Anzahl (letzte 24 h) - geladen: dict[str, str] = {} + """Reine Auswertung (testbar): Journal-Zeilen → Nutzung je Absender, Tag, Stunde und Modell.""" + jetzt = jetzt.astimezone(LOCAL_TZ) + tage = [jetzt.date() - timedelta(days=TAGE - 1 - i) for i in range(TAGE)] + beginn = datetime.combine(tage[0], uhrzeit(0), tzinfo=LOCAL_TZ) + # Stunden in UTC gerechnet, damit die Zeitumstellung keine Stunde verschluckt oder verdoppelt. + stunde0 = jetzt.astimezone(timezone.utc).replace(minute=0, second=0, microsecond=0) - timedelta(hours=23) grenze_24h = jetzt - timedelta(hours=24) + + anfragen: Counter = Counter() + fehler: dict[str, Counter] = defaultdict(Counter) # Absender → Status → Anzahl + dauern: dict[str, list[float]] = defaultdict(list) # nur erfolgreiche Antworten + summe: Counter = Counter() # alle Antwortzeiten in Sekunden + zuletzt: dict[str, datetime] = {} + je_tag: dict[str, Counter] = {t.isoformat(): Counter() for t in tage} + je_stunde = [Counter() for _ in range(24)] # chronologisch, die letzte ist die laufende + stunden_alt: dict[str, Counter] = defaultdict(Counter) # "HH" → Absender (für den Flugplan) + geladen: dict[str, list[datetime]] = defaultdict(list) + for z in zeilen: try: ts = datetime.fromisoformat(z.split(" ", 1)[0]) @@ -75,35 +128,78 @@ def werte_aus(zeilen: list[str], jetzt: datetime, namen: dict[str, str]) -> dict continue if ts.tzinfo is None: ts = ts.replace(tzinfo=LOCAL_TZ) + if ts < beginn: + continue if (m := _GELADEN.search(z)): - geladen[m.group(1)] = ts.isoformat() + geladen[m.group(1)].append(ts) continue m = _ANFRAGE.search(z) if not m or not m.group(2).startswith("/v1/chat/completions"): continue wer = namen.get(m.group(1), m.group(1)) - je_absender[wer] += 1 - je_tag[ts.date().isoformat()][wer] += 1 + anfragen[wer] += 1 + ok = m.group(3) is None or m.group(3).startswith("2") + if not ok: + fehler[wer][m.group(3)] += 1 + if (d := dauer_s(z.rsplit(None, 1)[-1])) is not None: + summe[wer] += d + if ok: + dauern[wer].append(d) + if wer not in zuletzt or ts > zuletzt[wer]: + zuletzt[wer] = ts + if (tag := ts.astimezone(LOCAL_TZ).date().isoformat()) in je_tag: + je_tag[tag][wer] += 1 + if 0 <= (i := int((ts - stunde0).total_seconds() // 3600)) < 24: + je_stunde[i][wer] += 1 if ts >= grenze_24h: - stunden[f"{ts.astimezone(LOCAL_TZ).hour:02d}"][wer] += 1 + stunden_alt[f"{ts.astimezone(LOCAL_TZ).hour:02d}"][wer] += 1 + + bekannt = list(dict.fromkeys(namen.values())) + fremde = [n for n, _ in anfragen.most_common() if n not in bekannt] + + def reihe(name: str) -> int: + return bekannt.index(name) if name in bekannt else len(bekannt) + fremde.index(name) + + alle_fehler: Counter = sum(fehler.values(), Counter()) + zuletzt_geladen = {modell: max(zeiten) for modell, zeiten in geladen.items()} return { "tage": TAGE, - "absender": [{"name": n, "anfragen": c} for n, c in je_absender.most_common()], - "je_tag": {tag: dict(c) for tag, c in sorted(je_tag.items())}, - "letzte_24h": [{"stunde": f"{h:02d}", "je_absender": dict(stunden.get(f"{h:02d}", {}))} + "seit": beginn.isoformat(), + "stand": jetzt.isoformat(timespec="seconds"), + "absender": [ + {"name": n, "anfragen": c, "reihe": reihe(n), + "fehler": sum(fehler[n].values()), "fehler_codes": dict(fehler[n]), + **_kennwerte(dauern[n], summe[n]), + "zuletzt": zuletzt[n].isoformat()} + for n, c in anfragen.most_common() + ], + "gesamt": { + "anfragen": sum(anfragen.values()), + "fehler": sum(alle_fehler.values()), "fehler_codes": dict(alle_fehler), + **_kennwerte([d for liste in dauern.values() for d in liste], sum(summe.values())), + }, + "je_tag": {tag: dict(c) for tag, c in je_tag.items()}, + "stunden": [{"beginn": (stunde0 + timedelta(hours=i)).astimezone(LOCAL_TZ).isoformat(), + "je_absender": dict(c)} for i, c in enumerate(je_stunde)], + "letzte_24h": [{"stunde": f"{h:02d}", "je_absender": dict(stunden_alt.get(f"{h:02d}", {}))} for h in range(24)], - "zuletzt_geladen": geladen, + "zuletzt_geladen": {modell: t.isoformat() for modell, t in zuletzt_geladen.items()}, + "ladevorgaenge": [ + {"modell": modell, "anzahl": len(geladen[modell]), + "zeiten": [t.isoformat() for t in sorted(geladen[modell], reverse=True)[:LADEZEITEN]]} + for modell in sorted(zuletzt_geladen, key=zuletzt_geladen.__getitem__, reverse=True) + ], } def nutzung() -> dict: - """Gecachte Auswertung der letzten TAGE Tage (10 min).""" + """Gecachte Auswertung der letzten TAGE Kalendertage (10 min).""" with _lock: if _cache["daten"] is not None and time.time() - _cache["ts"] < CACHE_S: return _cache["daten"] jetzt = datetime.now(LOCAL_TZ) - seit = (jetzt - timedelta(days=TAGE)).strftime("%Y-%m-%d %H:%M:%S") - daten = werte_aus(_journal(seit), jetzt, _namen()) + seit = datetime.combine(jetzt.date() - timedelta(days=TAGE - 1), uhrzeit(0)) + daten = werte_aus(_journal(seit.strftime("%Y-%m-%d %H:%M:%S")), jetzt, _namen()) with _lock: _cache.update(ts=time.time(), daten=daten) return daten diff --git a/backend/tests/test_modell_nutzung.py b/backend/tests/test_modell_nutzung.py index 6b0d64b..18c4584 100644 --- a/backend/tests/test_modell_nutzung.py +++ b/backend/tests/test_modell_nutzung.py @@ -1,7 +1,8 @@ -"""Tests für die Auswertung des llama-swap-Journals — Zeilenformat wie auf der Box (23.09.2026).""" +"""Tests für die Auswertung des llama-swap-Journals — Zeilenformat wie auf der Box (23. und 25.09.2026).""" from datetime import datetime +import pytest from services import modell_nutzung ZEILEN = [ @@ -19,18 +20,32 @@ ZEILEN = [ ] NAMEN = {"127.0.0.1": "Lucy und OpenChamber über MC2", "192.168.178.28": "NerdQuiz auf Arcane"} +JETZT = datetime.fromisoformat("2026-09-23T20:45:00+02:00") -def test_zaehlt_nur_chat_anfragen_je_absender(): - d = modell_nutzung.werte_aus(ZEILEN, datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN) - assert d["absender"] == [ - {"name": "NerdQuiz auf Arcane", "anfragen": 3}, - {"name": "Lucy und OpenChamber über MC2", "anfragen": 1}, +def anfrage(zeit: str, ip: str = "192.168.178.28", status: int = 200, dauer: str = "1s", + programm: str = "Go-http-client/1.1") -> str: + return (f'{zeit} tobisniceaiarbeitstier llama-swap[237863]: [INFO] Request {ip} ' + f'"POST /v1/chat/completions HTTP/1.1" {status} 805 "{programm}" {dauer}') + + +def je_name(d: dict) -> dict: + return {a["name"]: a for a in d["absender"]} + + +def test_zaehlt_nur_chat_anfragen_der_letzten_sieben_tage_je_absender(): + d = modell_nutzung.werte_aus(ZEILEN, JETZT, NAMEN) + # Der Lauf vom 10.09. liegt vor dem Fenster (17.09. 00:00 bis jetzt), Embeddings zählen nicht. + assert [(a["name"], a["anfragen"]) for a in d["absender"]] == [ + ("NerdQuiz auf Arcane", 2), + ("Lucy und OpenChamber über MC2", 1), ] + assert d["seit"] == "2026-09-17T00:00:00+02:00" + assert d["gesamt"]["anfragen"] == 3 def test_letzte_24h_je_stunde_und_zuletzt_geladen(): - d = modell_nutzung.werte_aus(ZEILEN, datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN) + d = modell_nutzung.werte_aus(ZEILEN, JETZT, NAMEN) stunden = {s["stunde"]: s["je_absender"] for s in d["letzte_24h"]} assert stunden["03"] == {"NerdQuiz auf Arcane": 2} # der Lauf vom 10.09. liegt außerhalb assert stunden["07"] == {"Lucy und OpenChamber über MC2": 1} @@ -38,8 +53,138 @@ def test_letzte_24h_je_stunde_und_zuletzt_geladen(): assert d["zuletzt_geladen"]["Qwen3.8-27B"].startswith("2026-09-17T19:59") -def test_unbekannte_ip_bleibt_als_ip_stehen(): - zeile = ('2026-09-23T10:00:00+02:00 box llama-swap[1]: [INFO] Request 10.0.0.9 ' - '"POST /v1/chat/completions HTTP/1.1" 200 1 "curl/8" 1s') - d = modell_nutzung.werte_aus([zeile], datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN) - assert d["absender"] == [{"name": "10.0.0.9", "anfragen": 1}] +def test_unbekannte_ip_bleibt_als_ip_stehen_und_kommt_nach_den_bekannten(): + zeile = anfrage("2026-09-23T10:00:00+02:00", ip="10.0.0.9", programm="curl/8") + d = modell_nutzung.werte_aus([zeile], JETZT, NAMEN) + assert [(a["name"], a["anfragen"], a["reihe"]) for a in d["absender"]] == [("10.0.0.9", 1, 2)] + + +@pytest.mark.parametrize(("text", "sekunden"), [ + ("388.092603ms", 0.388092603), + ("3.416448406s", 3.416448406), + ("31.899196571s", 31.899196571), + ("1m2.5s", 62.5), + ("2h0m0s", 7200.0), + ("850µs", 0.00085), + ("850μs", 0.00085), + ("12ns", 0.000000012), + ("0s", 0.0), +]) +def test_dauer_liest_go_dauern(text, sekunden): + assert modell_nutzung.dauer_s(text) == pytest.approx(sekunden) + + +@pytest.mark.parametrize("text", ["", "abc", "805", "1.5x", "s", '"Go-http-client/1.1"', "1s2"]) +def test_dauer_ohne_einheit_ist_keine_dauer(text): + assert modell_nutzung.dauer_s(text) is None + + +def test_antwortzeiten_fehler_und_letzte_anfrage_je_absender(): + zeilen = [ + anfrage("2026-09-23T09:00:00+02:00", dauer="400ms"), + anfrage("2026-09-23T09:01:00+02:00", dauer="1.2s"), + anfrage("2026-09-23T09:02:00+02:00", dauer="1m2s"), + anfrage("2026-09-23T09:03:00+02:00", status=502, dauer="31.9s"), + anfrage("2026-09-23T12:00:00+02:00", ip="127.0.0.1", status=499, dauer="5s", programm="python-httpx/0.28.1"), + anfrage("2026-09-23T12:01:00+02:00", ip="127.0.0.1", dauer="600ms", programm="python-httpx/0.28.1"), + # Ohne Dauer am Ende (anderes Format): zählt, geht aber in keine Zeit ein. + '2026-09-23T12:02:00+02:00 box llama-swap[1]: [INFO] Request 127.0.0.1 "POST /v1/chat/completions HTTP/1.1"', + ] + d = modell_nutzung.werte_aus(zeilen, JETZT, NAMEN) + nq, lucy = je_name(d)["NerdQuiz auf Arcane"], je_name(d)["Lucy und OpenChamber über MC2"] + + assert nq["anfragen"] == 4 + assert nq["fehler"] == 1 and nq["fehler_codes"] == {"502": 1} + # Typisch und 9 von 10 nur aus den erfolgreichen Antworten; die Rechenzeit zählt alle. + assert nq["dauer_typisch_s"] == pytest.approx(1.2) + assert nq["dauer_p90_s"] == pytest.approx(62.0) + assert nq["dauer_summe_s"] == pytest.approx(0.4 + 1.2 + 62 + 31.9, abs=0.05) + assert nq["zuletzt"] == "2026-09-23T09:03:00+02:00" + + assert lucy["anfragen"] == 3 + assert lucy["fehler_codes"] == {"499": 1} + assert lucy["dauer_typisch_s"] == pytest.approx(0.6) + assert lucy["zuletzt"] == "2026-09-23T12:02:00+02:00" + + assert d["gesamt"]["fehler"] == 2 + assert d["gesamt"]["fehler_codes"] == {"502": 1, "499": 1} + assert d["gesamt"]["dauer_typisch_s"] == pytest.approx(0.9) # Median aus 0,4 · 0,6 · 1,2 · 62 + assert d["gesamt"]["dauer_p90_s"] == pytest.approx(62.0) + + +def test_ohne_anfragen_bleiben_die_zeiten_leer(): + d = modell_nutzung.werte_aus([], JETZT, NAMEN) + assert d["absender"] == [] + assert d["gesamt"] == {"anfragen": 0, "fehler": 0, "fehler_codes": {}, "dauer_typisch_s": None, + "dauer_p90_s": None, "dauer_summe_s": 0.0} + assert list(d["je_tag"].values()) == [{}] * 7 + assert all(s["je_absender"] == {} for s in d["stunden"]) + + +def test_farbe_folgt_dem_absender_nicht_dem_rang(): + """Überholt Lucy NerdQuiz, bleibt die Reihe (= Farbe) gleich — sie kommt aus der Namensliste.""" + zeilen = [anfrage(f"2026-09-23T10:0{i}:00+02:00", ip="127.0.0.1") for i in range(5)] + zeilen.append(anfrage("2026-09-23T11:00:00+02:00")) + namen = {"192.168.178.28": "NerdQuiz auf Arcane", "127.0.0.1": "Lucy", "::1": "Lucy", "192.168.178.22": "PC"} + d = modell_nutzung.werte_aus(zeilen, JETZT, namen) + assert [(a["name"], a["reihe"]) for a in d["absender"]] == [("Lucy", 1), ("NerdQuiz auf Arcane", 0)] + + +def test_verlauf_je_tag_deckt_sieben_kalendertage_bis_heute(): + zeilen = [ + anfrage("2026-09-16T23:59:59+02:00"), # vor dem Fenster + anfrage("2026-09-17T00:00:00+02:00"), + anfrage("2026-09-20T22:30:00+00:00"), # in UTC geschrieben: in Berlin schon der 21.09. + anfrage("2026-09-23T20:00:00+02:00", ip="127.0.0.1"), + ] + d = modell_nutzung.werte_aus(zeilen, JETZT, NAMEN) + assert list(d["je_tag"]) == [f"2026-09-{t}" for t in range(17, 24)] + assert d["je_tag"]["2026-09-17"] == {"NerdQuiz auf Arcane": 1} + assert d["je_tag"]["2026-09-20"] == {} + assert d["je_tag"]["2026-09-21"] == {"NerdQuiz auf Arcane": 1} + assert d["je_tag"]["2026-09-23"] == {"Lucy und OpenChamber über MC2": 1} + + +def test_stunden_laufen_chronologisch_bis_zur_laufenden_stunde(): + zeilen = [ + anfrage("2026-09-22T20:59:00+02:00"), # vor der ersten Stunde (21:00 gestern) + anfrage("2026-09-22T21:10:00+02:00"), + anfrage("2026-09-23T00:30:00+02:00", ip="127.0.0.1"), + anfrage("2026-09-23T20:44:00+02:00"), + ] + d = modell_nutzung.werte_aus(zeilen, JETZT, NAMEN) + stunden = d["stunden"] + assert len(stunden) == 24 + assert stunden[0]["beginn"] == "2026-09-22T21:00:00+02:00" + assert stunden[-1]["beginn"] == "2026-09-23T20:00:00+02:00" + assert stunden[0]["je_absender"] == {"NerdQuiz auf Arcane": 1} + assert stunden[3]["je_absender"] == {"Lucy und OpenChamber über MC2": 1} + assert stunden[-1]["je_absender"] == {"NerdQuiz auf Arcane": 1} + + +def test_stunden_ueber_die_zeitumstellung(): + """Am 25.10.2026 gibt es 02:00 zweimal — die Stunden bleiben 24 echte Stunden.""" + d = modell_nutzung.werte_aus([], datetime.fromisoformat("2026-10-25T12:30:00+01:00"), NAMEN) + beginne = [s["beginn"] for s in d["stunden"]] + assert beginne[0] == "2026-10-24T14:00:00+02:00" + assert "2026-10-25T02:00:00+02:00" in beginne and "2026-10-25T02:00:00+01:00" in beginne + assert beginne[-1] == "2026-10-25T12:00:00+01:00" + + +def test_ladevorgaenge_je_modell_neueste_zuerst(): + geladen = "box llama-swap[1]: [INFO] <{}> Health check passed on http://localhost:5804/health" + zeilen = [ + f"2026-09-20T06:09:02+02:00 {geladen.format('Qwen3.6-35B-A3B')}", + f"2026-09-21T14:57:07+02:00 {geladen.format('Qwen3-VL-30B-A3B-Instruct')}", + f"2026-09-23T11:25:09+02:00 {geladen.format('Qwen3.6-35B-A3B')}", + f"2026-09-23T11:25:46+02:00 {geladen.format('Qwen3.6-35B-A3B')}", + f"2026-09-10T11:25:46+02:00 {geladen.format('Altes-Modell')}", # vor dem Fenster + ] + d = modell_nutzung.werte_aus(zeilen, JETZT, NAMEN) + assert d["ladevorgaenge"] == [ + {"modell": "Qwen3.6-35B-A3B", "anzahl": 3, + "zeiten": ["2026-09-23T11:25:46+02:00", "2026-09-23T11:25:09+02:00", "2026-09-20T06:09:02+02:00"]}, + {"modell": "Qwen3-VL-30B-A3B-Instruct", "anzahl": 1, "zeiten": ["2026-09-21T14:57:07+02:00"]}, + ] + assert d["zuletzt_geladen"] == {"Qwen3.6-35B-A3B": "2026-09-23T11:25:46+02:00", + "Qwen3-VL-30B-A3B-Instruct": "2026-09-21T14:57:07+02:00"} 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 271ea2e..37e85a9 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 3d060e1..6b03d00 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", } @@ -537,6 +495,12 @@ export function ModelleSeite() { const coderZwilling = zwilling(coder, "coder-bild") const weitere = liste.filter((m) => ![hirn, coder, hirnZwilling, coderZwilling].includes(m)) const zuletzt = nutzung.data?.zuletzt_geladen ?? {} + // Für die Ladeliste der Nutzung: Modellname → Rolle, damit dort nicht nur Dateinamen stehen. + const rollen: Record = {} + for (const [m, rolle] of [[hirn, "Hirn"], [coder, "Coder"], [hirnZwilling, "Bild-Zwilling des Hirns"], + [coderZwilling, "Bild-Zwilling des Coders"]] as const) { + if (m) rollen[m.name] = rolle + } return ( <> @@ -547,10 +511,9 @@ export function ModelleSeite() { -
- - -
+ {/* Die Nutzung braucht mit ihren Diagrammen die volle Breite (vorher halbe Zeile neben dem Radar). */} + + setSuche(true)}> Modelle selbst suchen}