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:
co-authored by
Claude Opus 5.5
parent
5325544d89
commit
904598031a
@@ -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"))
|
||||
Reference in New Issue
Block a user