- kern/messreihen.py: Minutenwerte als JSON-Zeilen je Quelle und Tag unter <Datenordner>/mc2-messwerte/, Anhaengen unter flock, Aufraeumen nach 8 Tagen, Lesen fuer 1h/24h/7d (60 s, 5-min- und 30-min-Mittel), Luecken bleiben null, kaputte Zeilen werden uebersprungen, Zaehler-Raten ohne Spruenge - KI-Box: Taktgeber im Steward (services/messwerte.py) schreibt jede Minute cpu, ram, gpu, Temperaturen, platte, Netz in Bytes/s und Tokens pro Minute; GET /api/messwerte (nur Rolle box) - Homelab: Der Ausfuehrer schickt jede Minute in einem eigenen Faden Host-Werte aus /proc und die Gaeste aus einem pvesh-Aufruf an POST /api/homelab/ausfuehrer/messwerte; services/homelab/messwerte.py rechnet die Zaehler in Bytes/s um (Neustarts und Spruenge ergeben null), GET /api/homelab/messwerte liefert Host und Gaeste wie im Inventar - Waechter (homelab): gelb, wenn der Host 10 Minuten ueber 95 % RAM oder 90 °C liegt - Aufraeumen der Modell-Platte bietet mc2-messwerte nie zum Loeschen an Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
185 lines
8.0 KiB
Python
185 lines
8.0 KiB
Python
"""Messwerte der KI-Box mit Verlauf (Monitoring, seit 24.09.2026).
|
|
|
|
Der Ereignisstrom (/api/stream) zeigt nur den Augenblick. Für den Verlauf schreibt der Steward (Rolle box, eigener
|
|
Prozess, übersteht MC2-Neustarts) jede Minute zur halben Minute einen Punkt über kern/messreihen.py (Quelle „box“):
|
|
|
|
cpu Mittel der Minute in % (Rechnung wie psutil.cpu_percent(interval=None), aber aus eigenen
|
|
cpu_times-Ständen: psutil merkt sich den letzten Aufruf je Thread, und die Messung läuft in wechselnden
|
|
Threads des Event-Loops)
|
|
ram, platte Belegung in % beim Messen (platte = das Modell-Laufwerk, wie im Cockpit)
|
|
gpu Mittel der Proben alle PROBE_S Sekunden in %: gpu_busy_percent zeigt nur den Augenblick, eine Probe je
|
|
Minute träfe die kurzen Antworten der Modelle kaum
|
|
temp_cpu/_gpu Mittel derselben Proben in °C
|
|
netz_rx/_tx Bytes/s über die Minute, alle Schnittstellen außer lo
|
|
tokens Prompt- plus Antwort-Tokens pro Minute (Differenz der Gesamtzähler aus services.token_stats)
|
|
|
|
Ein Zähler, der kleiner wird (Neustart des Gateways, neue Token-Datei), ergibt für diese Minute keine Rate (null).
|
|
GET /api/messwerte (routers/messwerte.py) liefert daraus die Reihen für 1h, 24h und 7d und den letzten Punkt.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import time
|
|
from collections.abc import Callable
|
|
|
|
import psutil
|
|
from config import MODELS_DIR
|
|
from kern import messreihen
|
|
|
|
from services import system, token_stats
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
QUELLE = "box"
|
|
REIHEN = ("cpu", "ram", "gpu", "temp_cpu", "temp_gpu", "platte", "netz_rx", "netz_tx", "tokens")
|
|
# Ein Trocken- oder Probelauf neben dem echten Steward schreibt nicht mit (sonst stünden zwei Punkte je Minute da).
|
|
ENABLED = (os.environ.get("MC_MESSWERTE_ENABLED", "1") != "0" and os.environ.get("MC_WAECHTER_TROCKEN", "") != "1"
|
|
and os.environ.get("MC_PROBELAUF", "") != "1")
|
|
PROBE_S = float(os.environ.get("MC_MESSWERTE_PROBE_S", "5"))
|
|
PHASE_S = 30 # gemessen wird zur halben Minute: jeder 60-s-Schritt bekommt genau einen Punkt
|
|
AKTUELL_MAX_S = 180 # älter ist der letzte Punkt nicht „aktuell“ (der Steward schreibt gerade nicht)
|
|
|
|
|
|
# --- Rohwerte ------------------------------------------------------------------------------------
|
|
|
|
def _cpu_zeiten() -> tuple[float, float] | None:
|
|
"""(beschäftigt, gesamt) in Sekunden seit dem Start — gezählt wie psutil.cpu_percent (guest steckt in user)."""
|
|
try:
|
|
z = psutil.cpu_times()
|
|
except Exception:
|
|
return None
|
|
gesamt = sum(z) - getattr(z, "guest", 0.0) - getattr(z, "guest_nice", 0.0)
|
|
return gesamt - z.idle - getattr(z, "iowait", 0.0), gesamt
|
|
|
|
|
|
def cpu_prozent(vorher: tuple[float, float] | None, jetzt: tuple[float, float] | None) -> float | None:
|
|
if not vorher or not jetzt or jetzt[1] - vorher[1] <= 0:
|
|
return None
|
|
anteil = (jetzt[0] - vorher[0]) / (jetzt[1] - vorher[1])
|
|
return round(min(100.0, max(0.0, anteil * 100)), 1)
|
|
|
|
|
|
def _netz() -> tuple[int, int] | None:
|
|
"""Empfangene und gesendete Bytes aller Schnittstellen außer lo (Gesamtzähler)."""
|
|
try:
|
|
zaehler = psutil.net_io_counters(pernic=True)
|
|
except Exception:
|
|
return None
|
|
return (sum(z.bytes_recv for name, z in zaehler.items() if name != "lo"),
|
|
sum(z.bytes_sent for name, z in zaehler.items() if name != "lo"))
|
|
|
|
|
|
def _tokens() -> int | None:
|
|
"""Prompt- plus Antwort-Tokens seit Beginn der Zählung (schreibt der Gateway-Prozess)."""
|
|
try:
|
|
stand = token_stats.get_stats()
|
|
return int(stand.get("prompt_tokens") or 0) + int(stand.get("completion_tokens") or 0)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _platte() -> float | None:
|
|
try:
|
|
return round(psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent, 1)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _mittel(werte: list[float]) -> float | None:
|
|
return round(sum(werte) / len(werte), 1) if werte else None
|
|
|
|
|
|
def _runden(wert: float | None, stellen: int | None = None) -> float | int | None:
|
|
return None if wert is None else round(wert, stellen)
|
|
|
|
|
|
# --- Die Minute --------------------------------------------------------------------------------------
|
|
|
|
class Messer:
|
|
"""Was zwischen zwei Minutenpunkten mitläuft: die Stände der Zähler und die Proben."""
|
|
|
|
def __init__(self, uhr: Callable[[], float] = time.monotonic) -> None:
|
|
self._uhr = uhr
|
|
self._seit = uhr()
|
|
self._cpu = _cpu_zeiten()
|
|
self._netz = _netz()
|
|
self._tokens = _tokens()
|
|
self._proben: dict[str, list[float]] = {"gpu": [], "temp_cpu": [], "temp_gpu": []}
|
|
|
|
def probe(self) -> None:
|
|
"""GPU-Last und Temperaturen einmal ablesen (sysfs, Bruchteile einer Millisekunde)."""
|
|
gpu = system._gpu_sysfs() or {}
|
|
temp = system._temps() or {}
|
|
for name, wert in (("gpu", gpu.get("busy_percent")), ("temp_cpu", temp.get("cpu")),
|
|
("temp_gpu", temp.get("gpu"))):
|
|
if messreihen.ist_zahl(wert):
|
|
self._proben[name].append(float(wert))
|
|
|
|
def punkt(self) -> dict[str, float | int | None]:
|
|
"""Der Punkt dieser Minute; danach beginnt die nächste."""
|
|
self.probe()
|
|
jetzt = self._uhr()
|
|
dt = jetzt - self._seit
|
|
cpu, netz, tokens = _cpu_zeiten(), _netz(), _tokens()
|
|
try:
|
|
ram = round(psutil.virtual_memory().percent, 1)
|
|
except Exception:
|
|
ram = None
|
|
tok_s = messreihen.rate(self._tokens, tokens, dt)
|
|
werte = {
|
|
"cpu": cpu_prozent(self._cpu, cpu),
|
|
"ram": ram,
|
|
"gpu": _mittel(self._proben["gpu"]),
|
|
"temp_cpu": _mittel(self._proben["temp_cpu"]),
|
|
"temp_gpu": _mittel(self._proben["temp_gpu"]),
|
|
"platte": _platte(),
|
|
"netz_rx": _runden(messreihen.rate(self._netz[0], netz[0], dt)) if self._netz and netz else None,
|
|
"netz_tx": _runden(messreihen.rate(self._netz[1], netz[1], dt)) if self._netz and netz else None,
|
|
"tokens": None if tok_s is None else round(tok_s * 60, 1),
|
|
}
|
|
self._seit, self._cpu, self._netz, self._tokens = jetzt, cpu, netz, tokens
|
|
for proben in self._proben.values():
|
|
proben.clear()
|
|
return werte
|
|
|
|
|
|
def naechste_messung(jetzt: float) -> float:
|
|
"""Die nächste halbe Minute, mindestens eine Sekunde entfernt."""
|
|
ziel = jetzt - jetzt % 60 + PHASE_S
|
|
return ziel if ziel > jetzt + 1 else ziel + 60
|
|
|
|
|
|
async def messwerte_loop() -> None:
|
|
"""Im mc2-steward (Rolle box): Proben alle PROBE_S Sekunden, ein Punkt je Minute. Fehler kosten einen Punkt, nie
|
|
die Schleife."""
|
|
messer = await asyncio.to_thread(Messer)
|
|
naechste = naechste_messung(time.time())
|
|
log.info("messwerte: aktiv (ein Punkt je Minute nach %s, Proben alle %ss)", messreihen.ordner(), PROBE_S)
|
|
while True:
|
|
await asyncio.sleep(max(0.2, min(PROBE_S, naechste - time.time())))
|
|
try:
|
|
if time.time() >= naechste:
|
|
werte = await asyncio.to_thread(messer.punkt)
|
|
await asyncio.to_thread(messreihen.schreiben, QUELLE, werte)
|
|
naechste = naechste_messung(time.time())
|
|
else:
|
|
await asyncio.to_thread(messer.probe)
|
|
except Exception:
|
|
log.warning("messwerte: Messung fehlgeschlagen", exc_info=True)
|
|
naechste = naechste_messung(time.time())
|
|
|
|
|
|
# --- Lesen (MC2-Prozess) -------------------------------------------------------------------------------
|
|
|
|
ZEITRAEUME = messreihen.ZEITRAEUME
|
|
|
|
|
|
def abfrage(zeitraum: str, jetzt: float | None = None) -> dict:
|
|
"""Für GET /api/messwerte: {"zeitraum", "schritt_s", "reihen": {…}, "aktuell": {…}}. aktuell ist der letzte
|
|
Minutenpunkt, solange er höchstens AKTUELL_MAX_S alt ist, sonst lauter null."""
|
|
jetzt = time.time() if jetzt is None else jetzt
|
|
daten = messreihen.lesen(QUELLE, zeitraum, REIHEN, jetzt)
|
|
letzter = messreihen.letzter_punkt(QUELLE, jetzt, AKTUELL_MAX_S) or {}
|
|
return {**daten, "aktuell": {name: letzter.get(name) for name in REIHEN}}
|