""" Wer nutzt die Modelle? (Box-Wart, Umbau 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"). 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). """ import os import re import subprocess import threading import time from collections import Counter, defaultdict from datetime import datetime, timedelta from zoneinfo import ZoneInfo CACHE_S = 600 TAGE = 7 LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin")) _STANDARD_NAMEN = { "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/_]+)') _GELADEN = re.compile(r"<([^>]+)> Health check passed") _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 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] = {} grenze_24h = jetzt - timedelta(hours=24) 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 (m := _GELADEN.search(z)): geladen[m.group(1)] = ts.isoformat() 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 if ts >= grenze_24h: stunden[f"{ts.astimezone(LOCAL_TZ).hour:02d}"][wer] += 1 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}", {}))} for h in range(24)], "zuletzt_geladen": geladen, } def nutzung() -> dict: """Gecachte Auswertung der letzten TAGE Tage (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()) with _lock: _cache.update(ts=time.time(), daten=daten) return daten