monitoring: Messwerte mit Verlauf fuer KI-Box und Homelab
- 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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bf1153cb66
commit
704c21d839
@@ -30,7 +30,7 @@ from config import MODELS_DIR
|
||||
from services import llamaswap
|
||||
|
||||
AKTIVE_ROLLEN = {"hermes", "fast", "coder", "heavy", "vision", "coder-bild", "embed", "reranker"}
|
||||
SYSTEM_ORDNER = {"mc2-backups", "radar", "pruefstand-cache", "lost+found"}
|
||||
SYSTEM_ORDNER = {"mc2-backups", "mc2-messwerte", "radar", "pruefstand-cache", "lost+found"}
|
||||
AUF_DER_BOX = os.name == "posix" # auf dem Windows-PC (Entwicklung) wird nie gelöscht
|
||||
|
||||
# Was der Nutzer über bekannte Ordner wissen sollte, bevor er löscht (Stand 24.09.2026).
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Messwerte des Homelabs mit Verlauf (Monitoring, seit 24.09.2026).
|
||||
|
||||
Der Ausführer auf dem Proxmox-Host schickt jede Minute POST /api/homelab/ausfuehrer/messwerte (eigener Faden, unabhängig
|
||||
vom 10-min-Bericht und von Aufträgen):
|
||||
{"zeit": Unix-Sekunden der Messung,
|
||||
"host": {"cpu": 0–1 (Mittel der Minute), "speicher": {"belegt", "gesamt"}, "rootfs": {"belegt", "gesamt"}, "load1",
|
||||
"uptime", "temp_cpu" (°C oder null), "netz": {"rx", "tx"} (kumulativ, Bytes)},
|
||||
"gaeste": [{"vmid", "art" (lxc|qemu), "name", "etiketten", "status", "cpu" (0–1 der eigenen Kerne), "maxcpu", "mem",
|
||||
"maxmem", "disk", "maxdisk", "netin", "netout" (kumulativ, Bytes), "uptime"}]}
|
||||
Hier wird daraus je Gerät ein Punkt über kern/messreihen.py (Quellen „pve“, „ct-<vmid>“, „vm-<vmid>“ wie im Inventar):
|
||||
CPU, RAM und Platte in %, Netz in Bytes/s aus der Differenz zum vorigen Zählerstand. Ist ein Zähler kleiner geworden
|
||||
oder die Laufzeit (Neustart des Gasts oder Hosts), fehlt der vorige Stand (Neustart dieses Teils) oder liegt er mehr als
|
||||
fünf Minuten zurück, bleibt die Rate dieser Minute null. Gestoppte Gäste haben keine Messwerte (null), keine Nullen.
|
||||
Der eigene Container (Etikett „mc2“) wird wie im Inventar nicht erfasst. Die Platte von VMs sieht Proxmox nicht (null).
|
||||
|
||||
GET /api/homelab/messwerte liefert je Gerät die Reihen (1h, 24h, 7d) und den letzten Punkt; pruefe_host() meldet dem
|
||||
Wächter, wenn der Host zehn Minuten lang über 95 % RAM oder 90 °C liegt.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
from kern import messreihen
|
||||
|
||||
from services.homelab import apps, inventar, kanal
|
||||
|
||||
HOST = "pve"
|
||||
_GAST = re.compile(r"(ct|vm)-\d+")
|
||||
ZEITRAEUME = messreihen.ZEITRAEUME
|
||||
HOST_REIHEN = ("cpu", "ram", "platte", "netz_rx", "netz_tx", "temp_cpu")
|
||||
GAST_REIHEN = ("cpu", "ram", "platte", "netz_rx", "netz_tx")
|
||||
AKTUELL = ("cpu", "ram", "ram_used", "ram_total", "platte", "netz_rx", "netz_tx", "temp_cpu", "load1", "uptime_s")
|
||||
AKTUELL_MAX_S = 180 # älter ist der letzte Punkt nicht „aktuell“ (der Ausführer schickt gerade nichts)
|
||||
ZEIT_TOLERANZ_S = 120 # die Messzeit des Ausführers gilt, wenn sie so nah an der eigenen Uhr liegt
|
||||
|
||||
# Wächter: der Host zehn Minuten lang über diesen Schwellen → gelber Hinweis.
|
||||
WACHE_S = 600
|
||||
WACHE_MIN_PUNKTE = 8 # so viele Minutenpunkte müssen in den zehn Minuten liegen (ein, zwei dürfen fehlen)
|
||||
RAM_GELB = float(os.environ.get("MC_WAECHTER_HOST_RAM", "95"))
|
||||
TEMP_GELB = float(os.environ.get("MC_WAECHTER_HOST_TEMP", "90"))
|
||||
|
||||
_lock = threading.Lock()
|
||||
# Vorige Zählerstände je Gerät (für die Raten) und die Geräte der letzten Meldung (für Namen und die Geräteliste).
|
||||
# Nur im Speicher: Nach einem Neustart dieses Teils fehlt für eine Minute die Netz-Rate, sonst nichts.
|
||||
_zustand: dict = {"zaehler": {}, "geraete": {}}
|
||||
|
||||
|
||||
def _zahl(wert: object) -> float | None:
|
||||
return float(wert) if messreihen.ist_zahl(wert) else None
|
||||
|
||||
|
||||
def _ganz(wert: object) -> int | None:
|
||||
return int(wert) if messreihen.ist_zahl(wert) else None
|
||||
|
||||
|
||||
def _prozent(anteil: object) -> float | None:
|
||||
"""0–1 → %, eine Nachkommastelle, auf 0–100 begrenzt."""
|
||||
return round(min(100.0, max(0.0, float(anteil) * 100)), 1) if messreihen.ist_zahl(anteil) else None
|
||||
|
||||
|
||||
def _anteil(teil: object, ganz: object) -> float | None:
|
||||
if not (messreihen.ist_zahl(teil) and messreihen.ist_zahl(ganz)) or float(ganz) <= 0:
|
||||
return None
|
||||
return _prozent(float(teil) / float(ganz))
|
||||
|
||||
|
||||
def _dict(wert: object) -> dict:
|
||||
return wert if isinstance(wert, dict) else {}
|
||||
|
||||
|
||||
def gast_id(gast: dict) -> str | None:
|
||||
"""ct-<vmid> bzw. vm-<vmid> wie im Inventar; None bei allem, was kein Gast ist."""
|
||||
vmid, art = gast.get("vmid"), gast.get("art")
|
||||
if not isinstance(vmid, int) or isinstance(vmid, bool) or not 100 <= vmid <= 999_999_999:
|
||||
return None
|
||||
return {"lxc": f"ct-{vmid}", "qemu": f"vm-{vmid}"}.get(art)
|
||||
|
||||
|
||||
def _art(geraet_id: str) -> str:
|
||||
return "proxmox-host" if geraet_id == HOST else "container" if geraet_id.startswith("ct-") else "vm"
|
||||
|
||||
|
||||
# --- Zähler → Bytes/s ------------------------------------------------------------------------------
|
||||
|
||||
def raten(vorher: dict | None, jetzt: dict) -> tuple[int | None, int | None]:
|
||||
"""Empfangen und gesendet in Bytes/s zwischen zwei Zählerständen {"t", "rx", "tx", "uptime", "laeuft"}. None, wenn
|
||||
es keinen vorigen Stand gibt, einer der beiden nicht lief, die Laufzeit kleiner wurde (neu gestartet: die Zähler
|
||||
begannen von vorn) oder messreihen.rate() den Zuwachs verwirft (Zähler kleiner, Lücke, Sprung)."""
|
||||
if not vorher or not vorher.get("laeuft") or not jetzt.get("laeuft"):
|
||||
return None, None
|
||||
alt_lauf, neu_lauf = vorher.get("uptime"), jetzt.get("uptime")
|
||||
if messreihen.ist_zahl(alt_lauf) and messreihen.ist_zahl(neu_lauf) and neu_lauf < alt_lauf:
|
||||
return None, None
|
||||
dt = jetzt["t"] - vorher["t"]
|
||||
rx, tx = messreihen.rate(vorher.get("rx"), jetzt.get("rx"), dt), messreihen.rate(vorher.get("tx"), jetzt.get("tx"), dt)
|
||||
return (None if rx is None else round(rx)), (None if tx is None else round(tx))
|
||||
|
||||
|
||||
def _zaehlen(geraet_id: str, t: float, rx: object, tx: object, uptime: object, laeuft: bool) -> tuple:
|
||||
"""Den neuen Zählerstand merken und die Raten gegen den vorigen liefern (unter _lock aufrufen)."""
|
||||
stand = {"t": t, "rx": rx, "tx": tx, "uptime": uptime, "laeuft": laeuft}
|
||||
ergebnis = raten(_zustand["zaehler"].get(geraet_id), stand)
|
||||
_zustand["zaehler"][geraet_id] = stand
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _host_punkt(host: dict, t: float) -> dict:
|
||||
speicher, rootfs, netz = _dict(host.get("speicher")), _dict(host.get("rootfs")), _dict(host.get("netz"))
|
||||
rx, tx = _zaehlen(HOST, t, netz.get("rx"), netz.get("tx"), host.get("uptime"), True)
|
||||
temp, last = _zahl(host.get("temp_cpu")), _zahl(host.get("load1"))
|
||||
return {"cpu": _prozent(host.get("cpu")),
|
||||
"ram": _anteil(speicher.get("belegt"), speicher.get("gesamt")),
|
||||
"ram_used": _ganz(speicher.get("belegt")), "ram_total": _ganz(speicher.get("gesamt")),
|
||||
"platte": _anteil(rootfs.get("belegt"), rootfs.get("gesamt")),
|
||||
"netz_rx": rx, "netz_tx": tx,
|
||||
"temp_cpu": None if temp is None else round(temp, 1),
|
||||
"load1": None if last is None else round(last, 2),
|
||||
"uptime_s": _ganz(host.get("uptime"))}
|
||||
|
||||
|
||||
def _gast_punkt(geraet_id: str, gast: dict, t: float) -> dict:
|
||||
laeuft = gast.get("status") == "running"
|
||||
rx, tx = _zaehlen(geraet_id, t, gast.get("netin"), gast.get("netout"), gast.get("uptime"), laeuft)
|
||||
if not laeuft:
|
||||
return {"cpu": None, "ram": None, "ram_used": None, "ram_total": _ganz(gast.get("maxmem")), "platte": None,
|
||||
"netz_rx": None, "netz_tx": None, "uptime_s": _ganz(gast.get("uptime"))}
|
||||
# Proxmox rechnet die CPU eines Gasts schon auf seine eigenen Kerne (1,0 = alle voll). Die Belegung der Platte
|
||||
# kennt es nur bei Containern; bei VMs steht dort immer 0.
|
||||
disk = gast.get("disk")
|
||||
platte = _anteil(disk, gast.get("maxdisk")) if gast.get("art") == "lxc" and messreihen.ist_zahl(disk) and disk else None
|
||||
return {"cpu": _prozent(gast.get("cpu")),
|
||||
"ram": _anteil(gast.get("mem"), gast.get("maxmem")),
|
||||
"ram_used": _ganz(gast.get("mem")), "ram_total": _ganz(gast.get("maxmem")),
|
||||
"platte": platte, "netz_rx": rx, "netz_tx": tx, "uptime_s": _ganz(gast.get("uptime"))}
|
||||
|
||||
|
||||
def annehmen(daten: dict, jetzt: float | None = None) -> int:
|
||||
"""Einen Minutenpunkt des Ausführers speichern. Rückgabe: für wie viele Geräte. Kaputte Einträge fallen still weg."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
zeit = daten.get("zeit")
|
||||
t = float(zeit) if messreihen.ist_zahl(zeit) and abs(float(zeit) - jetzt) <= ZEIT_TOLERANZ_S else jetzt
|
||||
punkte: list[tuple[str, dict]] = []
|
||||
with _lock:
|
||||
if isinstance(daten.get("host"), dict):
|
||||
punkte.append((HOST, _host_punkt(daten["host"], t)))
|
||||
gaeste = daten.get("gaeste") if isinstance(daten.get("gaeste"), list) else []
|
||||
geraete = {}
|
||||
for gast in gaeste:
|
||||
if not isinstance(gast, dict) or inventar.EIGENES_ETIKETT in (gast.get("etiketten") or []):
|
||||
continue
|
||||
if not (gid := gast_id(gast)):
|
||||
continue
|
||||
punkte.append((gid, _gast_punkt(gid, gast, t)))
|
||||
geraete[gid] = str(gast.get("name") or gid)
|
||||
if gaeste:
|
||||
_zustand["geraete"] = geraete
|
||||
for quelle, werte in punkte:
|
||||
messreihen.schreiben(quelle, werte, t)
|
||||
return len(punkte)
|
||||
|
||||
|
||||
# --- Lesen ------------------------------------------------------------------------------------------
|
||||
|
||||
def _geraete(von: float, bis: float) -> list[dict]:
|
||||
"""Die Geräte wie im Inventar: der Host und die Gäste aus dem letzten Bericht (Namen aus apps.py), dazu Gäste, die
|
||||
erst die Messwerte kennen. Ohne beides: alle Quellen mit Dateien im Zeitraum."""
|
||||
eingang = kanal.bericht()
|
||||
geraete: dict[str, dict] = {}
|
||||
eigene: set[str] = set()
|
||||
if eingang:
|
||||
geraete[HOST] = {"id": HOST, "name": "Proxmox-Host", "art": "proxmox-host"}
|
||||
for gast in (eingang or {}).get("bericht", {}).get("gaeste") or []:
|
||||
if not (gid := gast_id(gast)):
|
||||
continue
|
||||
if inventar.EIGENES_ETIKETT in (gast.get("etiketten") or []):
|
||||
eigene.add(gid)
|
||||
continue
|
||||
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
|
||||
geraete[gid] = {"id": gid, "name": app.name if app else str(gast.get("name") or gid), "art": _art(gid)}
|
||||
with _lock:
|
||||
gemeldet = dict(_zustand["geraete"])
|
||||
host_gemeldet = HOST in _zustand["zaehler"]
|
||||
if host_gemeldet:
|
||||
geraete.setdefault(HOST, {"id": HOST, "name": "Proxmox-Host", "art": "proxmox-host"})
|
||||
for gid, name in gemeldet.items():
|
||||
if gid not in geraete and gid not in eigene:
|
||||
app = apps.app_fuer(None, name)
|
||||
geraete[gid] = {"id": gid, "name": app.name if app else name, "art": _art(gid)}
|
||||
if not geraete:
|
||||
for quelle in messreihen.quellen(von, bis):
|
||||
if quelle == HOST or _GAST.fullmatch(quelle):
|
||||
geraete[quelle] = {"id": quelle, "name": "Proxmox-Host" if quelle == HOST else quelle,
|
||||
"art": _art(quelle)}
|
||||
# Reihenfolge wie im Inventar: der Host, dann die Gäste nach Nummer.
|
||||
return sorted(geraete.values(), key=lambda g: (g["id"] != HOST, int(g["id"].split("-")[1]) if g["id"] != HOST else 0))
|
||||
|
||||
|
||||
def abfrage(zeitraum: str, jetzt: float | None = None) -> dict:
|
||||
"""Für GET /api/homelab/messwerte: {"zeitraum", "schritt_s", "geraete": [{"id", "name", "art", "reihen",
|
||||
"aktuell"}]}. temp_cpu gibt es als Reihe nur beim Host; in „aktuell“ stehen temp_cpu und load1 bei Gästen auf null."""
|
||||
dauer, schritt = messreihen.zeitraum_pruefen(zeitraum)
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
geraete = []
|
||||
for geraet in _geraete(jetzt - dauer, jetzt):
|
||||
host = geraet["id"] == HOST
|
||||
daten = messreihen.lesen(geraet["id"], zeitraum, HOST_REIHEN if host else GAST_REIHEN, jetzt)
|
||||
letzter = messreihen.letzter_punkt(geraet["id"], jetzt, AKTUELL_MAX_S) or {}
|
||||
aktuell = {name: letzter.get(name) for name in AKTUELL}
|
||||
if not host:
|
||||
aktuell["temp_cpu"] = aktuell["load1"] = None
|
||||
geraete.append({**geraet, "reihen": daten["reihen"], "aktuell": aktuell})
|
||||
return {"zeitraum": zeitraum, "schritt_s": schritt, "geraete": geraete}
|
||||
|
||||
|
||||
# --- Wächter (Rolle homelab, registriert in services/waechter.py) -------------------------------------------
|
||||
|
||||
def _gb(anzahl: object) -> str:
|
||||
return f"{float(anzahl) / 1e9:.1f}".replace(".", ",") if messreihen.ist_zahl(anzahl) else "?"
|
||||
|
||||
|
||||
def pruefe_host(jetzt: float | None = None) -> list:
|
||||
"""Gelb, wenn der Proxmox-Host zehn Minuten lang über RAM_GELB % Arbeitsspeicher oder TEMP_GELB °C CPU-Temperatur
|
||||
liegt. Ohne genug Messungen (Ausführer schweigt, gerade erst gestartet) kein Befund — das Schweigen meldet
|
||||
pruefe_ausfuehrer()."""
|
||||
from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
punkte = messreihen.punkte(HOST, jetzt - WACHE_S, jetzt + 1)
|
||||
befunde = []
|
||||
ram = [p["ram"] for p in punkte if messreihen.ist_zahl(p.get("ram"))]
|
||||
if len(ram) >= WACHE_MIN_PUNKTE and min(ram) > RAM_GELB:
|
||||
letzter = next(p for p in reversed(punkte) if messreihen.ist_zahl(p.get("ram")))
|
||||
befunde.append(Befund(
|
||||
id="host-ram", stufe="gelb", titel=f"Proxmox-Host: Arbeitsspeicher seit 10 Minuten über {RAM_GELB:.0f} %",
|
||||
text=(f"Zuletzt {ram[-1]:.0f} % belegt ({_gb(letzter.get('ram_used'))} von {_gb(letzter.get('ram_total'))} "
|
||||
"GB). Wird es noch enger, beendet der Kernel Prozesse, auch in den Gästen. Die Messwerte zeigen, "
|
||||
"welcher Gast wie viel braucht; einem davon weniger Speicher geben oder ihn stoppen."),
|
||||
quelle=HOST, sofort=True))
|
||||
temp = [p["temp_cpu"] for p in punkte if messreihen.ist_zahl(p.get("temp_cpu"))]
|
||||
if len(temp) >= WACHE_MIN_PUNKTE and min(temp) > TEMP_GELB:
|
||||
befunde.append(Befund(
|
||||
id="host-temp", stufe="gelb", titel=f"Proxmox-Host: CPU seit 10 Minuten über {TEMP_GELB:.0f} °C",
|
||||
text=(f"Zuletzt {temp[-1]:.0f} °C. So heiß drosselt sich die CPU, und auf Dauer leidet die Hardware. "
|
||||
"Lüfter und Luftwege prüfen (Staub) und in den Messwerten nachsehen, welcher Gast die Last macht."),
|
||||
quelle=HOST, sofort=True))
|
||||
return befunde
|
||||
@@ -0,0 +1,184 @@
|
||||
"""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}}
|
||||
@@ -597,6 +597,8 @@ PRUEFUNGEN = {
|
||||
"homelab": (pruefe_platte, pruefe_partner, pruefe_ausfuehrer, pruefe_gaeste, pruefe_gast_platten),
|
||||
}[ROLLE]
|
||||
PRUEFUNGEN += (__import__("services.homelab.pflege").homelab.pflege.pruefe_paketlisten,) if ROLLE == "homelab" else ()
|
||||
# Messwerte des Proxmox-Hosts (seit 24.09.2026): zehn Minuten über 95 % RAM oder 90 °C = gelb.
|
||||
PRUEFUNGEN += (__import__("services.homelab.messwerte").homelab.messwerte.pruefe_host,) if ROLLE == "homelab" else ()
|
||||
BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"),
|
||||
"homelab": ("[Homelab-Problem]", "[Homelab wieder ok]")}[ROLLE]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user