From 7834de9592172be39c373c5c4c7caa92437b1188 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 25 Sep 2026 13:26:31 +0200 Subject: [PATCH 1/2] =?UTF-8?q?nutzung:=20Antwortzeiten,=20Fehler,=20Verla?= =?UTF-8?q?uf=20je=20Stunde=20und=20Ladevorg=C3=A4nge=20aus=20dem=20llama-?= =?UTF-8?q?swap-Journal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit „Wer nutzt die Modelle“ war zu dünn (nur Anzahl je Absender). Das Journal gibt je Anfrage auch Status und Dauer her — daraus rechnet die Box jetzt mehr, alles nur ergänzt; die alten Felder bleiben für den Flugplan (letzte_24h) und für ältere Oberflächen. - Je Absender: typische Antwortzeit (Median — einzelne 30-s-Wartezeiten bei Neustarts verziehen sonst den Schnitt), „9 von 10 bis“ (90-%-Wert als nächster Rang, also eine echte Antwortzeit), Rechenzeit (Summe aller Antwortzeiten), Fehler (Status nicht 2xx, je Code) und die letzte Anfrage. Go-Dauern werden samt ms/µs/ns und „1m2.3s“ gelesen. - Fenster = die letzten 7 Kalendertage bis jetzt statt 7×24 h rollend: je_tag hat genau 7 Tage, heute als letzten, auch leere — alle Zahlen beziehen sich auf dasselbe Fenster. - „stunden“: die letzten 24 h chronologisch mit Beginn (in UTC gerechnet, damit die Zeitumstellung keine Stunde verschluckt). letzte_24h bleibt Stunde des Tages wie bisher. - „ladevorgaenge“: je Modell Anzahl und die letzten Zeitpunkte („Health check passed“). - „reihe“: feste Reihenfolge aus der Namensliste, damit die Farbe dem Absender folgt und nicht dem Rang. NerdQuiz als größter Verbraucher zuerst (ruhiges Blau). Co-Authored-By: Claude Opus 5.5 --- backend/services/modell_nutzung.py | 144 +++++++++++++++++++---- backend/tests/test_modell_nutzung.py | 169 +++++++++++++++++++++++++-- 2 files changed, 277 insertions(+), 36 deletions(-) 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"} From dfe0971180ac2436f87200e1dec950fe2a0011a9 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 25 Sep 2026 13:26:45 +0200 Subject: [PATCH 2/2] =?UTF-8?q?modelle:=20=E2=80=9EWer=20nutzt=20die=20Mod?= =?UTF-8?q?elle=E2=80=9C=20neu=20=E2=80=94=20Kennzahlen,=20Tabelle=20je=20?= =?UTF-8?q?Absender,=20lesbare=20S=C3=A4ulendiagramme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der User: „Viel zu dünn. Und den Graphen kann ja kein Mensch lesen.“ Vorher drei Balken und ein winziges Stunden-Histogramm ohne lesbare Achsen, halb so breit neben dem Radar. - Eigene Ansicht views/ModellNutzung.tsx in voller Breite (Radar darunter, ebenfalls volle Breite). - Kennzahlen: Anfragen mit Schnitt je vollem Tag, heute/gestern, typische Antwort mit „9 von 10 bis“, Fehler (ab 1 % bernstein, ab 5 % rot, immer mit Wort dazu, nie nur Farbe). - Tabelle je Absender: Anfragen mit Anteil-Balken, Antwortzeiten, Rechenzeit mit Anteil (NerdQuiz stellt 92 % der Anfragen, aber nur 75 % der Rechenzeit), Fehler in Worten („Modell nicht erreichbar (502)“), letzte Anfrage. Schmal als Karten mit Beschriftung. - Neuer Baustein components/diagramme/Saeulen.tsx für „je Tag (7 Tage)“ und „je Stunde (24 h)“: gestapelt je Absender, glatte y-Achse, Wochentag/Datum bzw. Uhrzeit an der x-Achse (Stunden ausgedünnt auf 3 bzw. 6 h), Legende, Summen auf den Tagessäulen (bei den Stunden nur die höchste), Anzeige per Zeiger, Finger und Pfeiltasten, darunter „Werte als Tabelle“. Auf dem Handy steht die Anzeige unter dem Diagramm, sonst verdeckte sie das halbe Bild. Säulen höchstens 24 px, 2 px Luft zwischen den Stücken statt Rändern. - Farben: neue Tokens --reihe-3 (Gelb) und --reihe-weitere (Grau; ab der vierten Farbe fasst „Weitere“ zusammen). Mit dem Paletten-Prüfer gegen #15181b gemessen: jedes Paar auch bei Rot-Grün-Schwäche getrennt. Türkis fiel durch (neben Magenta bei Grünschwäche ΔE 1,6), Violett gleicht der KI-Box-Farbe. Die Farbe folgt dem Absender (feste Reihe der Box), nicht dem Rang. - „Wann welches Modell geladen wurde“ eingeklappt darunter: Anzahl, zuletzt, davor — mit Rolle (Hirn, Coder, Bild-Zwilling) statt nur Dateiname. - useBreite aus Verlauf.tsx nach lib/breite.ts, damit beide Diagramme ihn teilen. - Ältere Stände der Box ohne die neuen Felder zeigen weiter Anzahlen und Verläufe, Zeiten als „–“ — die Homelab-Instanz fragt über den Partner und kann einen anderen Stand haben. frontend/dist ist bewusst nicht gebaut (kommt beim Zusammenführen). Co-Authored-By: Claude Opus 5.5 --- .../src/components/diagramme/Saeulen.test.tsx | 58 ++++ frontend/src/components/diagramme/Saeulen.tsx | 225 +++++++++++++++ frontend/src/components/diagramme/Verlauf.tsx | 17 +- frontend/src/index.css | 6 + frontend/src/lib/breite.ts | 15 + frontend/src/lib/diagramm.test.ts | 49 ++++ frontend/src/lib/diagramm.ts | 60 ++++ frontend/src/lib/nutzung.test.ts | 123 ++++++++ frontend/src/lib/nutzung.ts | 264 ++++++++++++++++++ frontend/src/lib/typen.ts | 35 ++- frontend/src/test/nutzung.ts | 69 +++++ frontend/src/views/ModellNutzung.test.tsx | 143 ++++++++++ frontend/src/views/ModellNutzung.tsx | 215 ++++++++++++++ frontend/src/views/Modelle.tsx | 61 +--- 14 files changed, 1275 insertions(+), 65 deletions(-) create mode 100644 frontend/src/components/diagramme/Saeulen.test.tsx create mode 100644 frontend/src/components/diagramme/Saeulen.tsx create mode 100644 frontend/src/lib/breite.ts create mode 100644 frontend/src/lib/diagramm.test.ts create mode 100644 frontend/src/lib/nutzung.test.ts create mode 100644 frontend/src/lib/nutzung.ts create mode 100644 frontend/src/test/nutzung.ts create mode 100644 frontend/src/views/ModellNutzung.test.tsx create mode 100644 frontend/src/views/ModellNutzung.tsx 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}