MC2 wird Updater, Waechter und Modell-Radar (Konzept „MC2 als Box-Wart“, 23.09.2026). - Neuer Waechter (services/waechter.py) loest sentry.py ab: Dienste, Timer-Laeufe, Hermes-Jobs samt Werkzeugfehlern, Kern-HTTP-Proben, Platte. Abgestuerzte Dienste startet er selbst neu (max. 2/h), rote Hinweise gehen an Telegram und Lucy. Laeuft im mc2-steward; waehrend eines Updates haelt er still. - Neue Schnittstellen (routers/boxwart.py): /api/start, /api/hinweise (+ Aktionen), /api/modelle/nutzung, /api/zeitplan. - Modell-Nutzung aus dem llama-swap-Journal (wer fragt wie oft, 24 h je Stunde). - Entfernt: Ideen, Wissen, Chronik, Skills, Verbinden, Konsolen-Proxy, /api/events; Lucys Werkzeug idee_notieren; box_status nennt jetzt die offenen Hinweise. - Behoben: projekte-sync ueberspringt leere Gitea-Repos (lief seit 07.09. stuendlich rot); Motor-Version kam aus dem verwaisten /opt/llamacpp statt /opt/llamacpp-vulkan. - Erste Backend-Tests (8) fuer Waechter und Modell-Nutzung. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
110 lines
4.0 KiB
Python
110 lines
4.0 KiB
Python
"""
|
|
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
|