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
|
||||
|
||||
Reference in New Issue
Block a user