Merge branch 'worktree-agent-a342596bba36026ed' into wartung/oberflaeche-v4

This commit is contained in:
Hitonabi
2026-09-25 10:02:47 +02:00
14 changed files with 1509 additions and 12 deletions
+2
View File
@@ -128,6 +128,7 @@ def _box_router(app: FastAPI) -> None:
gateway_proxy,
hermes_ui,
maintenance,
messwerte,
models,
radar,
routing,
@@ -140,6 +141,7 @@ def _box_router(app: FastAPI) -> None:
app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan
app.include_router(einstellungen.router) # Update-Zeitfenster, Radar-Schalter, Telegram-Test
app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen
app.include_router(messwerte.router) # Messwerte mit Verlauf (1h, 24h, 7d; schreibt der Steward)
app.include_router(models.router)
app.include_router(routing.router)
app.include_router(system.router)
+301
View File
@@ -0,0 +1,301 @@
"""Messreihen: Minutenwerte mit Verlauf für beide Bereiche (Monitoring, seit 24.09.2026).
Ablage: je Quelle und Tag eine Datei, darin eine JSON-Zeile je Minute:
<Datenordner>/mc2-messwerte/<quelle>-JJJJ-MM-TT.jsonl (Tag nach Berliner Zeit, kern/zeit.py)
{"t":1790000000,"cpu":12.5,"ram":41.2,…} (t = Unix-Sekunden der Messung, null = keine Messung)
Quellen: „box“ (die KI-Box; schreibt ihr Steward) sowie „pve“, „ct-<vmid>“ und „vm-<vmid>“ (das Homelab; schreibt der
Homelab-Teil, was der Ausführer jede Minute schickt). Dateien, deren Tag mehr als AUFBEWAHREN_TAGE zurückliegt, löscht
das erste Schreiben eines neuen Tages.
Schreiben: eine Zeile anhängen, unter einer Thread-Sperre und (Linux) flock auf der Datei. Fehlt am Dateiende der
Zeilenumbruch (Absturz mitten im Schreiben), beginnt die neue Zeile auf einer eigenen statt an die kaputte anzuwachsen.
Lesen (lesen()): 1h in 60-s-Schritten, 24h als 5-min-Mittel, 7d als 30-min-Mittel. Die Schritte liegen auf vollen
Vielfachen ihrer Länge, der letzte ist der laufende. Ein Schritt ohne Messung bleibt null — Lücken werden nicht
aufgefüllt. Kaputte Zeilen werden übersprungen. Die Summen je Tagesdatei werden für 5- und 30-min-Schritte gemerkt,
solange sich die Datei nicht ändert (vergangene Tage liest so nur die erste Anfrage).
"""
import json
import logging
import math
import os
import re
import threading
import time
from collections.abc import Iterable, Iterator
from datetime import datetime, timedelta
from pathlib import Path
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
try:
import fcntl # Linux: Sperre über Prozessgrenzen
except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
fcntl = None
log = logging.getLogger(__name__)
ORDNER_NAME = "mc2-messwerte"
AUFBEWAHREN_TAGE = 8
# Zeitraum → (Länge, Schritt) in Sekunden.
ZEITRAEUME: dict[str, tuple[int, int]] = {"1h": (3600, 60), "24h": (24 * 3600, 300), "7d": (7 * 24 * 3600, 1800)}
MERKEN_AB_S = 300 # Summen je Datei merken; bei 60-s-Schritten (1440 je Tag) lohnt es den Speicher nicht
MERKEN_MAX = 512
LUECKE_MAX_S = 300 # Zählerstände, die weiter auseinanderliegen, ergeben keine Rate mehr
RATE_MAX = 5e9 # mehr als 5 GB/s ist kein Verkehr, sondern ein Zählersprung
_QUELLE = re.compile(r"[a-z0-9][a-z0-9-]{0,62}")
_DATEI = re.compile(r"(?P<quelle>[a-z0-9][a-z0-9-]*)-(?P<tag>\d{4}-\d{2}-\d{2})\.jsonl")
_lock = threading.Lock()
_aufgeraeumt: dict[str, str] = {} # Ordner → Tag des letzten Aufräumens (je Prozess)
_merk_lock = threading.Lock()
_merk: dict[tuple[str, int], tuple[tuple[int, int], dict[int, dict[str, list[float]]]]] = {}
def ordner() -> Path:
return einstellungen().daten_dir / ORDNER_NAME
def ist_zahl(wert: object) -> bool:
"""Eine endliche Zahl. bool zählt nicht, NaN und unendlich auch nicht."""
return isinstance(wert, (int, float)) and not isinstance(wert, bool) and math.isfinite(wert)
def zeitraum_pruefen(zeitraum: str) -> tuple[int, int]:
"""(Länge, Schritt) in Sekunden; ValueError bei einem unbekannten Zeitraum."""
if zeitraum not in ZEITRAEUME:
raise ValueError(f"Den Zeitraum „{zeitraum}“ gibt es nicht; möglich sind {', '.join(ZEITRAEUME)}.")
return ZEITRAEUME[zeitraum]
def _datum(t: float):
return datetime.fromtimestamp(t, LOCAL_TZ).date()
def _tage(von: float, bis: float) -> list[str]:
"""Alle Tage (Berliner Zeit), deren Dateien Messungen aus [von, bis) enthalten können."""
tag, letzter = _datum(von), _datum(max(von, bis - 1))
tage = []
while tag <= letzter:
tage.append(tag.isoformat())
tag += timedelta(days=1)
return tage
def _datei(quelle: str, tag: str) -> Path:
if not _QUELLE.fullmatch(quelle):
raise ValueError(f"Ungültiger Name einer Messquelle: {quelle!r}")
return ordner() / f"{quelle}-{tag}.jsonl"
# --- Schreiben -----------------------------------------------------------------------------------
def _wert(wert: object) -> int | float | None:
if not ist_zahl(wert):
return None
return wert if isinstance(wert, int) else round(float(wert), 3)
def schreiben(quelle: str, werte: dict[str, object], t: float | None = None) -> None:
"""Einen Messpunkt anhängen. werte: Name → Zahl oder None (nicht gemessen)."""
t = time.time() if t is None else float(t)
punkt: dict[str, object] = {"t": int(t)}
punkt.update({name: _wert(wert) for name, wert in werte.items() if name != "t"})
zeile = (json.dumps(punkt, separators=(",", ":")) + "\n").encode()
pfad = _datei(quelle, _datum(t).isoformat())
with _lock:
pfad.parent.mkdir(parents=True, exist_ok=True)
with open(pfad, "a+b") as f:
if fcntl is not None:
fcntl.flock(f, fcntl.LOCK_EX)
try:
f.seek(0, os.SEEK_END)
if f.tell() > 0:
f.seek(-1, os.SEEK_END)
if f.read(1) != b"\n":
zeile = b"\n" + zeile # die letzte Zeile brach ab: nicht an sie anhängen
f.write(zeile)
f.flush()
finally:
if fcntl is not None:
fcntl.flock(f, fcntl.LOCK_UN)
_einmal_am_tag_aufraeumen(t)
def _einmal_am_tag_aufraeumen(t: float) -> None:
schluessel, tag = str(ordner()), _datum(t).isoformat()
if _aufgeraeumt.get(schluessel) == tag:
return
_aufgeraeumt[schluessel] = tag
try:
aufraeumen(t)
except OSError:
log.warning("messreihen: Aufräumen in %s ging nicht", schluessel, exc_info=True)
def aufraeumen(jetzt: float | None = None) -> int:
"""Tagesdateien löschen, deren Tag mehr als AUFBEWAHREN_TAGE zurückliegt. Andere Dateien bleiben. Rückgabe: wie
viele gelöscht wurden."""
grenze = (_datum(time.time() if jetzt is None else jetzt) - timedelta(days=AUFBEWAHREN_TAGE)).isoformat()
try:
namen = os.listdir(ordner())
except FileNotFoundError:
return 0
geloescht = 0
for name in namen:
treffer = _DATEI.fullmatch(name)
if treffer and treffer["tag"] < grenze:
try:
(ordner() / name).unlink()
geloescht += 1
except FileNotFoundError:
pass # ein anderer Prozess war schneller
return geloescht
# --- Lesen ---------------------------------------------------------------------------------------
def _punkt(zeile: bytes) -> dict | None:
"""Eine Zeile als Messpunkt; None bei allem, was keiner ist (kaputt, leer, ohne Zeit)."""
zeile = zeile.strip()
if not zeile:
return None
try:
punkt = json.loads(zeile)
except ValueError: # auch UnicodeDecodeError
return None
return punkt if isinstance(punkt, dict) and ist_zahl(punkt.get("t")) else None
def _punkte_der_datei(pfad: Path) -> Iterator[dict]:
try:
roh = pfad.read_bytes()
except OSError:
return
for zeile in roh.split(b"\n"):
if (punkt := _punkt(zeile)) is not None:
yield punkt
def _eimer(pfad: Path, schritt: int, ab: float) -> dict[int, dict[str, list[float]]]:
"""Summe und Anzahl je Schritt und Wert für eine Tagesdatei. Gemerkt (ab 5-min-Schritten) wird die ganze Datei;
sonst zählen erst Messungen ab `ab`. Das Ergebnis nicht verändern: es kann gemerkt sein."""
try:
stand = pfad.stat()
except OSError:
return {}
merken = schritt >= MERKEN_AB_S
schluessel, kennung = (str(pfad), schritt), (stand.st_mtime_ns, stand.st_size)
if merken:
with _merk_lock:
gemerkt = _merk.get(schluessel)
if gemerkt and gemerkt[0] == kennung:
return gemerkt[1]
eimer: dict[int, dict[str, list[float]]] = {}
for punkt in _punkte_der_datei(pfad):
if not merken and punkt["t"] < ab:
continue
ziel = eimer.setdefault(int(punkt["t"]) // schritt * schritt, {})
for name, wert in punkt.items():
if name != "t" and ist_zahl(wert):
summe = ziel.setdefault(name, [0.0, 0])
summe[0] += wert
summe[1] += 1
if merken:
with _merk_lock:
if len(_merk) >= MERKEN_MAX:
_merk.clear()
_merk[schluessel] = (kennung, eimer)
return eimer
def _mittel(summe: list[float] | None) -> int | float | None:
"""Mittel eines Schritts: ab 100 ganzzahlig, darunter eine Nachkommastelle; ohne Messung None."""
if not summe or not summe[1]:
return None
wert = summe[0] / summe[1]
return round(wert) if abs(wert) >= 100 else round(wert, 1)
def lesen(quelle: str, zeitraum: str, namen: Iterable[str], jetzt: float | None = None) -> dict:
"""Die Reihen einer Quelle: {"zeitraum", "schritt_s", "reihen": {name: [[t, wert], …]}}. t = Beginn des Schritts
(Unix-Sekunden), wert = Mittel der Messungen darin oder None. Jede Reihe hat Länge/Schritt Einträge (60, 288, 336)."""
dauer, schritt = zeitraum_pruefen(zeitraum)
jetzt = time.time() if jetzt is None else jetzt
ende = (int(jetzt) // schritt + 1) * schritt # Ende des laufenden Schritts
beginn = ende - dauer
summen: dict[int, dict[str, list[float]]] = {}
for tag in _tage(beginn, ende):
for t0, werte in _eimer(_datei(quelle, tag), schritt, beginn).items():
if not beginn <= t0 < ende:
continue
ziel = summen.setdefault(t0, {})
for name, (summe, anzahl) in werte.items():
gesamt = ziel.setdefault(name, [0.0, 0])
gesamt[0] += summe
gesamt[1] += anzahl
raster = range(beginn, ende, schritt)
return {"zeitraum": zeitraum, "schritt_s": schritt,
"reihen": {name: [[t0, _mittel(summen.get(t0, {}).get(name))] for t0 in raster] for name in namen}}
def _ende_der_datei(pfad: Path, groesse: int = 16384) -> list[bytes]:
"""Die letzten Zeilen einer Datei, ohne sie ganz zu lesen."""
try:
with open(pfad, "rb") as f:
f.seek(0, os.SEEK_END)
laenge = f.tell()
f.seek(max(0, laenge - groesse))
roh = f.read()
except OSError:
return []
zeilen = roh.split(b"\n")
return zeilen[1:] if laenge > groesse else zeilen # die erste Zeile ist dann angeschnitten
def letzter_punkt(quelle: str, jetzt: float | None = None, max_alter_s: float | None = None) -> dict | None:
"""Die jüngste Messung einer Quelle (heute, sonst gestern). None, wenn es keine gibt oder sie älter ist als
max_alter_s — ein alter Wert ist kein aktueller."""
jetzt = time.time() if jetzt is None else jetzt
heute = _datum(jetzt)
for tag in (heute, heute - timedelta(days=1)):
for zeile in reversed(_ende_der_datei(_datei(quelle, tag.isoformat()))):
if (punkt := _punkt(zeile)) is not None:
if max_alter_s is not None and jetzt - punkt["t"] > max_alter_s:
return None
return punkt
return None
def punkte(quelle: str, von: float, bis: float) -> list[dict]:
"""Alle Messungen einer Quelle mit von ≤ t < bis, nach Zeit geordnet (für Prüfungen des Wächters)."""
gefunden = [p for tag in _tage(von, bis) for p in _punkte_der_datei(_datei(quelle, tag)) if von <= p["t"] < bis]
return sorted(gefunden, key=lambda p: p["t"])
def quellen(von: float, bis: float) -> list[str]:
"""Die Quellen, von denen es für [von, bis) eine Tagesdatei gibt."""
tage = set(_tage(von, bis))
try:
namen = os.listdir(ordner())
except OSError:
return []
return sorted({t["quelle"] for name in namen if (t := _DATEI.fullmatch(name)) and t["tag"] in tage})
# --- Zähler → Raten --------------------------------------------------------------------------------
def rate(alt: object, neu: object, dt: float) -> float | None:
"""Zuwachs je Sekunde zwischen zwei Ständen eines kumulativen Zählers (Bytes, Tokens). None, wenn ein Stand fehlt,
die Stände mehr als LUECKE_MAX_S auseinanderliegen, der Zähler kleiner wurde (Neustart, Überlauf) oder der Sprung
unmöglich groß ist — eine erfundene Rate wäre schlimmer als eine Lücke."""
if not (ist_zahl(alt) and ist_zahl(neu)) or not 0 < dt <= LUECKE_MAX_S:
return None
zuwachs = float(neu) - float(alt)
if zuwachs < 0:
return None
wert = zuwachs / dt
return None if wert > RATE_MAX else wert
+19
View File
@@ -155,3 +155,22 @@ async def _strom(request: Request, takt_s: float = 2.0, lebenszeichen_takte: int
async def stream(request: Request) -> StreamingResponse:
return StreamingResponse(_strom(request), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
# --- Messwerte mit Verlauf (Monitoring, seit 24.09.2026) ------------------------------------------------------
# POST /api/homelab/ausfuehrer/messwerte jede Minute vom Ausführer (Kopfzeile X-MC2-Ausfuehrer)
# GET /api/homelab/messwerte?zeitraum=1h|24h|7d Proxmox-Host und Gäste: Reihen und letzter Punkt
# Die Logik liegt in services/homelab/messwerte.py, die Ablage in kern/messreihen.py.
@router.post("/ausfuehrer/messwerte", dependencies=[Depends(_nur_ausfuehrer)])
def messwerte_annehmen(daten: dict) -> dict:
from services.homelab import messwerte as homelab_messwerte
return {"ok": True, "geraete": homelab_messwerte.annehmen(daten)}
@router.get("/messwerte")
def messwerte_lesen(zeitraum: str = "1h") -> dict:
from services.homelab import messwerte as homelab_messwerte
if zeitraum not in homelab_messwerte.ZEITRAEUME:
raise HTTPException(status_code=400, detail="Den Zeitraum gibt es nicht; möglich sind 1h, 24h und 7d.")
return homelab_messwerte.abfrage(zeitraum)
+18
View File
@@ -0,0 +1,18 @@
"""Messwerte der KI-Box mit Verlauf (Rolle box, seit 24.09.2026).
GET /api/messwerte?zeitraum=1h|24h|7d Minutenwerte des Stewards, verdichtet auf 60 s, 5 min bzw. 30 min
Dünn: die Logik liegt in services/messwerte.py, die Ablage in kern/messreihen.py.
"""
from fastapi import APIRouter, HTTPException
from services import messwerte
router = APIRouter(prefix="/api", tags=["messwerte"])
@router.get("/messwerte")
def messwerte_lesen(zeitraum: str = "1h") -> dict:
if zeitraum not in messwerte.ZEITRAEUME:
raise HTTPException(status_code=400, detail="Den Zeitraum gibt es nicht; möglich sind 1h, 24h und 7d.")
return messwerte.abfrage(zeitraum)
+1 -1
View File
@@ -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).
+247
View File
@@ -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
+184
View File
@@ -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}}
+2
View File
@@ -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]
+6 -1
View File
@@ -12,6 +12,8 @@ Mini-Dienst (mc2-steward.service, Restart=always):
• waechter.waechter_loop — Box-Wart-Wächter (seit 09/2026, löst sentry ab): Dienste,
Timer, Hermes-Jobs, Kern, Platte → Hinweise + Selbstreparatur;
beobachtet im Steward-Modus AUCH MC2 selbst + mc2-gateway
• messwerte.messwerte_loop — Messwerte der KI-Box mit Verlauf (seit 24.09.2026): ein Punkt
je Minute nach <Datenordner>/mc2-messwerte/ (nur Rolle box)
Das dritte Loop (Gedächtnis-Dubletten gegen den Sidecar auf :8765) ist mit dessen Ablösung
am 07.08.2026 entfallen — Hermes führt sein Gedächtnis selbst (docs/wissen/VERDIKTE.md).
@@ -32,7 +34,7 @@ import os
from config import CONFIG_PATH
from kern.einstellungen import einstellungen
from services import waechter, warmer
from services import messwerte, waechter, warmer
logging.basicConfig(
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
@@ -70,6 +72,9 @@ async def main() -> None:
warmer.INTERVAL, CONFIG_WATCH_S)
if waechter.ENABLED:
tasks.append(asyncio.create_task(waechter.waechter_loop()))
# Messwerte mit Verlauf (Logik in services/messwerte.py); das Homelab bekommt seine vom Ausführer.
if einstellungen().rolle == "box" and messwerte.ENABLED:
tasks.append(asyncio.create_task(messwerte.messwerte_loop()))
if not tasks:
log.warning("steward: alle Loops per Env deaktiviert — nichts zu tun, Ende.")
return
+394
View File
@@ -0,0 +1,394 @@
"""Messwerte mit Verlauf (Monitoring, 24.09.2026): der gemeinsame Speicher (kern/messreihen.py), der Minutenpunkt der
KI-Box, die Raten und Endpunkte des Homelabs, die reine Auswertung des Ausführers (mit echten Zeilen vom Proxmox-PC,
gelesen am 24.09.) und die Wächter-Prüfung des Hosts."""
import importlib.util
import json
import time
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from kern import einstellungen as einstellungen_mod
from kern import messreihen
from kern.zeit import LOCAL_TZ
BERICHT = json.loads((Path(__file__).parent / "fixtures" / "pve-bericht.json").read_text(encoding="utf-8"))
AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py"
MITTAG = datetime(2026, 9, 24, 12, 0, 10, tzinfo=LOCAL_TZ).timestamp() # Do 24.09.2026, 12:00:10 Berlin
@pytest.fixture
def daten(tmp_path, monkeypatch):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
monkeypatch.delenv("MC_AUSFUEHRER_TOKEN", raising=False)
einstellungen_mod.einstellungen.cache_clear()
from services.homelab import kanal
from services.homelab import messwerte as homelab_messwerte
monkeypatch.setattr(kanal, "_kontakt", {"zuletzt": None})
monkeypatch.setattr(homelab_messwerte, "_zustand", {"zaehler": {}, "geraete": {}})
yield tmp_path
einstellungen_mod.einstellungen.cache_clear()
def _werte(reihe: list) -> list:
return [v for _, v in reihe if v is not None]
# --- Der gemeinsame Speicher -----------------------------------------------------------------------
def test_stunde_im_minutenraster_mit_luecke(daten):
for minute, cpu in ((-5, 10), (-4, 20.0), (-2, 40), (-1, 50)): # 11:57 fehlt
messreihen.schreiben("box", {"cpu": cpu, "ram": 30}, t=MITTAG - 10 + minute * 60 + 30)
stunde = messreihen.lesen("box", "1h", ("cpu", "ram"), jetzt=MITTAG)
cpu = stunde["reihen"]["cpu"]
assert stunde["schritt_s"] == 60 and len(cpu) == 60 and len(stunde["reihen"]["ram"]) == 60
assert all(t % 60 == 0 for t, _ in cpu) and cpu[-1][0] == MITTAG - 10 # der laufende Schritt ist der letzte
assert [v for _, v in cpu[-6:]] == [10, 20.0, None, 40, 50, None] # Lücke bleibt Lücke, nichts erfunden
assert _werte(stunde["reihen"]["ram"]) == [30, 30, 30, 30]
def test_verdichtung_auf_fuenf_und_dreissig_minuten(daten):
beginn = MITTAG - 10 - 3600 # 11:00:00
for minute in range(60):
messreihen.schreiben("box", {"cpu": minute}, t=beginn + minute * 60 + 30)
tag = messreihen.lesen("box", "24h", ("cpu",), jetzt=MITTAG)
assert tag["schritt_s"] == 300 and len(tag["reihen"]["cpu"]) == 288
assert _werte(tag["reihen"]["cpu"]) == [2, 7, 12, 17, 22, 27, 32, 37, 42, 47, 52, 57] # Mittel je 5 Minuten
woche = messreihen.lesen("box", "7d", ("cpu",), jetzt=MITTAG)
assert woche["schritt_s"] == 1800 and len(woche["reihen"]["cpu"]) == 336
assert _werte(woche["reihen"]["cpu"]) == [14.5, 44.5]
# Ein zweites Lesen kommt aus den gemerkten Summen und ist gleich; ein neuer Punkt ändert die Datei und zählt mit.
assert messreihen.lesen("box", "7d", ("cpu",), jetzt=MITTAG) == woche
messreihen.schreiben("box", {"cpu": 100}, t=MITTAG)
assert _werte(messreihen.lesen("box", "7d", ("cpu",), jetzt=MITTAG)["reihen"]["cpu"]) == [14.5, 44.5, 100]
with pytest.raises(ValueError):
messreihen.lesen("box", "2d", ("cpu",), jetzt=MITTAG)
def test_kaputte_zeilen_werden_uebersprungen(daten):
messreihen.schreiben("pve", {"cpu": 1.0}, t=MITTAG - 200)
datei = messreihen.ordner() / "pve-2026-09-24.jsonl"
with open(datei, "ab") as f:
f.write(b'kein json\n[1, 2]\n{"cpu": 5}\n{"t": "gestern", "cpu": 5}\n\x00\x00\n')
f.write(b'{"t": %d, "cpu": NaN, "ram": true}\n' % int(MITTAG - 140))
f.write(b'{"t": %d, "cpu": 9' % int(MITTAG - 100)) # Absturz mitten in der Zeile
messreihen.schreiben("pve", {"cpu": 3.0, "ram": True, "platte": float("inf")}, t=MITTAG - 40)
punkte = messreihen.punkte("pve", MITTAG - 300, MITTAG)
assert [p["t"] for p in punkte] == [int(MITTAG - 200), int(MITTAG - 140), int(MITTAG - 40)]
assert punkte[-1] == {"t": int(MITTAG - 40), "cpu": 3.0, "ram": None, "platte": None}
assert _werte(messreihen.lesen("pve", "1h", ("cpu", "ram"), jetzt=MITTAG)["reihen"]["cpu"]) == [1.0, 3.0]
assert messreihen.letzter_punkt("pve", jetzt=MITTAG)["cpu"] == 3.0
def test_aufraeumen_nach_acht_tagen(daten):
ordner = messreihen.ordner()
ordner.mkdir(parents=True)
for name in ("box-2026-09-14.jsonl", "box-2026-09-15.jsonl", "box-2026-09-16.jsonl", "ct-100-2026-09-15.jsonl",
"ct-100-2026-09-23.jsonl", "notiz.txt", "zustand.json"):
(ordner / name).write_text("", encoding="utf-8")
messreihen.schreiben("box", {"cpu": 1}, t=MITTAG) # das erste Schreiben des Tages räumt auf
assert sorted(p.name for p in ordner.iterdir()) == [
"box-2026-09-16.jsonl", "box-2026-09-24.jsonl", "ct-100-2026-09-23.jsonl", "notiz.txt", "zustand.json"]
assert messreihen.aufraeumen(MITTAG) == 0
def test_letzter_punkt_auch_von_gestern_und_nie_zu_alt(daten):
mitternacht = datetime(2026, 9, 24, 0, 0, 20, tzinfo=LOCAL_TZ).timestamp()
messreihen.schreiben("box", {"cpu": 7}, t=mitternacht - 50) # 23.09., 23:59:30
assert messreihen.letzter_punkt("box", jetzt=mitternacht)["cpu"] == 7
assert messreihen.letzter_punkt("box", jetzt=mitternacht, max_alter_s=30) is None
assert messreihen.letzter_punkt("ct-999", jetzt=mitternacht) is None
assert messreihen.quellen(mitternacht - 3600, mitternacht) == ["box"]
def test_quellennamen_werden_geprueft(daten):
for boese in ("../box", "Box", "", "box/x"):
with pytest.raises(ValueError):
messreihen.schreiben(boese, {"cpu": 1}, t=MITTAG)
def test_rate_aus_zaehlerstaenden():
assert messreihen.rate(1000, 7000, 60) == 100.0
assert messreihen.rate(7000, 1000, 60) is None # Zähler kleiner: Neustart, keine negative Rate
assert messreihen.rate(1000, 7000, 301) is None # Lücke: keine Rate über fünf Minuten
assert messreihen.rate(None, 7000, 60) is None and messreihen.rate(1000, 7000, 0) is None
assert messreihen.rate(0, 1e12, 60) is None # 16 GB/s: ein Sprung, kein Verkehr
# --- KI-Box --------------------------------------------------------------------------------------
def test_minutenpunkt_der_box(monkeypatch):
from services import messwerte, system
folge = {"cpu": [(1000.0, 10000.0), (1300.0, 10600.0), (1400.0, 10700.0)],
"netz": [(1_000_000, 500_000), (1_600_000, 800_000), (100, 50)],
"tokens": [1000, 4000, 10], "gpu": [10, 30, 50, 0], "uhr": [0.0, 60.0, 120.0]}
monkeypatch.setattr(messwerte, "_cpu_zeiten", lambda: folge["cpu"].pop(0))
monkeypatch.setattr(messwerte, "_netz", lambda: folge["netz"].pop(0))
monkeypatch.setattr(messwerte, "_tokens", lambda: folge["tokens"].pop(0))
monkeypatch.setattr(messwerte, "_platte", lambda: 15.2)
monkeypatch.setattr(system, "_gpu_sysfs", lambda: {"busy_percent": folge["gpu"].pop(0)})
monkeypatch.setattr(system, "_temps", lambda: {"cpu": 40.0, "gpu": 35.5})
monkeypatch.setattr(messwerte.psutil, "virtual_memory", lambda: SimpleNamespace(percent=41.26))
messer = messwerte.Messer(uhr=lambda: folge["uhr"].pop(0))
messer.probe()
messer.probe()
# CPU: 300 von 600 Ticks beschäftigt; GPU: Mittel aus drei Proben; Netz und Tokens aus der Differenz über 60 s.
assert messer.punkt() == {"cpu": 50.0, "ram": 41.3, "gpu": 30.0, "temp_cpu": 40.0, "temp_gpu": 35.5,
"platte": 15.2, "netz_rx": 10000, "netz_tx": 5000, "tokens": 3000.0}
# Neustart des Gateways (neue Token-Zählung) und neue Netz-Zähler: diese Minute keine Rate statt einer negativen.
zweiter = messer.punkt()
assert (zweiter["netz_rx"], zweiter["netz_tx"], zweiter["tokens"], zweiter["gpu"]) == (None, None, None, 0.0)
assert zweiter["cpu"] == 100.0
def test_gemessen_wird_zur_halben_minute():
from services import messwerte
assert messwerte.naechste_messung(MITTAG) == MITTAG + 20 # 12:00:10 → 12:00:30
assert messwerte.naechste_messung(MITTAG + 20.5) == MITTAG + 80 # eben gemessen → 12:01:30
assert messwerte.naechste_messung(MITTAG + 25) == MITTAG + 80 # 12:00:35 → 12:01:30
def test_endpunkt_der_box(daten):
from routers import messwerte as messwerte_router
from services import messwerte
app = FastAPI()
app.include_router(messwerte_router.router)
client = TestClient(app)
jetzt = time.time()
punkt = {"cpu": 12.5, "ram": 40.0, "gpu": 3.0, "temp_cpu": 45.0, "temp_gpu": 40.0, "platte": 15.0,
"netz_rx": 1200, "netz_tx": 300, "tokens": 900.0}
messreihen.schreiben("box", punkt, t=jetzt - 30)
antwort = client.get("/api/messwerte", params={"zeitraum": "24h"}).json()
assert (antwort["zeitraum"], antwort["schritt_s"]) == ("24h", 300)
assert list(antwort["reihen"]) == list(messwerte.REIHEN)
assert all(len(reihe) == 288 for reihe in antwort["reihen"].values())
assert _werte(antwort["reihen"]["tokens"]) == [900] and _werte(antwort["reihen"]["cpu"]) == [12.5]
assert antwort["aktuell"] == punkt
stunde = client.get("/api/messwerte").json() # ohne Angabe: die letzte Stunde
assert stunde["schritt_s"] == 60 and len(stunde["reihen"]["cpu"]) == 60
assert client.get("/api/messwerte", params={"zeitraum": "1w"}).status_code == 400
# Schreibt der Steward nicht mehr, ist nichts „aktuell“.
assert set(messwerte.abfrage("1h", jetzt=jetzt + 600)["aktuell"].values()) == {None}
def test_aufraeumen_bietet_den_messwerte_ordner_nie_an(tmp_path, monkeypatch):
from services import aufraeumen, llamaswap
(tmp_path / "mc2-messwerte").mkdir()
(tmp_path / "Alt-GGUF").mkdir()
monkeypatch.setattr(aufraeumen, "MODELS_DIR", tmp_path)
monkeypatch.setattr(llamaswap, "read_config", lambda: {"models": {}})
assert [k["name"] for k in aufraeumen.kandidaten()] == ["Alt-GGUF"]
# --- Homelab: Raten, Annahme, Endpunkte ---------------------------------------------------------------
def test_netz_raten_mit_zaehlersprung_und_neustart():
from services.homelab import messwerte as hm
a = {"t": 0, "rx": 1000, "tx": 2000, "uptime": 500, "laeuft": True}
b = {"t": 60, "rx": 61000, "tx": 2600, "uptime": 560, "laeuft": True}
assert hm.raten(a, b) == (1000, 10)
assert hm.raten(None, b) == (None, None) # kein voriger Stand (dieser Teil neu gestartet)
c = {"t": 120, "rx": 500, "tx": 100, "uptime": 30, "laeuft": True}
assert hm.raten(b, c) == (None, None) # Gast neu gestartet: Laufzeit kleiner
d = {"t": 180, "rx": 400, "tx": 99999, "uptime": 90, "laeuft": True}
assert hm.raten(c, d) == (None, 1665) # nur rx kleiner geworden: nur dort keine Rate
assert hm.raten(d, {**d, "t": 600, "rx": 1000}) == (None, None) # Lücke über fünf Minuten
aus = {"t": 240, "rx": 0, "tx": 0, "uptime": 0, "laeuft": False}
assert hm.raten(d, aus) == (None, None) and hm.raten(aus, {**d, "t": 300}) == (None, None)
def _gast(vmid, art, name, etiketten, status="running", **werte):
gast = {"vmid": vmid, "art": art, "name": name, "etiketten": etiketten, "status": status, "cpu": 0.0, "maxcpu": 1,
"mem": 0, "maxmem": 536870912, "disk": 0, "maxdisk": 4143677440, "netin": 0, "netout": 0, "uptime": 0}
return {**gast, **werte}
def _minute(zeit: float, host_rx: int, gitea_netin: int) -> dict:
return {"zeit": zeit,
"host": {"cpu": 0.125, "speicher": {"belegt": 12_000_000_000, "gesamt": 32_000_000_000},
"rootfs": {"belegt": 48e9, "gesamt": 100e9}, "load1": 0.72, "uptime": 3379388, "temp_cpu": 50.5,
"netz": {"rx": host_rx, "tx": 1000, "schnittstellen": ["eno1"]}},
"gaeste": [
_gast(104, "lxc", "gitea", ["community-script", "git"], cpu=0.0183, mem=340934656,
maxmem=1073741824, disk=4914618368, maxdisk=8350298112, netin=gitea_netin, netout=5000,
uptime=1000),
_gast(106, "qemu", "arcane", [], cpu=0.25, maxcpu=4, mem=5325914112, maxmem=8589934592,
maxdisk=161061273600, netin=10, netout=10, uptime=99),
_gast(107, "lxc", "homelab-orchestrator", ["mc2"], cpu=0.5, mem=1, maxmem=2),
_gast(102, "lxc", "netbird", ["community-script"], status="stopped"),
]}
def _homelab_client() -> TestClient:
from routers import homelab
app = FastAPI()
app.include_router(homelab.router)
return TestClient(app)
def test_homelab_endpunkte(daten):
from services.homelab import kanal
client = _homelab_client()
jetzt = time.time()
assert client.post("/api/homelab/ausfuehrer/messwerte", json=_minute(jetzt, 0, 0)).status_code == 403
kopf = {"X-MC2-Ausfuehrer": kanal.token()}
erste = client.post("/api/homelab/ausfuehrer/messwerte", json=_minute(jetzt - 60, 5_000_000, 1_000_000),
headers=kopf)
assert erste.json() == {"ok": True, "geraete": 4} # Host und drei Gäste; der eigene Container fehlt
client.post("/api/homelab/ausfuehrer/messwerte", json=_minute(jetzt, 5_600_000, 1_600_000), headers=kopf)
assert not list(messreihen.ordner().glob("ct-107-*"))
# Noch ohne Bericht: die Geräte der letzten Meldung, Namen soweit bekannt.
ohne = client.get("/api/homelab/messwerte").json()
assert [(g["id"], g["name"], g["art"]) for g in ohne["geraete"]] == [
("pve", "Proxmox-Host", "proxmox-host"), ("ct-102", "netbird", "container"), ("ct-104", "gitea", "container"),
("vm-106", "Arcane (Docker)", "vm")]
kanal.bericht_speichern(BERICHT) # mit Bericht: Geräte und Namen wie im Inventar
antwort = client.get("/api/homelab/messwerte", params={"zeitraum": "1h"}).json()
assert (antwort["zeitraum"], antwort["schritt_s"]) == ("1h", 60)
geraete = {g["id"]: g for g in antwort["geraete"]}
assert list(geraete) == ["pve", "ct-100", "ct-101", "ct-102", "ct-103", "ct-104", "ct-105", "vm-106"]
assert (geraete["ct-104"]["name"], geraete["ct-100"]["name"], geraete["vm-106"]["name"]) == (
"Gitea", "AdGuard Home", "Arcane (Docker)")
host = geraete["pve"]
assert list(host["reihen"]) == ["cpu", "ram", "platte", "netz_rx", "netz_tx", "temp_cpu"]
assert all(len(reihe) == 60 for reihe in host["reihen"].values())
assert _werte(host["reihen"]["netz_rx"]) == [10000] # erste Minute ohne Vorgänger: keine Rate
assert host["aktuell"] == {"cpu": 12.5, "ram": 37.5, "ram_used": 12_000_000_000, "ram_total": 32_000_000_000,
"platte": 48.0, "netz_rx": 10000, "netz_tx": 0, "temp_cpu": 50.5, "load1": 0.72,
"uptime_s": 3379388}
gitea = geraete["ct-104"]
assert list(gitea["reihen"]) == ["cpu", "ram", "platte", "netz_rx", "netz_tx"]
assert gitea["aktuell"] == {"cpu": 1.8, "ram": 31.8, "ram_used": 340934656, "ram_total": 1073741824,
"platte": 58.9, "netz_rx": 10000, "netz_tx": 0, "temp_cpu": None, "load1": None,
"uptime_s": 1000}
arcane = geraete["vm-106"]["aktuell"]
assert (arcane["cpu"], arcane["ram"], arcane["platte"]) == (25.0, 62.0, None) # die Platte einer VM sieht Proxmox nicht
netbird = geraete["ct-102"]["aktuell"] # gestoppt: keine Messwerte, keine Nullen
assert (netbird["cpu"], netbird["ram"], netbird["netz_rx"], netbird["ram_total"]) == (None, None, None, 536870912)
assert set(geraete["ct-100"]["aktuell"].values()) == {None} # nichts gemessen
assert client.get("/api/homelab/messwerte", params={"zeitraum": "7d"}).json()["schritt_s"] == 1800
assert client.get("/api/homelab/messwerte", params={"zeitraum": "30d"}).status_code == 400
# --- Der Ausführer (läuft auf dem Proxmox-Host) -----------------------------------------------------------
NETDEV = """Inter-| Receive | Transmit
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
lo: 12220307 51502 0 0 0 0 0 0 12220307 51502 0 0 0 0 0 0
eno1: 848364497291 629881217 0 114788 0 0 0 3360103 211535843861 102116072 0 4 0 0 0 0
enp2s0: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
vmbr0: 736642320585 66694945 0 3399405 0 0 0 2462406 212959884634 52971342 0 5 0 0 0 0
veth100i0: 963620053 5314692 0 0 0 0 0 0 5535391141 14176389 0 0 0 0 0 0
veth104i0: 205263214515 35385337 0 0 0 0 0 0 6152585548 42736082 0 0 0 0 0 0
tap106i0: 10110528132 6394361 0 0 0 0 0 0 35706903774 10586880 0 0 0 0 0 0
fwpr106p0: 10110528132 6394361 0 0 0 0 0 0 35706903840 10586881 0 0 0 0 0 0
fwln106i0: 35706903840 10586881 0 0 0 0 0 0 10110528132 6394361 0 0 0 0 0 0
"""
MEMINFO = "MemTotal: 32225256 kB\nMemFree: 13537532 kB\nMemAvailable: 20092076 kB\nBuffers: 1 kB\n"
RESSOURCEN = [
{"id": "lxc/100", "type": "lxc", "vmid": 100, "name": "adguard", "node": "pve", "status": "running",
"tags": "adblock;community-script", "template": 0, "cpu": 0.000218199761345051, "maxcpu": 1, "mem": 247558144,
"maxmem": 536870912, "disk": 1361436672, "maxdisk": 2040373248, "netin": 5535402482, "netout": 963621401,
"uptime": 3379433},
{"id": "qemu/106", "type": "qemu", "vmid": 106, "name": "arcane", "node": "pve", "status": "running", "tags": None,
"template": 0, "cpu": 0.0194595849334057, "maxcpu": 4, "mem": 5325914112, "maxmem": 8589934592, "disk": 0,
"maxdisk": 161061273600, "netin": 35706938260, "netout": 10111961580, "uptime": 1726847},
{"id": "lxc/102", "type": "lxc", "vmid": 102, "name": "netbird", "node": "pve", "status": "stopped",
"tags": "community-script", "template": 0, "cpu": 0, "maxcpu": 1, "mem": 0, "maxmem": 536870912, "disk": 0,
"maxdisk": 4143677440, "netin": 0, "netout": 0, "uptime": 0},
{"id": "lxc/900", "type": "lxc", "vmid": 900, "name": "vorlage", "node": "pve", "template": 1},
{"id": "lxc/300", "type": "lxc", "vmid": 300, "name": "fremd", "node": "pve2", "status": "running"},
]
FUEHLER = [("r8169_0_100:00", "", 40.0), ("nvme", "Composite", 40.85), ("k10temp", "Tctl", 50.5),
("amdgpu", "edge", 38.0)]
def _ausfuehrer_modul():
spec = importlib.util.spec_from_file_location("ausfuehrer_messwerte", AUSFUEHRER)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
return modul
def _roh(zeit: float, netdev: str = NETDEV) -> dict:
return {"zeit": zeit, "stat": "cpu 1000 0 500 8000 500 0 0 0 0 0\ncpu0 500 0 250 4000 250 0 0 0 0 0\n",
"meminfo": MEMINFO, "loadavg": "0.72 0.50 0.49 3/665 2945458\n", "uptime": "3379388.63 39689887.90\n",
"netdev": netdev, "rootfs": (100861726720, 52717445120), "physisch": {"eno1", "enp2s0", "wlp3s0"},
"fuehler": FUEHLER, "ressourcen": RESSOURCEN}
def test_ausfuehrer_baut_den_messpunkt_aus_echten_zeilen():
a = _ausfuehrer_modul()
vorher = a.cpu_zeiten("cpu 900 0 400 7500 480 0 0 0 0 0\n")
punkt = a.messpunkt(_roh(1790000000.0), vorher, node="pve")
# Speicher und rootfs wie pvesh /nodes/pve/status am 24.09.2026 (used 12424376320 bzw. 48144281600).
assert punkt["host"] == {"cpu": 0.2778, "speicher": {"belegt": 12424376320, "gesamt": 32998662144},
"rootfs": {"belegt": 48144281600, "gesamt": 100861726720}, "load1": 0.72,
"uptime": 3379388, "temp_cpu": 50.5,
"netz": {"rx": 848364497291, "tx": 211535843861, "schnittstellen": ["eno1", "enp2s0"]}}
gaeste = {g["vmid"]: g for g in punkt["gaeste"]}
assert list(gaeste) == [100, 102, 106] # keine Vorlage, kein fremder Knoten
# Netz aus veth/tap, zeitgleich mit dem Host gelesen (Richtung aus Sicht des Gasts), nicht aus fwpr/fwln.
assert (gaeste[100]["netin"], gaeste[100]["netout"]) == (5535391141, 963620053)
assert (gaeste[106]["netin"], gaeste[106]["netout"]) == (35706903774, 10110528132)
assert (gaeste[102]["netin"], gaeste[102]["status"]) == (0, "stopped") # ohne Schnittstelle: /cluster/resources
assert gaeste[100]["etiketten"] == ["adblock", "community-script"] and gaeste[106]["art"] == "qemu"
assert (gaeste[106]["cpu"], gaeste[106]["maxcpu"], gaeste[106]["maxdisk"]) == (0.0194595849334057, 4, 161061273600)
# Ohne vorigen Stand keine CPU, ohne pvesh keine Gäste — der Host kommt trotzdem.
leer = a.messpunkt({**_roh(1.0), "ressourcen": None, "rootfs": None, "fuehler": []}, None, node="pve")
assert leer["gaeste"] == [] and leer["host"]["cpu"] is None and leer["host"]["temp_cpu"] is None
assert leer["host"]["rootfs"] is None and leer["host"]["load1"] == 0.72
def test_ausfuehrer_cpu_temperatur_und_netz_ohne_physische_schnittstelle():
a = _ausfuehrer_modul()
assert a.cpu_temperatur([("k10temp", "Tctl", 60.0), ("k10temp", "Tdie", 55.0)]) == 55.0
assert a.cpu_temperatur([("coretemp", "Core 0", 50.0), ("coretemp", "Package id 0", 58.3)]) == 58.3
assert a.cpu_temperatur([("nvme", "Composite", 40.0), ("amdgpu", "edge", 38.0)]) is None
zaehler = a.netz_zaehler(NETDEV)
assert a.host_netz(zaehler, set())["schnittstellen"] == ["vmbr0"]
assert a.host_netz({"lo": (1, 1)}, set()) is None
assert a.cpu_anteil((100, 1000), (100, 1000)) is None and a.speicher_aus("") is None
def test_ausfuehrer_und_homelab_teil_verstehen_sich(daten):
"""Der Messpunkt des Ausführers geht so, wie er ist, durch annehmen(): Namen der Felder und Richtung der Zähler."""
from services.homelab import messwerte as hm
a = _ausfuehrer_modul()
t0 = MITTAG + 20
erster = a.messpunkt(_roh(t0 - 60), None, node="pve")
netdev = NETDEV.replace("848364497291", "848370497291").replace("5535391141", "5535991141")
zweiter = a.messpunkt(_roh(t0, netdev), None, node="pve")
assert hm.annehmen(erster, jetzt=t0) == 4 and hm.annehmen(zweiter, jetzt=t0) == 4
host = messreihen.letzter_punkt("pve", jetzt=t0)
# 6 MB mehr auf eno1 in 60 s; RAM 12,42 von 33,0 GB wie im Proxmox-Knoten.
assert (host["netz_rx"], host["netz_tx"], host["ram"], host["platte"], host["temp_cpu"]) == (100000, 0, 37.7, 47.7,
50.5)
adguard = messreihen.letzter_punkt("ct-100", jetzt=t0)
assert (adguard["netz_rx"], adguard["netz_tx"], adguard["cpu"], adguard["platte"]) == (10000, 0, 0.0, 66.7)
assert messreihen.letzter_punkt("vm-106", jetzt=t0)["platte"] is None
# --- Wächter (Rolle homelab) ---------------------------------------------------------------------------
def test_waechter_meldet_vollen_oder_heissen_host(daten):
from services.homelab import messwerte as hm
for i in range(10):
messreihen.schreiben("pve", {"ram": 96.0 + i / 10, "ram_used": 31e9, "ram_total": 32e9, "temp_cpu": 91.0},
t=MITTAG - 590 + i * 60)
befunde = {b.id: b for b in hm.pruefe_host(MITTAG)}
assert set(befunde) == {"host-ram", "host-temp"} and {b.stufe for b in befunde.values()} == {"gelb"}
assert befunde["host-ram"].titel == "Proxmox-Host: Arbeitsspeicher seit 10 Minuten über 95 %"
assert "Zuletzt 97 % belegt (31,0 von 32,0 GB)" in befunde["host-ram"].text
assert befunde["host-temp"].titel == "Proxmox-Host: CPU seit 10 Minuten über 90 °C"
assert hm.pruefe_host(MITTAG + 400) == [] # nur drei Minuten im Fenster: zu wenig
messreihen.schreiben("pve", {"ram": 90.0, "temp_cpu": 80.0}, t=MITTAG - 30)
assert hm.pruefe_host(MITTAG) == [] # eine Minute darunter: nicht „10 Minuten lang“
# Registriert ist die Prüfung nur in der Rolle homelab: test_partner.py prüft die Liste in einem eigenen Prozess.
+1 -1
View File
@@ -220,7 +220,7 @@ from services import waechter
print(json.dumps({"pruefungen": [p.__name__ for p in waechter.PRUEFUNGEN], "daten": str(waechter.DATEN_DIR)}))
""")
assert ergebnis["pruefungen"] == ["pruefe_platte", "pruefe_partner", "pruefe_ausfuehrer", "pruefe_gaeste",
"pruefe_gast_platten", "pruefe_paketlisten"]
"pruefe_gast_platten", "pruefe_paketlisten", "pruefe_host"]
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):
+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
+70 -5
View File
@@ -58,7 +58,7 @@ Client an MC2 vorbei direkt auf `:8080`. Nur den Radar-Kandidaten startet der Ra
|---|---|---|---|
| `mission-control-2` (`:9001`) | `backend/app.py` | Oberfläche aus `frontend/dist`; 62 `/api`-Routen; reicht `/v1` roh an den Gateway weiter (`MC_V1_UPSTREAM`); reicht das Hermes-Dashboard unter `/hermes-ui/` durch (HTTP und WebSocket); Erinnerungen; startet Update- und Download-Aufträge als eigene systemd-Einheiten `mc2-job-<id>` (`services/jobengine.py`) | Briefkasten, Erinnerungen, Routing-Policy, Hugging-Face-Zugang, ausgeblendete Hinweise |
| `mc2-gateway` (`127.0.0.1:9010`) | `backend/gateway_app.py` | `/v1` mit `model: auto` und Bild-Weiche, Kontext-Warnung, `/gw/health` | Token-Zähler (`~/.hermes/token_stats.json`) |
| `mc2-steward` | `backend/steward.py` | Re-Warm (alle 90 s, lädt das Hirn nach, wenn nichts geladen ist), Config-Watch (5 s), Wächter (jede Minute) | `mc2-waechter.json` (einziger Schreiber) |
| `mc2-steward` | `backend/steward.py` | Re-Warm (alle 90 s, lädt das Hirn nach, wenn nichts geladen ist), Config-Watch (5 s), Wächter (jede Minute), Messwerte (ein Punkt je Minute, seit 24.09.) | `mc2-waechter.json` (einziger Schreiber), `mc2-messwerte/box-*.jsonl` |
| `mc2-radar` (Timer 00:30) | `backend/radar_lauf.py` | Suche und Nachttest neuer Modelle | `mc2-radar.json`, Baseline, `/srv/models/radar/` |
| Bash-Schicht | `deploy/*.sh` | Updates (`autoupdate.sh` mit `update-swap.sh`, `update-engine.sh`, Postchecks, `self-repair.sh`), Sicherung (`backup.sh`, `restore.sh`), Meldungen (`notify.sh`, `morgenmeldung.sh`), Vorwärmen (`warmup.sh`), `projekte-sync.sh`, Deploy (`deploy.sh`, `pruefen.sh`) | Pins, Meldeprotokoll, Nacht-Warteschlange, Deploy-Log |
@@ -172,6 +172,7 @@ Fremde Dienste, die der Box-Wart nur steuert oder überwacht: `llama-swap` (Syst
| `/srv/models/mc2-deploy.log` | `deploy.sh` | eine Zeile je Deploy, auch gescheiterte |
| `~/mc2-notify.log` | `notify.sh` (anhängen), `morgenmeldung.sh` (über 3 MB auf 2 MB kürzen) | Quelle des Update-Verlaufs für Läufe vor dem 24.09.; der Wortlaut „QUEUED für Morgen-Digest" muss bleiben |
| `/srv/models/mc2-update-verlauf.jsonl` | `autoupdate.sh`, MC2 (Update-Knöpfe) | nur anhängen, eine JSON-Zeile je Baustein |
| `/srv/models/mc2-messwerte/box-JJJJ-MM-TT.jsonl` | nur der Steward (Box); im Homelab `/var/lib/mc2/mc2-messwerte/<gerät>-…` nur MC2 | nur anhängen unter `flock`, eine Zeile je Minute; Tage älter als 8 löscht der Schreiber selbst (siehe „Monitoring“) |
| `~/.hermes/night-queue.txt` | `notify.sh` (anhängen), `morgenmeldung.sh` (übernehmen, senden) | nicht gesendeter Stapel bleibt als `.senden` liegen |
## Meldeweg
@@ -201,6 +202,7 @@ Partner-Instanz. In der Rolle `homelab` gibt es nur `/api/health` und die Partne
| Modelle (`routers/models.py`) | `GET /models`, `GET /discover`, `POST /models/register`, `GET /hf/search`, `GET /hf/quants`, `POST /models/install`, `GET /jobs`, `POST /jobs/{id}/cancel`, `POST /models/{id}/role`, `POST /models/unload`, `POST /models/{id}/unload`, `POST /models/{id}/load`, `DELETE /models/{id}` |
| Wartung (`routers/maintenance.py`) | `GET /maintenance/updates`, `GET /maintenance/update-details`, `POST` `check-updates`, `os-update`, `engine-update`, `swap-update`, `hermes-update`, `update-all`, `restart`; `GET /maintenance/logs`; `GET`/`POST /maintenance/geheimnisse` |
| System (`routers/system.py`) | `GET /system/status`, `GET /system/services`, `POST /system/backup`, `GET /system/backups`, `POST /system/restart`, `GET /system/token-stats` |
| Messwerte (`routers/messwerte.py`, seit 24.09.) | `GET /messwerte?zeitraum=1h\|24h\|7d` |
| Lucy und Sprache (`routers/voice.py`) | `POST /alarm`, `POST /voice/announce`, `GET /voice/announcements`, `POST /voice/stt`, `POST /voice/turn`, `POST /voice/chat`, `GET /lucy/stimme/health`, `POST /lucy/stimme/tts`, `POST /lucy/stimme/tts/stream` |
| Sonstiges | `GET /health`, `GET /stream` (Live-Strom, SSE), `GET /routing`, `GET`/`POST /reminders`, `DELETE /reminders/{id}`, `GET /zeitmaschine`, `POST /zeitmaschine/restore` |
| Partner (`routers/partner.py`) | `GET /partner` (Lebenszeichen), `/partner/{pfad}` (Durchreiche, alle Methoden) |
@@ -234,7 +236,7 @@ flowchart LR
feste Liste von Aktionen aus (`bericht`, `snapshot`, `update`, `os_update`, `suchen`, `zurueck`,
`snapshot_loeschen`, `sichern`, `sicherung_zurueck`, `sicherung_loeschen`, `host_update`, `host_neustart`) und
prüft selbst, ob ein Gast das Etikett `community-script` oder `watcher` trägt und nicht `watcher-aus` — dem Server
vertraut er dabei nicht.
vertraut er dabei nicht. Seit 24.09. schickt er außerdem jede Minute Messwerte (eigener Faden, siehe „Monitoring“).
- **Bericht** (nur lesend, auch von Hand: `python3 ausfuehrer.py --bericht`): Host-Version und Paket-Updates, je
Gast Status, IP, Etiketten, Snapshots, ob ein Snapshot geht (Bind-Mounts wie beim PBS verhindern ihn), die
Community-Script-Kennung (aus `/usr/bin/update` im Gast), die App-Version (je App eigener Weg: `/root/.<app>`,
@@ -257,7 +259,8 @@ flowchart LR
`apps.py` (App-Katalog: Name, GitHub-Quelle, Weboberfläche, Update-Weg), `inventar.py` (Bericht + neueste Versionen
von GitHub + eigene Webprüfung → Ziele im gemeinsamen Modell; Paketlisten älter als 14 Tage = „unklar“ mit Knopf
„Nach Updates suchen“), `karenz.py` (Wartezeit nach einer Skriptänderung), `updates.py` („Jetzt updaten“),
`pflege.py` (wöchentliches Suchen). Schnittstellen `routers/homelab.py` unter `/api/homelab/…`.
`pflege.py` (wöchentliches Suchen), `messwerte.py` (Minutenpunkte des Ausführers, siehe „Monitoring“).
Schnittstellen `routers/homelab.py` unter `/api/homelab/…`.
- **„Jetzt updaten“** (nur per Knopf): Snapshot, wo keiner geht Sicherung (wo auch die nicht geht: ohne Rückweg, mit
Warnung in der Rückfrage; scheitert der Schritt, beginnt das Update nicht) → `update` des Community-Scripts
(`PHS_SILENT=1`) bzw. Pakete → 20 s warten → frischer Bericht → Prüfung (Gast läuft, Weboberfläche antwortet,
@@ -284,8 +287,9 @@ flowchart LR
mit `MC_ARCANE_ECHT=1`. Die Arcane-VM braucht dafür kein Etikett, weil der Ausführer nicht beteiligt ist.
- **Wächter** in der Rolle `homelab`: Platte, Partner (die KI-Box), Ausführer (kein Bericht seit 30 min = rot),
jede Weboberfläche der freigegebenen Gäste (außer mitten in ihrem Update-Lauf; das Ergebnis meldet der Lauf), die
Platten der laufenden Container (ab 80 % gelb, ab 90 % rot — ext4 hält 5 % für root zurück; der Ausführer schickt `platte` mit) und das
wöchentliche Suchen (gelb, wenn es wiederholt scheitert).
Platten der laufenden Container (ab 80 % gelb, ab 90 % rot — ext4 hält 5 % für root zurück; der Ausführer schickt `platte` mit), das
wöchentliche Suchen (gelb, wenn es wiederholt scheitert) und seit 24.09. den Proxmox-Host selbst aus den Messwerten
(gelb, wenn er 10 Minuten lang über 95 % RAM oder 90 °C CPU liegt).
- **Oberfläche** (Bereich „Homelab“, nur Daten aus `/api/homelab/…`):
- **Cockpit** (`/homelab`): oben die Lage wie das Warnpanel der KI-Box — die große Leuchte (Störung vor Hinweis vor
laufendem Update vor offenen Updates) und eine Lampe je Gerät —, darunter die Hinweise des Homelab-Wächters und
@@ -298,3 +302,64 @@ flowchart LR
(`/etc/mc2/telegram.env`), nachts sammelt eine eigene Morgenmeldung („[Morgenmeldung Homelab]“).
- **Einrichtung** (Reihenfolge, jeder Schritt braucht das User-OK): `container-anlegen.sh` → `ausrollen.sh <ip>` →
`ausfuehrer-einrichten.sh <ip>` → `box-partner.sh <ip>`; Details in [BETRIEB.md](BETRIEB.md).
## Monitoring: Messwerte mit Verlauf (seit 24.09.)
Der Live-Strom der KI-Box (`/api/stream`, jede Sekunde ein Messpunkt) zeigt nur den Augenblick. Für den Verlauf
schreiben beide Bereiche jede Minute einen Punkt, gelesen wird er für die letzte Stunde, den letzten Tag oder die
letzte Woche.
```mermaid
flowchart LR
ST["mc2-steward (Box)<br/>jede Minute"] -->|box-…jsonl| MR[("mc2-messwerte/<br/>kern/messreihen.py")]
AF["Ausführer (pve)<br/>eigener Faden, jede Minute"] -->|POST …/ausfuehrer/messwerte| HL["Homelab-Teil<br/>services/homelab/messwerte.py"]
HL -->|pve-…, ct-…, vm-…jsonl| MR2[("mc2-messwerte/<br/>im Container")]
MR -->|GET /api/messwerte| UI["Oberfläche"]
MR2 -->|GET /api/homelab/messwerte| UI
MR2 -->|RAM, Temperatur| W["Wächter (homelab)"]
```
- **Speicher** `backend/kern/messreihen.py` (beide Rollen): je Quelle und Tag eine Datei
`<Datenordner>/mc2-messwerte/<quelle>-JJJJ-MM-TT.jsonl` (Tag nach Berliner Zeit), eine JSON-Zeile je Minute
(`{"t": Unix-Sekunden, "cpu": …, …}`, `null` = nicht gemessen). Schreiben hängt eine Zeile an, unter Thread-Sperre
und `flock`; fehlt am Dateiende der Zeilenumbruch (Absturz), beginnt die neue Zeile auf einer eigenen. Das erste
Schreiben eines neuen Tages löscht Dateien, deren Tag mehr als 8 Tage zurückliegt. Lesen verdichtet: `1h` in
60-s-Schritten, `24h` als 5-min-Mittel, `7d` als 30-min-Mittel (60, 288 bzw. 336 Werte, Schritte auf vollen
Vielfachen, der letzte ist der laufende). Ein Schritt ohne Messung bleibt `null`, nichts wird aufgefüllt; kaputte
Zeilen werden übersprungen. Die Summen vergangener Tage merkt sich der Prozess je Datei, solange sie sich nicht
ändert. Gemessen wird zur halben Minute, damit jeder 60-s-Schritt genau einen Punkt bekommt.
- **KI-Box** (`services/messwerte.py`, Taktgeber im Steward; `MC_MESSWERTE_ENABLED=0` schaltet ab, ein Trocken- oder
Probelauf schreibt nie mit): `cpu` = Mittel der Minute (aus eigenen `psutil.cpu_times`-Ständen gerechnet wie
`cpu_percent`, denn psutil merkt sich den letzten Aufruf je Thread), `ram` und `platte` (Modell-Laufwerk) beim Messen,
`gpu`, `temp_cpu`, `temp_gpu` als Mittel von Proben alle 5 s (`MC_MESSWERTE_PROBE_S`), `netz_rx`/`netz_tx` in
Bytes/s über alle Schnittstellen außer `lo`, `tokens` = Prompt- plus Antwort-Tokens pro Minute (Differenz der
Gesamtzähler aus `services/token_stats.py`). Ein Zähler, der kleiner wird, ergibt in dieser Minute `null`.
- **Homelab:** Der Ausführer schickt in einem eigenen Faden jede Minute `POST /api/homelab/ausfuehrer/messwerte`
(gleiche Anmeldung wie seine anderen Endpunkte), unabhängig vom 10-min-Bericht und von Aufträgen, die bis zu einer
Stunde laufen. Host-Werte liest er selbst aus `/proc` (`stat` für das CPU-Mittel der Minute, `meminfo`, `loadavg`,
`uptime`) und `statvfs("/")`, denn `pvesh get /nodes/<node>/status` liefert in einem frischen pvesh-Prozess immer
`cpu: 0` (nachgesehen am 24.09.); belegt wie bei Proxmox: RAM = gesamt − verfügbar, rootfs = gesamt − frei. Netz des
Hosts = Summe der physischen Schnittstellen aus `/proc/net/dev` (wie die Knoten-Anzeige in Proxmox; `vmbr0` sähe den
Verkehr der Gäste nach draußen nicht), ohne physische Schnittstelle `vmbr0`. CPU-Temperatur aus hwmon (`k10temp`
bzw. `zenpower` mit Tdie vor Tctl, `coretemp` mit „Package id 0“), sonst `null`; auf dem Proxmox-PC gibt es nur
`k10temp`/Tctl, kein `thermal_zone` und kein lm-sensors. Die Gäste kommen aus einem einzigen Aufruf
`pvesh get /cluster/resources --type vm` (`cpu` schon auf die eigenen Kerne gerechnet, `mem`, `disk`, …), ihre
Netz-Zähler aber aus denselben `/proc/net/dev`-Zeilen (`veth<vmid>iN`, `tap<vmid>iN`; die Stände in
`/cluster/resources` sind bis zu 10 s alt und verzerrten die Minutenrate). Fehler bleiben still; ein Fehler steht
einmal im Journal, bis wieder ein Punkt durchgeht. Von Hand: `python3 ausfuehrer.py --messwerte`.
- **Homelab-Teil** (`services/homelab/messwerte.py`): rechnet die kumulativen Zähler in Bytes/s um. Keine Rate
(`null`) gibt es, wenn der Zähler oder die Laufzeit kleiner wurde (Neustart von Gast oder Host), der vorige Stand
fehlt (Neustart des Homelab-Teils; die Stände liegen nur im Speicher) oder mehr als 5 Minuten zurückliegt, oder der
Sprung über 5 GB/s liegt. Gestoppte Gäste haben keine Messwerte (`null`, keine Nullen); die Platte einer VM sieht
Proxmox nicht (`null`). Der eigene Container (Etikett `mc2`) wird wie im Inventar nicht erfasst. Quellen `pve`,
`ct-<vmid>`, `vm-<vmid>`.
- **Schnittstellen:** `GET /api/messwerte?zeitraum=1h|24h|7d` (nur Rolle `box`) liefert
`{zeitraum, schritt_s, reihen: {cpu, ram, gpu, temp_cpu, temp_gpu, platte, netz_rx, netz_tx, tokens}, aktuell}`;
`GET /api/homelab/messwerte?zeitraum=…` liefert `{zeitraum, schritt_s, geraete: [{id, name, art, reihen, aktuell}]}`
mit den Geräten wie im Inventar (Host, dann die Gäste nach Nummer; Namen aus `apps.py`). Jede Reihe ist eine Liste
`[t, wert]` (t = Beginn des Schritts); `temp_cpu` gibt es als Reihe nur beim Host, in `aktuell` stehen `temp_cpu`
und `load1` bei Gästen auf `null`. `aktuell` ist der letzte Minutenpunkt, solange er höchstens 3 Minuten alt ist,
sonst lauter `null`. Ein unbekannter Zeitraum ergibt 400. Neue Punkte stößt der Live-Strom nicht an: Die Oberfläche
fragt die Messwerte selbst nach (einmal je Minute genügt).
- **Wächter** (Rolle `homelab`, `pruefe_host`): gelb, wenn der Proxmox-Host in den letzten 10 Minuten (mindestens 8
Minutenpunkte) durchgehend über 95 % RAM (`MC_WAECHTER_HOST_RAM`) oder über 90 °C CPU (`MC_WAECHTER_HOST_TEMP`) lag.
+41 -4
View File
@@ -81,11 +81,13 @@ soll kein Alarm sein).
| Festgehaltene Updates | – | jeder Pin |
| Partner-Instanz (nur mit `MC_PARTNER_URL`) | „<Name> antwortet nicht" | – |
| Sicherung (seit 24.09.) | – | `pbs-backup` scheitert; Probe-Wiederherstellung rot, älter als 40 Tage oder noch nie gelaufen (sobald ihr Timer eingerichtet ist) |
| Proxmox-Host (nur Rolle `homelab`, seit 24.09.) | – | 10 Minuten lang über 95 % RAM oder über 90 °C CPU (aus den Messwerten, mindestens 8 Minutenpunkte) |
| Abgestürzte Prüfung | – | „Eine Prüfung des Wächters lief nicht" |
In der Rolle `homelab` prüft der Wächter Platte, Partner, den Ausführer und die Weboberflächen der freigegebenen
Gäste (einen Gast mitten in seinem Update-Lauf nicht: Das Ergebnis meldet der Lauf selbst) und meldet mit
„[Homelab-Problem]". Im selben Takt stößt er das wöchentliche Suchen an (siehe „Homelab-Teil im Betrieb“); scheitert
In der Rolle `homelab` prüft der Wächter Platte, Partner, den Ausführer, die Weboberflächen der freigegebenen
Gäste (einen Gast mitten in seinem Update-Lauf nicht: Das Ergebnis meldet der Lauf selbst) und seit 24.09. RAM und
CPU-Temperatur des Proxmox-Hosts (gelb, Schwellen über `MC_WAECHTER_HOST_RAM` und `MC_WAECHTER_HOST_TEMP` in
`/etc/mc2/homelab.env`) und meldet mit „[Homelab-Problem]". Im selben Takt stößt er das wöchentliche Suchen an (siehe „Homelab-Teil im Betrieb“); scheitert
es für einen Gast zweimal hintereinander, gibt es einen gelben Hinweis „<App>: Die Suche nach Updates scheitert“.
- **Selbstreparatur:** Nur ein abgestürzter Dienst (`ActiveState=failed`) wird neu gestartet, höchstens 2× je Stunde
@@ -257,6 +259,41 @@ laufen lassen. Achtung: `ausfuehrer-einrichten.sh` schreibt `/etc/mc2-ausfuehrer
nach dem Start. Stand in `/var/lib/mc2/homelab-pflege.json` (Nacht, je Gast: letzter Tag, Fehlschläge, letzter
Fehler). Schreibt nur der Steward; zum Zurücksetzen die Datei löschen und `systemctl restart mc2-homelab-steward`.
## Messwerte (Monitoring, seit 24.09.)
Jede Minute ein Punkt je Gerät, gelesen als letzte Stunde, letzter Tag oder letzte Woche (Aufbau in
[ARCHITEKTUR.md](ARCHITEKTUR.md), Abschnitt „Monitoring“).
- **Wo:** Box `/srv/models/mc2-messwerte/box-JJJJ-MM-TT.jsonl` (schreibt `mc2-steward`). Homelab
`/var/lib/mc2/mc2-messwerte/pve-…`, `ct-<vmid>-…`, `vm-<vmid>-…` (schreibt `mc2-homelab`, was der Ausführer jede Minute
schickt; Eigentümer `mc2`). Je Tag (Berliner Zeit) eine Datei, eine JSON-Zeile je Minute.
- **Wie groß:** eine Zeile hat rund 140 Bytes (Box, Gäste) bzw. 175 Bytes (Proxmox-Host), am Tag 1440 Zeilen. Box:
rund 0,2 MB je Tag, höchstens rund 2 MB. Homelab mit Host und 7 Gästen: rund 1,7 MB je Tag, höchstens rund 15 MB;
jeder weitere Gast rund 0,2 MB je Tag.
- **Wie lange:** Das erste Schreiben eines neuen Tages löscht die Tagesdateien, deren Tag mehr als 8 Tage zurückliegt;
es bleiben höchstens 9 je Gerät (heute und die 8 Tage davor). Nicht in der Sicherung (`backup.sh` nimmt nur
`mc2-*.json`); das Aufräumen der Modell-Platte bietet den Ordner nie zum Löschen an. Den Ordner von Hand zu löschen
ist harmlos: Er entsteht mit dem nächsten Punkt neu, der Verlauf beginnt von vorn.
- **Lücken:** Läuft der Steward, die Box oder der Ausführer nicht, bleiben die Minuten leer (`null`) und werden nicht
aufgefüllt. Nach einem Neustart des Homelab-Teils fehlt für eine Minute die Netz-Rate; nach dem Neustart eines Gasts
oder des Hosts ebenso (die Zähler beginnen von vorn).
- **Ansehen:**
```bash
tail -n 3 /srv/models/mc2-messwerte/box-$(date +%F).jsonl # Box: die letzten Minuten
curl -s 'http://127.0.0.1:9001/api/messwerte?zeitraum=1h' | jq '.aktuell' # Box: letzter Punkt
curl -s 'http://192.168.178.31:9001/api/homelab/messwerte?zeitraum=1h' | jq '.geraete[] | {id, aktuell}'
python3 /usr/local/lib/mc2/ausfuehrer.py --messwerte # auf pve: ein Punkt, nur lesend (CPU über eine Sekunde)
journalctl -u mc2-ausfuehrer -n 50 | grep Messwerte # auf pve: steht hier etwas, gingen Punkte nicht durch
```
- **Ausführer:** Ein neuer Stand kommt nicht mit dem Deploy, sondern mit `bash deploy/homelab/ausfuehrer-einrichten.sh
<ip>` (danach ein von Hand gesetztes `sicherung_speicher` wieder eintragen, siehe oben). Ein alter Ausführer schickt
keine Messwerte; dann bleibt der Homelab-Verlauf leer, sonst ändert sich nichts.
- **Schalter:** `MC_MESSWERTE_ENABLED=0` im Steward der Box schaltet das Schreiben ab; `MC_MESSWERTE_PROBE_S` (Standard
5) ist der Abstand der Proben für GPU-Last und Temperaturen. Ein Trocken- oder Probelauf (`MC_WAECHTER_TROCKEN=1`,
`MC_PROBELAUF=1`) schreibt nie mit.
## Sicherung
- **Wann:** `mc2-backup.timer` täglich 03:30 (+≤5 min); außerdem vor jedem Hermes-Update, vor jedem Zurückspielen und
@@ -383,7 +420,7 @@ Update erneut.
| `~/mission-control-v2` | Checkout von `main` (nur über `deploy.sh` ändern) |
| `~/mission-control-v2/backend/.venv` | Python-Umgebung des Box-Warts (Python 3.14) |
| `~/.config/systemd/user/` | User-Units und Drop-ins |
| `/srv/models/` | Modelle, Box-Wart-Zustand (`mc2-*.json`, darunter `mc2-einstellungen.json` und `mc2-stand.json`), Sicherungen (`mc2-backups/`), Radar-Downloads (`radar/`) |
| `/srv/models/` | Modelle, Box-Wart-Zustand (`mc2-*.json`, darunter `mc2-einstellungen.json` und `mc2-stand.json`), Sicherungen (`mc2-backups/`), Radar-Downloads (`radar/`), Messwerte (`mc2-messwerte/`) |
| `~/.config/systemd/user/mc2-autoupdate.timer.d/zeitfenster.conf` | Update-Zeitfenster aus der Oberfläche (nur MC2 schreibt es) |
| `/etc/llama-swap/config.yaml` | lebende llama-swap-Config (+ `.bak-*`) |
| `/opt/llamacpp-vulkan/`, `/usr/local/bin/llama-server` | Motor (llama.cpp Vulkan) |