""" Wer nutzt die Modelle? (Box-Wart, Umbau 09/2026; ausgebaut am 25.09.2026) 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). 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, 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.22": "NerdQuiz-Tests vom PC", } # 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} def _namen() -> dict[str, str]: namen = dict(_STANDARD_NAMEN) for paar in os.environ.get("MC_NUTZER_NAMEN", "").split(";"): if "=" in paar: ip, name = paar.split("=", 1) namen[ip.strip()] = name.strip() return namen def _journal(seit: str) -> list[str]: """Nur die Zeilen, die uns interessieren — llama-swap loggt sonst ~6.000 Status-Abfragen am Tag. Ohne systemd (Windows-Dev) leer.""" cmd = (f"journalctl -u llama-swap --since '{seit}' -o short-iso --no-pager 2>/dev/null" " | grep -E 'POST /v1/|Health check passed'") try: out = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, timeout=60) except Exception: return [] 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, 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]) except ValueError: 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)].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)) 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_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, "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": {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 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 = 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