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