Files
mission-control-v2/backend/services/modell_nutzung.py
T
HitonabiandClaude Opus 5.5 7834de9592 nutzung: Antwortzeiten, Fehler, Verlauf je Stunde und Ladevorgänge aus dem llama-swap-Journal
„Wer nutzt die Modelle“ war zu dünn (nur Anzahl je Absender). Das Journal gibt je Anfrage auch Status und
Dauer her — daraus rechnet die Box jetzt mehr, alles nur ergänzt; die alten Felder bleiben für den Flugplan
(letzte_24h) und für ältere Oberflächen.

- Je Absender: typische Antwortzeit (Median — einzelne 30-s-Wartezeiten bei Neustarts verziehen sonst den
  Schnitt), „9 von 10 bis“ (90-%-Wert als nächster Rang, also eine echte Antwortzeit), Rechenzeit (Summe aller
  Antwortzeiten), Fehler (Status nicht 2xx, je Code) und die letzte Anfrage. Go-Dauern werden samt ms/µs/ns
  und „1m2.3s“ gelesen.
- Fenster = die letzten 7 Kalendertage bis jetzt statt 7×24 h rollend: je_tag hat genau 7 Tage, heute als
  letzten, auch leere — alle Zahlen beziehen sich auf dasselbe Fenster.
- „stunden“: die letzten 24 h chronologisch mit Beginn (in UTC gerechnet, damit die Zeitumstellung keine
  Stunde verschluckt). letzte_24h bleibt Stunde des Tages wie bisher.
- „ladevorgaenge“: je Modell Anzahl und die letzten Zeitpunkte („Health check passed“).
- „reihe“: feste Reihenfolge aus der Namensliste, damit die Farbe dem Absender folgt und nicht dem Rang.
  NerdQuiz als größter Verbraucher zuerst (ruhiges Blau).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:26:31 +02:00

206 lines
9.1 KiB
Python

"""
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