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:
Hitonabi
2026-09-24 22:41:53 +02:00
co-authored by Claude Opus 5.5
parent bf1153cb66
commit 704c21d839
12 changed files with 1398 additions and 3 deletions
+223
View File
@@ -16,6 +16,9 @@ Container des Homelab-Teils braucht keinen Proxmox-Schlüssel.
nie auf einen Speicher der Art pbs, sonst sicherte sich der PBS selbst. Nur eigene
Sicherungen (Notiz „mc2-sicherung“) werden zurückgespielt oder gelöscht. An der
Speicher-Konfiguration ändert der Ausführer nichts.
• Messwerte jede Minute (seit 24.09.2026) in einem eigenen Faden, unabhängig vom Bericht und von
Aufträgen: Host aus /proc und /sys, Gäste aus einem einzigen pvesh-Aufruf. Nur lesend;
Fehler bleiben still.
Nur die Standardbibliothek (Debian-Python des Hosts). Konfiguration: /etc/mc2-ausfuehrer.json
{"server": "http://192.168.178.x:9001", "token": "<gemeinsames Geheimnis>", "node": "pve", "nur_lesen": false}
@@ -25,9 +28,11 @@ der Sicherungen annimmt). ausfuehrer-einrichten.sh schreibt die Datei neu; den S
Aufruf: ausfuehrer.py Dauerbetrieb (systemd)
ausfuehrer.py --bericht Bericht einmal auf die Konsole (nur lesend, zum Prüfen)
ausfuehrer.py --messwerte einen Messpunkt auf die Konsole (nur lesend, CPU über eine Sekunde)
"""
import argparse
import glob
import json
import logging
import os
@@ -35,6 +40,7 @@ import platform
import re
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
@@ -602,6 +608,214 @@ def ausfuehren(auftrag: dict) -> dict:
return {"code": code, "text": text[-20000:]}
# --- Messwerte: jede Minute, eigener Faden (reine Auswertungen ohne Host testbar) ----------------
MESSWERTE_ALLE_S = 60
MESS_PHASE_S = 30 # gemessen wird zur halben Minute (wie auf der KI-Box): genau ein Punkt je Minutenschritt
# CPU-Fühler in hwmon, in dieser Reihenfolge. Auf dem Proxmox-PC (Ryzen 5 5500U) gibt es nur k10temp mit „Tctl“;
# /sys/class/thermal und lm-sensors fehlen dort (nachgesehen am 24.09.2026).
CPU_FUEHLER = ("k10temp", "zenpower", "coretemp", "cpu_thermal")
CPU_BESCHRIFTUNG = ("Tdie", "Tctl", "Package id 0")
_GAST_SCHNITTSTELLE = re.compile(r"(?:veth|tap)(\d+)i\d+")
def _erste_zahl(text: str | None) -> float | None:
try:
return float(str(text or "").split()[0])
except (IndexError, ValueError):
return None
def cpu_zeiten(stat: str) -> tuple[int, int] | None:
"""(beschäftigt, gesamt) in Ticks aus der Zeile „cpu“ von /proc/stat: user nice system idle iowait irq softirq
steal (guest steckt schon in user); beschäftigt ist alles außer idle und iowait."""
for zeile in stat.splitlines():
if zeile.startswith("cpu "):
try:
werte = [int(x) for x in zeile.split()[1:9]]
except ValueError:
return None
werte += [0] * (8 - len(werte))
gesamt = sum(werte)
return gesamt - werte[3] - werte[4], gesamt
return None
def cpu_anteil(vorher: tuple[int, int] | None, jetzt: tuple[int, int] | None) -> float | None:
"""Wie viel die CPU zwischen zwei Ständen von /proc/stat arbeitete (0–1) — das Mittel der Minute. pvesh kann das
nicht: /nodes/<node>/status zeigt in einem frischen pvesh-Prozess immer cpu 0 (24.09.2026 nachgesehen)."""
if not vorher or not jetzt or jetzt[1] <= vorher[1]:
return None
return round(min(1.0, max(0.0, (jetzt[0] - vorher[0]) / (jetzt[1] - vorher[1]))), 4)
def speicher_aus(meminfo: str) -> dict | None:
"""Arbeitsspeicher in Bytes; belegt = MemTotal − MemAvailable, wie Proxmox rechnet."""
werte = {}
for zeile in meminfo.splitlines():
name, _, rest = zeile.partition(":")
teile = rest.split()
if teile and teile[0].isdigit():
werte[name.strip()] = int(teile[0]) * 1024
gesamt, verfuegbar = werte.get("MemTotal"), werte.get("MemAvailable")
if not gesamt or verfuegbar is None:
return None
return {"belegt": gesamt - verfuegbar, "gesamt": gesamt}
def netz_zaehler(netdev: str) -> dict[str, tuple[int, int]]:
"""Schnittstelle → (empfangen, gesendet) in Bytes aus /proc/net/dev."""
zaehler = {}
for zeile in netdev.splitlines():
name, trenner, rest = zeile.partition(":")
teile = rest.split()
if trenner and len(teile) >= 9:
try:
zaehler[name.strip()] = (int(teile[0]), int(teile[8]))
except ValueError:
continue
return zaehler
def host_netz(zaehler: dict[str, tuple[int, int]], physisch: set[str]) -> dict | None:
"""Verkehr des Hosts: die Summe seiner physischen Schnittstellen, so zeigt ihn auch Proxmox für den Knoten. Die
Brücke vmbr0 zählt nur, was der Host selbst empfängt und sendet, nicht den Verkehr der Gäste nach draußen. Ohne
erkennbare physische Schnittstelle doch vmbr0."""
namen = sorted(n for n in zaehler if n in physisch) or [n for n in ("vmbr0",) if n in zaehler]
if not namen:
return None
return {"rx": sum(zaehler[n][0] for n in namen), "tx": sum(zaehler[n][1] for n in namen), "schnittstellen": namen}
def gast_netz(zaehler: dict[str, tuple[int, int]]) -> dict[int, tuple[int, int]]:
"""vmid → (netin, netout) aus den Schnittstellen der Gäste (veth<vmid>iN, tap<vmid>iN). Auf der Host-Seite ist die
Richtung vertauscht: Was der Host dorthin sendet, empfängt der Gast (so rechnet auch pvestatd). Zeitgleich mit
dem Host gelesen — /cluster/resources hat Stände, die bis zu 10 s alt sind, das verzerrte die Minutenrate."""
gaeste: dict[int, tuple[int, int]] = {}
for name, (rx, tx) in zaehler.items():
if treffer := _GAST_SCHNITTSTELLE.fullmatch(name):
netin, netout = gaeste.get(int(treffer.group(1)), (0, 0))
gaeste[int(treffer.group(1))] = (netin + tx, netout + rx)
return gaeste
def cpu_temperatur(fuehler: list[tuple[str, str, float]]) -> float | None:
"""CPU-Temperatur in °C aus den hwmon-Fühlern (Chip, Beschriftung, °C): AMD k10temp/zenpower (Tdie vor Tctl), Intel
coretemp (Package id 0), sonst der erste Wert des Chips. Ohne CPU-Fühler None."""
for chip in CPU_FUEHLER:
werte = [(beschriftung, grad) for name, beschriftung, grad in fuehler if name == chip]
if werte:
bevorzugt = [grad for gesucht in CPU_BESCHRIFTUNG for beschriftung, grad in werte if beschriftung == gesucht]
return round((bevorzugt or [werte[0][1]])[0], 1)
return None
def _gast_messwerte(eintrag: dict, netz: dict[int, tuple[int, int]]) -> dict:
vmid = int(eintrag["vmid"])
netin, netout = netz.get(vmid, (eintrag.get("netin"), eintrag.get("netout")))
werte = {"vmid": vmid, "art": eintrag.get("type"), "name": eintrag.get("name"),
"etiketten": _etiketten(eintrag.get("tags")), "status": eintrag.get("status")}
werte.update({k: eintrag.get(k) for k in ("cpu", "maxcpu", "mem", "maxmem", "disk", "maxdisk", "uptime")})
werte.update(netin=netin, netout=netout)
return werte
def messpunkt(roh: dict, cpu_vorher: tuple[int, int] | None, node: str | None = None) -> dict:
"""Der Minutenpunkt für den Homelab-Teil aus Rohdaten (reine Auswertung):
roh = {"zeit", "stat", "meminfo", "loadavg", "uptime", "netdev" (Dateiinhalte), "rootfs": (gesamt, frei) oder None,
"physisch": {Schnittstellen}, "fuehler": [(Chip, Beschriftung, °C)], "ressourcen": /cluster/resources oder None}
Netz-Zähler sind kumulativ (Bytes); die Raten rechnet der Homelab-Teil."""
node = node or NODE
zaehler = netz_zaehler(roh.get("netdev") or "")
rootfs = roh.get("rootfs")
uptime = _erste_zahl(roh.get("uptime"))
host = {"cpu": cpu_anteil(cpu_vorher, cpu_zeiten(roh.get("stat") or "")),
"speicher": speicher_aus(roh.get("meminfo") or ""),
# belegt wie bei Proxmox (/nodes/<node>/status): gesamt minus frei, samt der für root reservierten Blöcke
"rootfs": {"belegt": rootfs[0] - rootfs[1], "gesamt": rootfs[0]} if rootfs else None,
"load1": _erste_zahl(roh.get("loadavg")),
"uptime": None if uptime is None else int(uptime),
"temp_cpu": cpu_temperatur(roh.get("fuehler") or []),
"netz": host_netz(zaehler, roh.get("physisch") or set())}
gnetz = gast_netz(zaehler)
gaeste = [_gast_messwerte(e, gnetz) for e in roh.get("ressourcen") or []
if isinstance(e, dict) and e.get("type") in ("lxc", "qemu") and e.get("node") == node
and not e.get("template") and e.get("vmid") is not None]
return {"zeit": roh.get("zeit"), "host": host, "gaeste": sorted(gaeste, key=lambda g: g["vmid"])}
def _lesen(pfad: str) -> str:
try:
with open(pfad, encoding="utf-8", errors="replace") as f:
return f.read()
except OSError:
return ""
def _physische_schnittstellen() -> set[str]:
"""Netzschnittstellen mit einem Gerät dahinter (/sys/class/net/<name>/device): keine Brücken, veth oder tap."""
try:
return {n for n in os.listdir("/sys/class/net") if os.path.exists(f"/sys/class/net/{n}/device")}
except OSError:
return set()
def _hwmon_fuehler() -> list[tuple[str, str, float]]:
fuehler = []
for ordner in sorted(glob.glob("/sys/class/hwmon/hwmon*")):
chip = _lesen(f"{ordner}/name").strip()
for eingang in sorted(glob.glob(f"{ordner}/temp*_input")):
try:
grad = int(_lesen(eingang).strip()) / 1000
except ValueError:
continue
fuehler.append((chip, _lesen(eingang[: -len("_input")] + "_label").strip(), grad))
return fuehler
def _messwerte_roh() -> dict:
"""Alles für einen Minutenpunkt: Dateien aus /proc und /sys (billig) und ein einziger pvesh-Aufruf (Gäste)."""
roh = {"zeit": time.time(), "stat": _lesen("/proc/stat"), "netdev": _lesen("/proc/net/dev"),
"meminfo": _lesen("/proc/meminfo"), "loadavg": _lesen("/proc/loadavg"), "uptime": _lesen("/proc/uptime"),
"physisch": _physische_schnittstellen(), "fuehler": _hwmon_fuehler(), "rootfs": None, "ressourcen": None}
try:
stand = os.statvfs("/")
roh["rootfs"] = (stand.f_blocks * stand.f_frsize, stand.f_bfree * stand.f_frsize)
except (AttributeError, OSError):
pass
try:
roh["ressourcen"] = _pvesh("/cluster/resources", "--type", "vm")
except Exception:
pass # dann fehlen in diesem Punkt die Gäste, der Host nicht
return roh
def _bis_zur_messung(jetzt: float) -> float:
"""Sekunden bis zur nächsten halben Minute, mindestens eine."""
warten = (MESS_PHASE_S - jetzt) % MESSWERTE_ALLE_S
return warten if warten >= 1 else warten + MESSWERTE_ALLE_S
def messwerte_schleife() -> None:
"""Jede Minute ein Messpunkt an den Homelab-Teil — im eigenen Faden, damit weder der Bericht noch ein langer Auftrag
(Sicherung, Host-Update: bis zu einer Stunde) ihn aufhält. Fehler bleiben still: derselbe steht nur einmal im
Journal, bis wieder ein Punkt durchgeht."""
vorher = cpu_zeiten(_lesen("/proc/stat"))
zuletzt_fehler = None
while True:
time.sleep(_bis_zur_messung(time.time()))
try:
roh = _messwerte_roh()
punkt = messpunkt(roh, vorher)
vorher = cpu_zeiten(roh["stat"]) or vorher
_anfrage("POST", "/api/homelab/ausfuehrer/messwerte", punkt)
zuletzt_fehler = None
except Exception as exc:
if str(exc) != zuletzt_fehler:
log.warning("Messwerte nicht gesendet: %s", exc)
zuletzt_fehler = str(exc)
# --- Verbindung zum Homelab-Teil -------------------------------------------------------------
def _anfrage(methode: str, pfad: str, daten: object = None) -> object:
@@ -614,6 +828,8 @@ def _anfrage(methode: str, pfad: str, daten: object = None) -> object:
def dauerbetrieb() -> None:
# Messwerte im eigenen Faden: Ein Auftrag darf hier eine Stunde laufen, die Messung jede Minute nicht warten.
threading.Thread(target=messwerte_schleife, name="messwerte", daemon=True).start()
naechster_bericht = 0.0
while True:
try:
@@ -655,11 +871,18 @@ def main() -> int:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
teile = argparse.ArgumentParser(description=__doc__.splitlines()[0])
teile.add_argument("--bericht", action="store_true", help="Bericht einmal ausgeben (nur lesend)")
teile.add_argument("--messwerte", action="store_true", help="einen Messpunkt ausgeben (nur lesend)")
args = teile.parse_args()
if args.bericht:
json.dump(bericht(), sys.stdout, ensure_ascii=False, indent=2)
print()
return 0
if args.messwerte:
vorher = cpu_zeiten(_lesen("/proc/stat"))
time.sleep(1) # die CPU über eine Sekunde statt über die Minute
json.dump(messpunkt(_messwerte_roh(), vorher), sys.stdout, ensure_ascii=False, indent=2)
print()
return 0
if not SERVER or not TOKEN:
log.error("Konfiguration %s fehlt oder ist unvollständig (server, token).", KONFIG)
return 1