Merge branch 'worktree-agent-a7ad50f83f2d4236b' into wartung/helfer-nutzung-dienste

This commit is contained in:
Hitonabi
2026-09-25 13:31:28 +02:00
16 changed files with 1552 additions and 101 deletions
+120 -24
View File
@@ -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