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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user