Später · 18 Plattengesundheit: Proxmox-Host, NAS über SNMP, NVMe der KI-Box

- kern/platten.py: SMART aus pvesh, smartctl und SNMP in einer Form; rot bei SMART FAILED, kritischer Warnung,
  aufgebrauchter Reserve, ≥ 90 % verbraucht; gelb bei ≥ 80 %, Hitze, gewachsenen Fehlerzählern (Ausgangslage je
  Platte, „Zur Kenntnis genommen“ setzt sie neu)
- Ausführer: stündlich disks/list + disks/smart im Bericht (nur lesend)
- services/homelab/nas_snmp.py: kleiner SNMPv2c-GetBulk-Leser ohne neue Abhängigkeit, QNAP hdTable/diskTable,
  Zugang 0600, Einstellungen „Verbindung testen und speichern“
- services/platten.py: NVMe der KI-Box über sudo smartctl (smartmontools am 26.09. nachinstalliert)
- Wächter beider Rollen, Plattenfeld in beiden Cockpits, Doku

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-26 09:54:38 +02:00
co-authored by Claude Opus 5.5
parent 5325544d89
commit 904598031a
46 changed files with 1212 additions and 41 deletions
+201
View File
@@ -0,0 +1,201 @@
"""Plattengesundheit (Ausbauplan „Später“, Punkt 18, seit 26.09.2026): SMART-Werte vereinheitlichen und bewerten.
Quellen: der Proxmox-Host (Ausführer: pvesh disks/list und disks/smart), die KI-Box (smartctl -j) und das NAS (SNMP,
services/homelab/nas_snmp.py). Einheitliche Form je Platte:
{"id", "name", "wo", "typ", "groesse", "gesund": True/False/None, "verbraucht": % oder None,
"reserve": % oder None, "reserve_grenze": % oder None, "warnung": 0 oder Bits, "temperatur": °C oder None,
"stunden": h oder None, "fehler": {"medien", "umgelagert", "ausstehend", "unkorrigierbar", "kabel": Zahl}}
Gemeldet wird, was schlecht ist oder schlechter wird (User-Entscheid „wie empfohlen“ 25.09.2026):
rot die Platte hält sich selbst für krank (SMART FAILED, kritische Warnung, Reserve unter der Grenze) oder ist
zu 90 % verbraucht
gelb zu 80 % verbraucht, zu heiß, oder ein Fehlerzähler ist seit dem letzten „Zur Kenntnis genommen“ gewachsen
Fehler, die schon beim ersten Blick da waren, gelten als Ausgangslage (basis) — sonst meldete eine ältere, aber stabile
Platte für immer. Die Ausgangslage liegt je Instanz in <Datenordner>/platten-basis.json.
"""
import json
import re
from pathlib import Path
from kern.einstellungen import einstellungen
VERBRAUCHT_GELB, VERBRAUCHT_ROT = 80, 90
HEISS = {"nvme": 70, "ssd": 60, "hdd": 55}
FEHLER_NAME = {"medien": "Medienfehler", "umgelagert": "umgelagerte Sektoren", "ausstehend": "wartende Sektoren",
"unkorrigierbar": "unkorrigierbare Sektoren", "kabel": "Übertragungsfehler (Kabel)"}
# SATA-Attribute (Nummer → Fehlerart) und die Temperatur.
ATA_FEHLER = {5: "umgelagert", 197: "ausstehend", 198: "unkorrigierbar", 199: "kabel"}
ATA_TEMPERATUR = (194, 190)
def _zahl(text: object) -> int | None:
m = re.search(r"-?\d[\d,.]*", str(text or ""))
if not m:
return None
try:
return int(m.group(0).replace(",", "").replace(".", ""))
except ValueError:
return None
def aus_nvme_text(text: str) -> dict:
"""Die NVMe-Werte aus dem Text von smartctl (so liefert ihn pvesh disks/smart)."""
werte: dict[str, str] = {}
for zeile in (text or "").splitlines():
if ":" in zeile:
k, v = zeile.split(":", 1)
werte.setdefault(k.strip(), v.strip())
warnung = werte.get("Critical Warning")
return {
"warnung": int(warnung, 16) if warnung and re.fullmatch(r"0x[0-9a-fA-F]+", warnung) else _zahl(warnung) or 0,
"temperatur": _zahl(werte.get("Temperature")),
"reserve": _zahl(werte.get("Available Spare")),
"reserve_grenze": _zahl(werte.get("Available Spare Threshold")),
"verbraucht": _zahl(werte.get("Percentage Used")),
"stunden": _zahl(werte.get("Power On Hours")),
"fehler": {"medien": _zahl(werte.get("Media and Data Integrity Errors")) or 0},
}
def aus_ata_attributen(attribute: list) -> dict:
"""SATA: Fehlerzähler und Temperatur aus den Attributen (pvesh disks/smart „attributes“)."""
fehler: dict[str, int] = {}
temperatur = None
for a in attribute or []:
try:
nr = int(a.get("id"))
except (TypeError, ValueError):
continue
roh = _zahl(str(a.get("raw") or "").split()[0] if str(a.get("raw") or "").split() else None)
if nr in ATA_FEHLER and roh is not None:
fehler[ATA_FEHLER[nr]] = roh
if nr in ATA_TEMPERATUR and temperatur is None and roh is not None:
temperatur = roh
return {"fehler": fehler, "temperatur": temperatur}
def aus_proxmox(disk: dict, smart: dict | None) -> dict:
"""Eine Platte des Proxmox-Hosts aus pvesh disks/list (+ disks/smart)."""
typ = str(disk.get("type") or "").lower() or "unbekannt"
health = str((smart or {}).get("health") or disk.get("health") or "").upper()
platte = {"id": f"pve:{disk.get('serial') or disk.get('devpath')}", "name": disk.get("model") or disk.get("devpath"),
"wo": f"Proxmox-Host {disk.get('devpath')}", "typ": typ, "groesse": disk.get("size"),
"gesund": True if health in ("PASSED", "OK") else False if health == "FAILED" else None,
"verbraucht": None, "reserve": None, "reserve_grenze": None, "warnung": 0, "temperatur": None,
"stunden": None, "fehler": {}}
wearout = disk.get("wearout")
if isinstance(wearout, (int, float)) and 0 <= wearout <= 100:
platte["verbraucht"] = 100 - int(wearout) # Proxmox zeigt, was noch übrig ist
if smart:
if (smart.get("type") == "text" or typ == "nvme") and smart.get("text"):
werte = aus_nvme_text(smart["text"])
platte.update({k: v for k, v in werte.items() if v is not None and k != "fehler"})
platte["fehler"] = werte["fehler"]
elif smart.get("attributes"):
platte.update(aus_ata_attributen(smart["attributes"]))
return platte
def aus_smartctl(d: dict, geraet: str, wo: str) -> dict:
"""Eine Platte aus `smartctl -j -a` (KI-Box)."""
n = d.get("nvme_smart_health_information_log") or {}
status = (d.get("smart_status") or {}).get("passed")
typ = "nvme" if n else ("ssd" if (d.get("rotation_rate") == 0) else "hdd")
platte = {"id": f"{wo}:{d.get('serial_number') or geraet}", "name": d.get("model_name") or geraet,
"wo": f"{wo} {geraet}", "typ": typ, "groesse": (d.get("user_capacity") or {}).get("bytes"),
"gesund": status if isinstance(status, bool) else None, "verbraucht": n.get("percentage_used"),
"reserve": n.get("available_spare"), "reserve_grenze": n.get("available_spare_threshold"),
"warnung": int(n.get("critical_warning") or 0), "temperatur": (d.get("temperature") or {}).get("current"),
"stunden": (d.get("power_on_time") or {}).get("hours") or n.get("power_on_hours"), "fehler": {}}
if n:
platte["fehler"] = {"medien": int(n.get("media_errors") or 0)}
else:
tabelle = [{"id": a.get("id"), "raw": str((a.get("raw") or {}).get("value", ""))}
for a in (d.get("ata_smart_attributes") or {}).get("table") or []]
platte.update(aus_ata_attributen(tabelle))
return platte
def bewerten(p: dict, basis: dict | None) -> tuple[str, list[str]]:
"""(Stufe, Sätze). basis = die Fehlerzähler, die als Ausgangslage gelten."""
stufe, saetze = "ok", []
rang = {"ok": 0, "gelb": 1, "rot": 2}
def schlimmer(neu: str, satz: str) -> None:
nonlocal stufe
saetze.append(satz)
if rang[neu] > rang[stufe]:
stufe = neu
if p.get("gesund") is False:
schlimmer("rot", "meldet sich selbst als defekt (SMART)")
if p.get("smart_warnung"):
schlimmer("gelb", "SMART meldet eine Warnung")
if p.get("warnung"):
schlimmer("rot", f"meldet eine kritische Warnung (0x{int(p['warnung']):02x})")
reserve, grenze = p.get("reserve"), p.get("reserve_grenze")
if isinstance(reserve, int) and isinstance(grenze, int) and grenze > 0 and reserve <= grenze:
schlimmer("rot", f"Ersatzblöcke fast aufgebraucht ({reserve} %)")
v = p.get("verbraucht")
if isinstance(v, (int, float)) and v >= VERBRAUCHT_ROT:
schlimmer("rot", f"zu {int(v)} % verbraucht — Ersatz einplanen")
elif isinstance(v, (int, float)) and v >= VERBRAUCHT_GELB:
schlimmer("gelb", f"zu {int(v)} % verbraucht")
t, heiss = p.get("temperatur"), HEISS.get(p.get("typ") or "", 60)
if isinstance(t, (int, float)) and t >= heiss:
schlimmer("gelb", f"{int(t)} °C warm (ab {heiss} °C zu heiß)")
for art, n in (p.get("fehler") or {}).items():
alt = (basis or {}).get(art, n)
if isinstance(n, int) and isinstance(alt, int) and n > alt:
schlimmer("gelb", f"{FEHLER_NAME.get(art, art)}: {alt} → {n}")
return stufe, saetze
# --- Ausgangslage je Instanz -------------------------------------------------------------------------------------
def _pfad() -> Path:
return einstellungen().daten_dir / "platten-basis.json"
def basis_lesen() -> dict[str, dict]:
try:
daten = json.loads(_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return daten if isinstance(daten, dict) else {}
def _basis_schreiben(daten: dict) -> None:
pfad = _pfad()
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp = pfad.with_suffix(".tmp")
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
tmp.replace(pfad)
def bewertet(platten: list[dict]) -> list[dict]:
"""Die Platten mit stufe und saetze. Neue Platten bekommen ihre Ausgangslage beim ersten Blick."""
basis = basis_lesen()
neu = {p["id"]: dict(p.get("fehler") or {}) for p in platten if p.get("id") and p["id"] not in basis}
if neu:
_basis_schreiben({**basis, **neu})
basis = {**basis, **neu}
ergebnis = []
for p in platten:
stufe, saetze = bewerten(p, basis.get(p.get("id")))
ergebnis.append({**p, "stufe": stufe, "saetze": saetze})
return ergebnis
def hinnehmen(pid: str, platten: list[dict]) -> dict:
"""„Zur Kenntnis genommen“: die heutigen Fehlerzähler werden die neue Ausgangslage."""
p = next((x for x in platten if x.get("id") == pid), None)
if p is None:
return {"ok": False, "detail": "Diese Platte ist gerade nicht zu sehen."}
_basis_schreiben({**basis_lesen(), pid: dict(p.get("fehler") or {})})
return {"ok": True, "text": f"Zur Kenntnis genommen: {p.get('name')}. Gemeldet wird erst wieder, wenn die Fehler "
"weiter wachsen."}
def befund_titel(p: dict) -> str:
return f"Platte {p.get('name')} ({p.get('wo')}): {p['saetze'][0]}" if p.get("saetze") else str(p.get("name"))