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"))
|
||||
@@ -156,6 +156,13 @@ def start() -> dict:
|
||||
}
|
||||
|
||||
|
||||
@router.get("/platten")
|
||||
def platten() -> dict:
|
||||
"""Plattengesundheit der KI-Box (smartctl, höchstens stündlich gelesen; seit 26.09.2026)."""
|
||||
from services import platten as box_platten
|
||||
return box_platten.lage()
|
||||
|
||||
|
||||
@router.get("/hinweise")
|
||||
def hinweise() -> dict:
|
||||
return waechter.lese_stand()
|
||||
|
||||
@@ -134,6 +134,33 @@ def hinweis_aktion(hinweis_id: str, aktion_id: str) -> dict:
|
||||
return _antwort(waechter.fuehre_aktion_aus(hinweis_id, aktion_id))
|
||||
|
||||
|
||||
@router.get("/platten")
|
||||
def platten() -> dict:
|
||||
"""Plattengesundheit: Proxmox-Host (Ausführer) und NAS (SNMP), bewertet (seit 26.09.2026)."""
|
||||
from services.homelab import platten as homelab_platten
|
||||
return homelab_platten.lage()
|
||||
|
||||
|
||||
@router.post("/einstellungen/nas-snmp")
|
||||
async def nas_snmp_setzen(request: Request) -> dict:
|
||||
"""{"community", "adresse"?}: erst das NAS fragen, dann speichern. Der Body wird selbst gelesen (keine 422 mit
|
||||
dem Zugangsnamen darin), die Abfrage läuft neben dem Ereignis-Takt."""
|
||||
from services.homelab import nas_snmp
|
||||
try:
|
||||
daten = json.loads(await request.body() or b"{}")
|
||||
except ValueError:
|
||||
daten = {}
|
||||
daten = daten if isinstance(daten, dict) else {}
|
||||
return await asyncio.to_thread(nas_snmp.setzen, str(daten.get("community") or ""), str(daten.get("adresse") or ""))
|
||||
|
||||
|
||||
@router.delete("/einstellungen/nas-snmp")
|
||||
def nas_snmp_loeschen() -> dict:
|
||||
from services.homelab import nas_snmp
|
||||
da = nas_snmp.zugang_loeschen()
|
||||
return {"ok": True, "text": "Der SNMP-Zugang des NAS ist gelöscht." if da else "Es war kein SNMP-Zugang hinterlegt."}
|
||||
|
||||
|
||||
@router.get("/netz")
|
||||
def netz() -> dict:
|
||||
"""Die Geräte im Heimnetz aus der Nachbartabelle des Proxmox-Hosts, neue zuerst (seit 26.09.2026)."""
|
||||
|
||||
@@ -17,7 +17,7 @@ import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from services.homelab import adguard, apps, arcane, inventar, kanal, karenz
|
||||
from services.homelab import adguard, apps, arcane, inventar, kanal, karenz, nas_snmp
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,6 +46,9 @@ def stand() -> dict:
|
||||
"echt_quelle": arcane.echt_quelle()},
|
||||
"adguard": {"url": adguard.url() or "", "zugang": "hinterlegt" if (z := adguard.zugang()) else "fehlt",
|
||||
"benutzer": z[0] if z else None},
|
||||
# Seit 26.09.2026: SNMP-Zugang des NAS für die Plattengesundheit — nie der Zugangsname selbst.
|
||||
"nas_snmp": {"zugang": "hinterlegt" if (n := nas_snmp.zugang()) else "fehlt",
|
||||
"adresse": (n or {}).get("adresse") or nas_snmp.nas_adresse()},
|
||||
"karenz_h": round(karenz.karenz_s() / 3600),
|
||||
"ausfuehrer": {"verbunden": bool(zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S),
|
||||
"zuletzt": zuletzt, "nur_lesen": bool(host.get("nur_lesen")),
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Die Platten des NAS über SNMP (Ausbauplan „Später“, Punkt 18, seit 26.09.2026).
|
||||
|
||||
User-Entscheid 25.09.2026: Der User schaltet am QNAP SNMP v1/v2c ein (Systemsteuerung → Netzwerk- und Dateidienste →
|
||||
SNMP) und trägt den Zugangsnamen („Community“) unter Einstellungen → Homelab ein. Vor dem Speichern fragt dieser Teil
|
||||
das NAS einmal damit; antwortet es nicht, wird nichts gespeichert. Der Zugangsname liegt in <Datenordner>/nas-snmp.json
|
||||
(0600) und erscheint nie wieder in einer Antwort.
|
||||
|
||||
Ein kleiner SNMPv2c-Leser (GetBulk über UDP 161) statt einer neuen Abhängigkeit. Gelesen wird die Plattentabelle der
|
||||
QNAP-MIB: zuerst die ältere (NAS-MIB, hdTable — die meisten QTS-Versionen liefern sie noch), sonst die neuere
|
||||
(QTS-MIB, diskTable), deren Spalten nach Inhalt erkannt werden. Ein falscher Zugangsname fällt bei SNMP v2c nur als
|
||||
Schweigen auf: Das NAS antwortet dann gar nicht.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
from services.homelab import kanal
|
||||
|
||||
PORT = 161
|
||||
ZEITLIMIT_S = 3.0
|
||||
CACHE_S = 600
|
||||
SYS_DESCR = "1.3.6.1.2.1.1.1"
|
||||
HD_TABELLE = "1.3.6.1.4.1.24681.1.2.11.1" # NAS-MIB hdTable: 2 Name, 3 Temperatur, 4 Status, 5 Modell, 7 SMART
|
||||
DISK_TABELLE = "1.3.6.1.4.1.24681.1.4.1.1.1.1.5.2.1" # QTS-MIB diskTable (neuere Firmware)
|
||||
GESUND = {"good", "normal", "ok"}
|
||||
KRANK = {"abnormal", "bad", "error"}
|
||||
_lock = threading.Lock()
|
||||
_cache: dict[str, tuple[float, dict]] = {}
|
||||
|
||||
|
||||
class SnmpFehler(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# --- BER: gerade genug für GetBulk und die Antwort -----------------------------------------------------------------
|
||||
|
||||
def _laenge(n: int) -> bytes:
|
||||
if n < 0x80:
|
||||
return bytes([n])
|
||||
b = n.to_bytes((n.bit_length() + 7) // 8, "big")
|
||||
return bytes([0x80 | len(b)]) + b
|
||||
|
||||
|
||||
def _tlv(tag: int, inhalt: bytes) -> bytes:
|
||||
return bytes([tag]) + _laenge(len(inhalt)) + inhalt
|
||||
|
||||
|
||||
def _int(n: int) -> bytes:
|
||||
return _tlv(0x02, n.to_bytes(max(1, (n.bit_length() + 8) // 8), "big", signed=True))
|
||||
|
||||
|
||||
def _oid(oid: str) -> bytes:
|
||||
teile = [int(t) for t in oid.strip(".").split(".")]
|
||||
inhalt = bytearray([40 * teile[0] + teile[1]])
|
||||
for t in teile[2:]:
|
||||
stuecke = [t & 0x7F]
|
||||
t >>= 7
|
||||
while t:
|
||||
stuecke.insert(0, 0x80 | (t & 0x7F))
|
||||
t >>= 7
|
||||
inhalt += bytes(stuecke)
|
||||
return _tlv(0x06, bytes(inhalt))
|
||||
|
||||
|
||||
def anfrage(community: str, oid: str, anfrage_id: int, wiederholungen: int = 25) -> bytes:
|
||||
"""Eine SNMPv2c-GetBulk-Anfrage ab oid."""
|
||||
bindung = _tlv(0x30, _oid(oid) + b"\x05\x00")
|
||||
pdu = _tlv(0xA5, _int(anfrage_id) + _int(0) + _int(wiederholungen) + _tlv(0x30, bindung))
|
||||
return _tlv(0x30, _int(1) + _tlv(0x04, community.encode("utf-8")) + pdu)
|
||||
|
||||
|
||||
def _lies(daten: bytes, pos: int) -> tuple[int, bytes, int]:
|
||||
tag = daten[pos]
|
||||
laenge = daten[pos + 1]
|
||||
pos += 2
|
||||
if laenge & 0x80:
|
||||
n = laenge & 0x7F
|
||||
laenge = int.from_bytes(daten[pos:pos + n], "big")
|
||||
pos += n
|
||||
return tag, daten[pos:pos + laenge], pos + laenge
|
||||
|
||||
|
||||
def _oid_text(b: bytes) -> str:
|
||||
teile = [b[0] // 40, b[0] % 40]
|
||||
wert = 0
|
||||
for x in b[1:]:
|
||||
wert = (wert << 7) | (x & 0x7F)
|
||||
if not x & 0x80:
|
||||
teile.append(wert)
|
||||
wert = 0
|
||||
return ".".join(str(t) for t in teile)
|
||||
|
||||
|
||||
def _wert(tag: int, inhalt: bytes) -> object:
|
||||
if tag == 0x02:
|
||||
return int.from_bytes(inhalt, "big", signed=True)
|
||||
if tag == 0x04:
|
||||
return inhalt.decode("utf-8", "replace").strip("\x00").strip()
|
||||
if tag in (0x41, 0x42, 0x43, 0x46): # Counter32, Gauge32, TimeTicks, Counter64
|
||||
return int.from_bytes(inhalt, "big")
|
||||
if tag == 0x06:
|
||||
return _oid_text(inhalt)
|
||||
if tag == 0x40:
|
||||
return ".".join(str(b) for b in inhalt)
|
||||
return None # NULL, noSuchObject, noSuchInstance, endOfMibView
|
||||
|
||||
|
||||
def antwort(daten: bytes) -> tuple[int, int, list[tuple[str, object, int]]]:
|
||||
"""(Anfrage-Nr., Fehlerstatus, [(OID, Wert, Typ)]) aus einer Response-PDU."""
|
||||
try:
|
||||
_, nachricht, _ = _lies(daten, 0)
|
||||
_, _, pos = _lies(nachricht, 0) # Version
|
||||
_, _, pos = _lies(nachricht, pos) # Community
|
||||
tag, pdu, _ = _lies(nachricht, pos)
|
||||
if tag != 0xA2:
|
||||
raise SnmpFehler("keine Antwort-PDU")
|
||||
_, nr, p = _lies(pdu, 0)
|
||||
_, fehler, p = _lies(pdu, p)
|
||||
_, _, p = _lies(pdu, p)
|
||||
_, bindungen, _ = _lies(pdu, p)
|
||||
werte, q = [], 0
|
||||
while q < len(bindungen):
|
||||
_, bindung, q = _lies(bindungen, q)
|
||||
_, oid, r = _lies(bindung, 0)
|
||||
wtag, winhalt, _ = _lies(bindung, r)
|
||||
werte.append((_oid_text(oid), _wert(wtag, winhalt), wtag))
|
||||
except (IndexError, ValueError) as exc:
|
||||
raise SnmpFehler("unlesbare Antwort") from exc
|
||||
return int.from_bytes(nr, "big", signed=True), int.from_bytes(fehler, "big"), werte
|
||||
|
||||
|
||||
def walk(adresse: str, community: str, basis: str, zeitlimit: float | None = None, grenze: int = 2000,
|
||||
port: int | None = None) -> list:
|
||||
"""Alle (OID, Wert) unter basis. SnmpFehler bei Schweigen (falscher Zugangsname, SNMP aus) oder Fehlern."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.settimeout(zeitlimit or ZEITLIMIT_S)
|
||||
ergebnis: list[tuple[str, object]] = []
|
||||
oid, nr = basis, random.randint(1, 1_000_000)
|
||||
try:
|
||||
while len(ergebnis) < grenze:
|
||||
nr += 1
|
||||
sock.sendto(anfrage(community, oid, nr), (adresse, port or PORT))
|
||||
try:
|
||||
daten, _ = sock.recvfrom(65535)
|
||||
except TimeoutError as exc:
|
||||
raise SnmpFehler("keine Antwort") from exc
|
||||
rnr, fehler, werte = antwort(daten)
|
||||
if rnr != nr:
|
||||
continue
|
||||
if fehler:
|
||||
raise SnmpFehler(f"Fehlerstatus {fehler}")
|
||||
fertig = not werte
|
||||
for o, w, tag in werte:
|
||||
if tag == 0x82 or not o.startswith(basis + "."):
|
||||
fertig = True
|
||||
break
|
||||
ergebnis.append((o, w))
|
||||
if fertig:
|
||||
break
|
||||
oid = werte[-1][0]
|
||||
except OSError as exc:
|
||||
raise SnmpFehler(f"Netzfehler {exc.__class__.__name__}") from exc
|
||||
finally:
|
||||
sock.close()
|
||||
return ergebnis
|
||||
|
||||
|
||||
# --- Plattentabelle der QNAP-MIB ----------------------------------------------------------------------------------
|
||||
|
||||
def _spalten(werte: list, basis: str) -> dict[str, dict[int, object]]:
|
||||
"""{Zeile: {Spalte: Wert}} aus OIDs der Form <basis>.<spalte>.<zeile>."""
|
||||
zeilen: dict[str, dict[int, object]] = {}
|
||||
for oid, wert in werte:
|
||||
rest = oid[len(basis) + 1:].split(".")
|
||||
if len(rest) >= 2 and rest[0].isdigit():
|
||||
zeilen.setdefault(".".join(rest[1:]), {})[int(rest[0])] = wert
|
||||
return zeilen
|
||||
|
||||
|
||||
def platte(nr: str, name: object, modell: object, temperatur: object, smart: object, status: object = None) -> dict:
|
||||
s = str(smart or "").strip().lower()
|
||||
temp = re.search(r"-?\d+", str(temperatur)) if temperatur is not None else None
|
||||
return {"id": f"nas:{nr}:{modell or name}", "name": str(modell or name or f"Platte {nr}").strip(),
|
||||
"wo": f"NAS {name or nr}", "typ": "hdd", "groesse": None,
|
||||
"gesund": True if s in GESUND else False if s in KRANK else None,
|
||||
"smart_warnung": s == "warning", "smart": str(smart or "") or None,
|
||||
"verbraucht": None, "reserve": None, "reserve_grenze": None, "warnung": 0,
|
||||
"temperatur": int(temp.group(0)) if temp else None, "stunden": None,
|
||||
"fehler": {}, "status": status}
|
||||
|
||||
|
||||
def platten_aus_hd(werte: list) -> list[dict]:
|
||||
"""NAS-MIB hdTable: 2 Name, 3 Temperatur („35 C/95 F“), 4 Status (0 = bereit), 5 Modell, 7 SMART („GOOD“)."""
|
||||
ergebnis = []
|
||||
for nr, z in sorted(_spalten(werte, HD_TABELLE).items()):
|
||||
if z.get(4) == -5: # kein Laufwerk im Schacht
|
||||
continue
|
||||
ergebnis.append(platte(nr, z.get(2), z.get(5), z.get(3), z.get(7), z.get(4)))
|
||||
return ergebnis
|
||||
|
||||
|
||||
def platten_aus_disk(werte: list) -> list[dict]:
|
||||
"""QTS-MIB diskTable, Spalten nach Inhalt: SMART-Wort, Temperatur (Zahl zwischen 5 und 90), längster Text = Modell."""
|
||||
ergebnis = []
|
||||
for nr, z in sorted(_spalten(werte, DISK_TABELLE).items()):
|
||||
texte = {k: v for k, v in z.items() if isinstance(v, str) and v}
|
||||
smart = next((v for v in texte.values() if v.strip().lower() in GESUND | KRANK | {"warning"}), None)
|
||||
temperatur = next((v for k, v in z.items() if isinstance(v, int) and 5 <= v <= 90 and k > 3), None)
|
||||
modell = max((v for v in texte.values() if v != smart), key=len, default=None)
|
||||
ergebnis.append(platte(nr, f"Platte {nr}", modell, temperatur, smart))
|
||||
return ergebnis
|
||||
|
||||
|
||||
# --- Zugang, Abfrage, Oberfläche ---------------------------------------------------------------------------------
|
||||
|
||||
def _pfad() -> Path:
|
||||
return einstellungen().daten_dir / "nas-snmp.json"
|
||||
|
||||
|
||||
def zugang() -> dict | None:
|
||||
"""{"community", "adresse"} oder None."""
|
||||
try:
|
||||
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
if not isinstance(daten, dict) or not isinstance(daten.get("community"), str) or not daten["community"]:
|
||||
return None
|
||||
return {"community": daten["community"], "adresse": str(daten.get("adresse") or "") or None}
|
||||
|
||||
|
||||
def zugang_speichern(community: str, adresse: str | None) -> None:
|
||||
pfad = _pfad()
|
||||
tmp = pfad.with_name(pfad.name + ".tmp")
|
||||
with _lock:
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp.unlink(missing_ok=True)
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump({"community": community, "adresse": adresse}, f)
|
||||
os.replace(tmp, pfad)
|
||||
_cache.clear()
|
||||
|
||||
|
||||
def zugang_loeschen() -> bool:
|
||||
with _lock:
|
||||
pfad = _pfad()
|
||||
da = pfad.exists()
|
||||
pfad.unlink(missing_ok=True)
|
||||
_cache.clear()
|
||||
return da
|
||||
|
||||
|
||||
def nas_adresse() -> str | None:
|
||||
"""Das NAS aus dem Bericht (die Einbindungen des Proxmox-Hosts), sonst None."""
|
||||
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
|
||||
for m in host.get("netzlaufwerke") or []:
|
||||
if m.get("server"):
|
||||
return str(m["server"])
|
||||
return None
|
||||
|
||||
|
||||
def lesen(adresse: str, community: str) -> dict:
|
||||
"""{"ok", "system", "platten"} oder {"ok": False, "detail"} — ohne den Zugangsnamen."""
|
||||
try:
|
||||
system = walk(adresse, community, SYS_DESCR, grenze=1)
|
||||
platten = platten_aus_hd(walk(adresse, community, HD_TABELLE))
|
||||
if not platten:
|
||||
platten = platten_aus_disk(walk(adresse, community, DISK_TABELLE))
|
||||
except SnmpFehler as exc:
|
||||
if str(exc) == "keine Antwort":
|
||||
return {"ok": False, "detail": f"Das NAS ({adresse}) antwortet nicht über SNMP. Ist SNMP v1/v2c an, und "
|
||||
"stimmt der Zugangsname (Community)?"}
|
||||
return {"ok": False, "detail": f"SNMP-Abfrage an {adresse} gescheitert: {exc}."}
|
||||
return {"ok": True, "system": str(system[0][1]) if system else None, "platten": platten}
|
||||
|
||||
|
||||
def stand() -> dict:
|
||||
"""Für Oberfläche und Wächter: höchstens alle CACHE_S neu gefragt. {"eingerichtet", "ok", "platten", "detail"}."""
|
||||
z = zugang()
|
||||
if not z:
|
||||
return {"eingerichtet": False, "ok": False, "platten": [], "detail": None}
|
||||
adresse = z["adresse"] or nas_adresse()
|
||||
if not adresse:
|
||||
return {"eingerichtet": True, "ok": False, "platten": [], "detail": "Die Adresse des NAS ist unbekannt."}
|
||||
jetzt = time.time()
|
||||
with _lock:
|
||||
if (eintrag := _cache.get(adresse)) and jetzt - eintrag[0] < CACHE_S:
|
||||
return eintrag[1]
|
||||
ergebnis = {"eingerichtet": True, **lesen(adresse, z["community"])}
|
||||
ergebnis.setdefault("platten", [])
|
||||
with _lock:
|
||||
_cache[adresse] = (jetzt, ergebnis)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def setzen(community: str, adresse: str | None) -> dict:
|
||||
"""POST /api/homelab/einstellungen/nas-snmp: erst das NAS fragen, dann speichern."""
|
||||
community, adresse = (community or "").strip(), (adresse or "").strip() or None
|
||||
if not community or len(community) > 200 or (adresse and not re.fullmatch(r"[\w.:-]{1,253}", adresse)):
|
||||
return {"ok": False, "detail": "Zugangsname (Community) eintragen, die Adresse nur, wenn sie abweicht. Nichts "
|
||||
"gespeichert."}
|
||||
ziel = adresse or nas_adresse()
|
||||
if not ziel:
|
||||
return {"ok": False, "detail": "Die Adresse des NAS ist unbekannt — bitte mit eintragen. Nichts gespeichert."}
|
||||
ergebnis = lesen(ziel, community)
|
||||
if not ergebnis["ok"]:
|
||||
return {"ok": False, "detail": ergebnis["detail"] + " Nichts gespeichert."}
|
||||
try:
|
||||
zugang_speichern(community, adresse)
|
||||
except OSError as exc:
|
||||
return {"ok": False, "detail": f"Das NAS antwortet, aber der Zugang ließ sich nicht speichern "
|
||||
f"({exc.__class__.__name__})."}
|
||||
platten = ergebnis["platten"]
|
||||
gesund = sum(1 for p in platten if p["gesund"])
|
||||
return {"ok": True, "text": f"Das NAS antwortet ({(ergebnis.get('system') or 'SNMP')[:80]}): "
|
||||
+ (f"{len(platten)} Platten, davon {gesund} gesund." if platten
|
||||
else "Die Plattentabelle ist leer — die Firmware nennt sie vielleicht anders.")}
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Plattengesundheit im Homelab (Ausbauplan „Später“, Punkt 18, seit 26.09.2026): die Platten des Proxmox-Hosts
|
||||
(Ausführer, stündlich im Bericht) und die des NAS (SNMP, services/homelab/nas_snmp.py), bewertet mit kern/platten.py.
|
||||
Der Wächter meldet nur, was schlecht ist oder schlechter wird; „Zur Kenntnis genommen“ macht die heutigen
|
||||
Fehlerzähler zur neuen Ausgangslage.
|
||||
"""
|
||||
|
||||
from kern import platten as kern_platten
|
||||
|
||||
from services.homelab import kanal, nas_snmp
|
||||
|
||||
|
||||
def host_platten() -> list[dict]:
|
||||
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
|
||||
return [kern_platten.aus_proxmox(e.get("disk") or {}, e.get("smart")) for e in host.get("platten") or []
|
||||
if isinstance(e, dict) and isinstance(e.get("disk"), dict)]
|
||||
|
||||
|
||||
def lage() -> dict:
|
||||
"""Für GET /api/homelab/platten."""
|
||||
nas = nas_snmp.stand()
|
||||
platten = kern_platten.bewertet(host_platten() + list(nas.get("platten") or []))
|
||||
return {"platten": platten, "nas": {"eingerichtet": nas["eingerichtet"], "ok": nas.get("ok", False),
|
||||
"detail": nas.get("detail"), "system": nas.get("system")}}
|
||||
|
||||
|
||||
def hinnehmen(pid: str) -> dict:
|
||||
return kern_platten.hinnehmen(pid, host_platten() + list(nas_snmp.stand().get("platten") or []))
|
||||
|
||||
|
||||
def pruefe_platten_gesundheit() -> list:
|
||||
"""Für den Wächter (Rolle homelab)."""
|
||||
from services.waechter import Befund, _aktion
|
||||
|
||||
befunde = []
|
||||
for p in lage()["platten"]:
|
||||
if p["stufe"] == "ok":
|
||||
continue
|
||||
fehler = any("→" in s for s in p["saetze"])
|
||||
befunde.append(Befund(
|
||||
id=f"platte:{p['id']}", stufe=p["stufe"], titel=kern_platten.befund_titel(p),
|
||||
text="; ".join(p["saetze"]) + ". Die Daten darauf sichern und die Platte bald ersetzen, wenn es weiter "
|
||||
"schlechter wird.",
|
||||
quelle=p["id"].split(":")[0] if p["id"].startswith("nas:") else "pve",
|
||||
aktionen=[_aktion("hinnehmen", "Zur Kenntnis genommen", platte=p["id"])] if fehler else [],
|
||||
sofort=True))
|
||||
return befunde
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Plattengesundheit der KI-Box (Ausbauplan „Später“, Punkt 18, seit 26.09.2026): smartctl (Paket smartmontools,
|
||||
seit 26.09.2026 auf der Box) je Platte aus lsblk, höchstens stündlich, bewertet mit kern/platten.py. smartctl braucht
|
||||
root (sudo -n); fehlt es oder verweigert sudo, gibt es schlicht keine Werte — nie einen Absturz (Windows, Tests).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
from kern import platten as kern_platten
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CACHE_S = 3600
|
||||
_lock = threading.Lock()
|
||||
_cache: dict = {"zeit": 0.0, "platten": None}
|
||||
|
||||
|
||||
def _laufen(befehl: list[str], zeitlimit: int = 30) -> tuple[int, str]:
|
||||
try:
|
||||
r = subprocess.run(befehl, capture_output=True, text=True, timeout=zeitlimit)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return 127, ""
|
||||
return r.returncode, r.stdout
|
||||
|
||||
|
||||
def geraete() -> list[str]:
|
||||
code, aus = _laufen(["lsblk", "-J", "-d", "-o", "NAME,TYPE"], 10)
|
||||
try:
|
||||
return [f"/dev/{d['name']}" for d in json.loads(aus).get("blockdevices") or [] if d.get("type") == "disk"
|
||||
and not str(d.get("name", "")).startswith(("zram", "loop"))] if code == 0 else []
|
||||
except (ValueError, AttributeError):
|
||||
return []
|
||||
|
||||
|
||||
def lesen() -> list[dict]:
|
||||
if not shutil.which("sudo"): # Windows, Tests ohne sudo: keine Werte
|
||||
return []
|
||||
ergebnis = []
|
||||
for geraet in geraete():
|
||||
_, aus = _laufen(["sudo", "-n", "smartctl", "-j", "-a", geraet])
|
||||
try:
|
||||
daten = json.loads(aus)
|
||||
except ValueError:
|
||||
continue
|
||||
# smartctl setzt Bits im Exit-Code auch für „Fehler im Protokoll“ — gelesen ist gelesen.
|
||||
if not isinstance(daten, dict) or not (daten.get("model_name") or daten.get("nvme_smart_health_information_log")):
|
||||
continue
|
||||
ergebnis.append(kern_platten.aus_smartctl(daten, geraet, "KI-Box"))
|
||||
return ergebnis
|
||||
|
||||
|
||||
def platten() -> list[dict]:
|
||||
with _lock:
|
||||
if _cache["platten"] is not None and time.time() - _cache["zeit"] < CACHE_S:
|
||||
return _cache["platten"]
|
||||
werte = lesen()
|
||||
with _lock:
|
||||
_cache.update(zeit=time.time(), platten=werte)
|
||||
return werte
|
||||
|
||||
|
||||
def lage() -> dict:
|
||||
"""Für GET /api/platten."""
|
||||
return {"platten": kern_platten.bewertet(platten())}
|
||||
|
||||
|
||||
def hinnehmen(pid: str) -> dict:
|
||||
return kern_platten.hinnehmen(pid, platten())
|
||||
|
||||
|
||||
def pruefe_platten_gesundheit() -> list:
|
||||
"""Für den Wächter (Rolle box)."""
|
||||
from services.waechter import Befund, _aktion
|
||||
|
||||
befunde = []
|
||||
for p in lage()["platten"]:
|
||||
if p["stufe"] == "ok":
|
||||
continue
|
||||
fehler = any("→" in s for s in p["saetze"])
|
||||
befunde.append(Befund(
|
||||
id=f"platte:{p['id']}", stufe=p["stufe"], titel=kern_platten.befund_titel(p),
|
||||
text="; ".join(p["saetze"]) + ". Wichtiges sichern; wird es schlechter, die Platte ersetzen.",
|
||||
quelle="platte", aktionen=[_aktion("hinnehmen", "Zur Kenntnis genommen", platte=p["id"])] if fehler else [],
|
||||
sofort=True))
|
||||
return befunde
|
||||
@@ -640,6 +640,9 @@ PRUEFUNGEN += (__import__("services.homelab.speicher").homelab.speicher.pruefe_s
|
||||
PRUEFUNGEN += (__import__("services.homelab.zertifikate").homelab.zertifikate.pruefe_zertifikate,) if ROLLE == "homelab" else ()
|
||||
# Docker-Dienste unter Arcane (seit 25.09.2026): gelb, wenn einer ungesund ist oder ständig neu startet.
|
||||
PRUEFUNGEN += (__import__("services.homelab.docker").homelab.docker.pruefe_docker,) if ROLLE == "homelab" else ()
|
||||
# Plattengesundheit (seit 26.09.2026): Host und NAS im Homelab, die NVMe der KI-Box auf der Box.
|
||||
PRUEFUNGEN += ((__import__("services.homelab.platten").homelab.platten.pruefe_platten_gesundheit,) if ROLLE == "homelab"
|
||||
else (__import__("services.platten").platten.pruefe_platten_gesundheit,))
|
||||
# Neue Geräte im Heimnetz (seit 26.09.2026): gelb mit „Ist bekannt“, bis jemand ihn drückt.
|
||||
PRUEFUNGEN += (__import__("services.homelab.netzgeraete").homelab.netzgeraete.pruefe_netzgeraete,) \
|
||||
if ROLLE == "homelab" else ()
|
||||
@@ -923,6 +926,12 @@ def fuehre_aktion_aus(hinweis_id: str, aktion_id: str) -> dict:
|
||||
if not update_verlauf.freigeben(aktion["baustein"]):
|
||||
return {"ok": False, "detail": "Der Baustein war schon freigegeben."}
|
||||
return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT}
|
||||
if aktion_id == "hinnehmen": # Plattenfehler zur Kenntnis genommen (seit 26.09.2026)
|
||||
if ROLLE == "homelab":
|
||||
from services.homelab import platten as homelab_platten
|
||||
return homelab_platten.hinnehmen(str(aktion.get("platte") or ""))
|
||||
from services import platten
|
||||
return platten.hinnehmen(str(aktion.get("platte") or ""))
|
||||
if aktion_id == "bekannt": # neues Gerät im Heimnetz (seit 26.09.2026)
|
||||
from services.homelab import netzgeraete
|
||||
return netzgeraete.bestaetigen(str(aktion.get("mac") or ""))
|
||||
|
||||
@@ -97,6 +97,7 @@ def test_stand_von_anfang_an(daten, monkeypatch):
|
||||
"arcane": {"url": "http://192.168.178.28:3552", "schluessel": "fehlt", "echt": False,
|
||||
"echt_quelle": "standard"},
|
||||
"adguard": {"url": "http://192.168.178.100:8080", "zugang": "fehlt", "benutzer": None},
|
||||
"nas_snmp": {"zugang": "fehlt", "adresse": None},
|
||||
"karenz_h": 48,
|
||||
"ausfuehrer": {"verbunden": True, "zuletzt": stand["ausfuehrer"]["zuletzt"], "nur_lesen": False,
|
||||
"sicherung_speicher": None}}
|
||||
|
||||
@@ -222,7 +222,8 @@ print(json.dumps({"pruefungen": [p.__name__ for p in waechter.PRUEFUNGEN], "date
|
||||
""")
|
||||
assert ergebnis["pruefungen"] == ["pruefe_platte", "pruefe_partner", "pruefe_ausfuehrer", "pruefe_gaeste",
|
||||
"pruefe_gast_platten", "pruefe_paketlisten", "pruefe_host", "pruefe_speicher",
|
||||
"pruefe_zertifikate", "pruefe_docker"]
|
||||
"pruefe_zertifikate", "pruefe_docker", "pruefe_platten_gesundheit",
|
||||
"pruefe_netzgeraete"]
|
||||
|
||||
|
||||
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Plattengesundheit (Ausbauplan „Später“, Punkt 18, 26.09.2026): Proxmox-Host (pvesh), KI-Box (smartctl) und NAS
|
||||
(SNMP) in einer Form, gemeldet wird nur, was schlecht ist oder schlechter wird. Die Werte stammen von den echten
|
||||
Platten am 26.09. (PELADN 512GB im Host, YMTC PC41Q-2TB in der Box)."""
|
||||
|
||||
import socket
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
from kern import einstellungen as einstellungen_mod
|
||||
from kern import platten as kp
|
||||
from services.homelab import nas_snmp
|
||||
|
||||
NVME_TEXT = """
|
||||
SMART/Health Information (NVMe Log 0x02, NSID 0xffffffff)
|
||||
Critical Warning: 0x00
|
||||
Temperature: 41 Celsius
|
||||
Available Spare: 100%
|
||||
Available Spare Threshold: 1%
|
||||
Percentage Used: 2%
|
||||
Power On Hours: 9,992
|
||||
Unsafe Shutdowns: 6
|
||||
Media and Data Integrity Errors: 0
|
||||
Error Information Log Entries: 0
|
||||
"""
|
||||
HOST_DISK = {"devpath": "/dev/nvme0n1", "model": "PELADN 512GB", "serial": "2024110610000123", "type": "nvme",
|
||||
"size": 512110190592, "health": "PASSED", "wearout": 98}
|
||||
BOX_JSON = {"model_name": "YMTC PC41Q-2TB-B", "serial_number": "S1", "smart_status": {"passed": True},
|
||||
"user_capacity": {"bytes": 2048408248320}, "temperature": {"current": 35},
|
||||
"power_on_time": {"hours": 2380},
|
||||
"nvme_smart_health_information_log": {"critical_warning": 0, "temperature": 35, "available_spare": 100,
|
||||
"available_spare_threshold": 10, "percentage_used": 0,
|
||||
"media_errors": 0, "power_on_hours": 2380}}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def daten(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
|
||||
einstellungen_mod.einstellungen.cache_clear()
|
||||
yield tmp_path
|
||||
einstellungen_mod.einstellungen.cache_clear()
|
||||
|
||||
|
||||
def test_host_platte_aus_proxmox():
|
||||
p = kp.aus_proxmox(HOST_DISK, {"health": "PASSED", "type": "text", "text": NVME_TEXT})
|
||||
assert (p["id"], p["name"], p["wo"], p["typ"]) == ("pve:2024110610000123", "PELADN 512GB", "Proxmox-Host /dev/nvme0n1",
|
||||
"nvme")
|
||||
assert (p["gesund"], p["verbraucht"], p["temperatur"], p["reserve"], p["reserve_grenze"], p["stunden"]) == (
|
||||
True, 2, 41, 100, 1, 9992)
|
||||
assert p["fehler"] == {"medien": 0} and kp.bewerten(p, None) == ("ok", [])
|
||||
|
||||
|
||||
def test_sata_attribute():
|
||||
werte = kp.aus_ata_attributen([{"id": 5, "raw": "8"}, {"id": 197, "raw": "0"}, {"id": 194, "raw": "36 (Min/Max 20/45)"},
|
||||
{"id": "x", "raw": "1"}])
|
||||
assert werte == {"fehler": {"umgelagert": 8, "ausstehend": 0}, "temperatur": 36}
|
||||
|
||||
|
||||
def test_box_platte_aus_smartctl():
|
||||
p = kp.aus_smartctl(BOX_JSON, "/dev/nvme0n1", "KI-Box")
|
||||
assert (p["id"], p["wo"], p["typ"], p["verbraucht"], p["temperatur"], p["stunden"]) == (
|
||||
"KI-Box:S1", "KI-Box /dev/nvme0n1", "nvme", 0, 35, 2380)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("aenderung, stufe, satz", [
|
||||
({"gesund": False}, "rot", "meldet sich selbst als defekt (SMART)"),
|
||||
({"warnung": 4}, "rot", "meldet eine kritische Warnung (0x04)"),
|
||||
({"reserve": 9, "reserve_grenze": 10}, "rot", "Ersatzblöcke fast aufgebraucht (9 %)"),
|
||||
({"verbraucht": 85}, "gelb", "zu 85 % verbraucht"),
|
||||
({"verbraucht": 93}, "rot", "zu 93 % verbraucht — Ersatz einplanen"),
|
||||
({"temperatur": 72}, "gelb", "72 °C warm (ab 70 °C zu heiß)"),
|
||||
({"fehler": {"medien": 3}}, "gelb", "Medienfehler: 0 → 3"),
|
||||
({"smart_warnung": True}, "gelb", "SMART meldet eine Warnung"),
|
||||
])
|
||||
def test_bewerten(aenderung, stufe, satz):
|
||||
p = {**kp.aus_smartctl(BOX_JSON, "/dev/nvme0n1", "KI-Box"), **aenderung}
|
||||
assert kp.bewerten(p, {"medien": 0}) == (stufe, [satz])
|
||||
|
||||
|
||||
def test_nur_was_schlechter_wird(daten):
|
||||
alt = {**kp.aus_smartctl(BOX_JSON, "/dev/nvme0n1", "KI-Box"), "fehler": {"medien": 5}}
|
||||
assert kp.bewertet([alt])[0]["stufe"] == "ok" # beim ersten Blick schon da: Ausgangslage
|
||||
schlechter = {**alt, "fehler": {"medien": 7}}
|
||||
(p,) = kp.bewertet([schlechter])
|
||||
assert (p["stufe"], p["saetze"]) == ("gelb", ["Medienfehler: 5 → 7"])
|
||||
assert kp.befund_titel(p) == "Platte YMTC PC41Q-2TB-B (KI-Box /dev/nvme0n1): Medienfehler: 5 → 7"
|
||||
assert kp.hinnehmen(p["id"], [schlechter])["ok"]
|
||||
assert kp.bewertet([schlechter])[0]["stufe"] == "ok"
|
||||
assert not kp.hinnehmen("gibt-es-nicht", [schlechter])["ok"]
|
||||
|
||||
|
||||
# --- NAS über SNMP ----------------------------------------------------------------------------------------------
|
||||
|
||||
def _antwort_pdu(anfrage: bytes, zeilen: list[tuple[str, object]], community: str) -> bytes:
|
||||
"""Eine Response-PDU mit den Werten ab der erfragten OID (wie ein NAS, das GetBulk beantwortet)."""
|
||||
_, nachricht, _ = nas_snmp._lies(anfrage, 0)
|
||||
_, _, pos = nas_snmp._lies(nachricht, 0)
|
||||
_, gemeinschaft, pos = nas_snmp._lies(nachricht, pos)
|
||||
if gemeinschaft.decode() != community:
|
||||
return b"" # falscher Zugangsname: SNMP schweigt
|
||||
_, pdu, _ = nas_snmp._lies(nachricht, pos)
|
||||
_, nr, p = nas_snmp._lies(pdu, 0)
|
||||
for _ in range(2):
|
||||
_, _, p = nas_snmp._lies(pdu, p)
|
||||
_, bindungen, _ = nas_snmp._lies(pdu, p)
|
||||
_, bindung, _ = nas_snmp._lies(bindungen, 0)
|
||||
_, oid, _ = nas_snmp._lies(bindung, 0)
|
||||
ab = nas_snmp._oid_text(oid)
|
||||
|
||||
def schluessel(o: str) -> tuple:
|
||||
return tuple(int(t) for t in o.split("."))
|
||||
folgende = sorted((z for z in zeilen if schluessel(z[0]) > schluessel(ab)), key=lambda z: schluessel(z[0]))[:25]
|
||||
teile = b""
|
||||
for o, w in folgende:
|
||||
wert = nas_snmp._tlv(0x04, w.encode()) if isinstance(w, str) else nas_snmp._int(int(w))
|
||||
teile += nas_snmp._tlv(0x30, nas_snmp._oid(o) + wert)
|
||||
if len(folgende) < 25:
|
||||
teile += nas_snmp._tlv(0x30, nas_snmp._oid(ab if not folgende else folgende[-1][0]) + b"\x82\x00")
|
||||
antwort = nas_snmp._tlv(0xA2, nas_snmp._tlv(0x02, nr) + nas_snmp._int(0) + nas_snmp._int(0)
|
||||
+ nas_snmp._tlv(0x30, teile))
|
||||
return nas_snmp._tlv(0x30, nas_snmp._int(1) + nas_snmp._tlv(0x04, gemeinschaft) + antwort)
|
||||
|
||||
|
||||
HD = nas_snmp.HD_TABELLE
|
||||
QNAP = [(f"{nas_snmp.SYS_DESCR}.0", "Linux TS-453D 5.10.60-qnap"),
|
||||
(f"{HD}.1.1", 1), (f"{HD}.1.2", 2), (f"{HD}.1.3", 3),
|
||||
(f"{HD}.2.1", "HDD1"), (f"{HD}.2.2", "HDD2"), (f"{HD}.2.3", "HDD3"),
|
||||
(f"{HD}.3.1", "34 C/93 F"), (f"{HD}.3.2", "36 C/96 F"), (f"{HD}.3.3", "--"),
|
||||
(f"{HD}.4.1", 0), (f"{HD}.4.2", 0), (f"{HD}.4.3", -5),
|
||||
(f"{HD}.5.1", "WDC WD40EFRX"), (f"{HD}.5.2", "ST4000VN008"), (f"{HD}.5.3", "--"),
|
||||
(f"{HD}.7.1", "GOOD"), (f"{HD}.7.2", "Abnormal"), (f"{HD}.7.3", "--")]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nas(monkeypatch):
|
||||
"""Ein nachgestelltes NAS auf einem freien UDP-Port von 127.0.0.1."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.settimeout(0.2)
|
||||
laeuft = threading.Event()
|
||||
laeuft.set()
|
||||
|
||||
def dienen():
|
||||
while laeuft.is_set():
|
||||
try:
|
||||
anfrage, absender = sock.recvfrom(65535)
|
||||
except (TimeoutError, OSError):
|
||||
continue
|
||||
if antwort := _antwort_pdu(anfrage, QNAP, "geheim-lesen"):
|
||||
sock.sendto(antwort, absender)
|
||||
|
||||
faden = threading.Thread(target=dienen, daemon=True)
|
||||
faden.start()
|
||||
monkeypatch.setattr(nas_snmp, "PORT", sock.getsockname()[1])
|
||||
monkeypatch.setattr(nas_snmp, "ZEITLIMIT_S", 0.5)
|
||||
yield
|
||||
laeuft.clear()
|
||||
faden.join(timeout=2)
|
||||
sock.close()
|
||||
|
||||
|
||||
def test_nas_platten_ueber_snmp(nas):
|
||||
ergebnis = nas_snmp.lesen("127.0.0.1", "geheim-lesen")
|
||||
assert ergebnis["ok"] and ergebnis["system"] == "Linux TS-453D 5.10.60-qnap"
|
||||
platten = ergebnis["platten"]
|
||||
assert [(p["name"], p["wo"], p["gesund"], p["temperatur"]) for p in platten] == [
|
||||
("WDC WD40EFRX", "NAS HDD1", True, 34), ("ST4000VN008", "NAS HDD2", False, 36)] # leerer Schacht fällt weg
|
||||
assert kp.bewerten(platten[1], None)[0] == "rot"
|
||||
|
||||
|
||||
def test_nas_falscher_zugangsname_und_speichern(nas, daten, monkeypatch):
|
||||
monkeypatch.setattr(nas_snmp, "nas_adresse", lambda: "127.0.0.1")
|
||||
falsch = nas_snmp.setzen("public", "")
|
||||
assert not falsch["ok"] and "antwortet nicht über SNMP" in falsch["detail"] and "Nichts gespeichert" in falsch["detail"]
|
||||
assert nas_snmp.zugang() is None
|
||||
richtig = nas_snmp.setzen("geheim-lesen", "")
|
||||
assert richtig == {"ok": True, "text": "Das NAS antwortet (Linux TS-453D 5.10.60-qnap): 2 Platten, davon 1 gesund."}
|
||||
assert nas_snmp.zugang() == {"community": "geheim-lesen", "adresse": None}
|
||||
assert "geheim-lesen" not in str(nas_snmp.stand())
|
||||
assert nas_snmp.zugang_loeschen() and nas_snmp.stand() == {"eingerichtet": False, "ok": False, "platten": [],
|
||||
"detail": None}
|
||||
|
||||
|
||||
def test_disk_tabelle_der_neueren_firmware():
|
||||
d = nas_snmp.DISK_TABELLE
|
||||
werte = [(f"{d}.1.1", 1), (f"{d}.2.1", "Disk 1"), (f"{d}.5.1", "GOOD"), (f"{d}.6.1", 33),
|
||||
(f"{d}.8.1", "WDC WD40EFRX-68N32N0")]
|
||||
(p,) = nas_snmp.platten_aus_disk(werte)
|
||||
assert (p["name"], p["gesund"], p["temperatur"]) == ("WDC WD40EFRX-68N32N0", True, 33)
|
||||
@@ -1115,10 +1115,44 @@ def bericht() -> dict:
|
||||
host["netz"] = _netz_stand()
|
||||
except Exception as exc:
|
||||
host["netz_fehler"] = str(exc)[:200]
|
||||
try:
|
||||
host["platten"] = _platten_stand()
|
||||
except Exception as exc:
|
||||
host["platten_fehler"] = str(exc)[:200]
|
||||
return {"zeit": datetime.now(timezone.utc).isoformat(timespec="seconds"), "host": host,
|
||||
"gaeste": sorted(gaeste, key=lambda g: g["vmid"])}
|
||||
|
||||
|
||||
# --- Plattengesundheit (seit 26.09.2026, nur lesend) ------------------------------------------------------
|
||||
# Was Proxmox selbst über seine Platten weiß (Rechenzentrum → Knoten → Disks): Liste mit Gesundheit und Verschleiß,
|
||||
# dazu je Platte die SMART-Werte. Ausgewertet wird auf dem Server (backend/kern/platten.py); höchstens stündlich.
|
||||
|
||||
PLATTEN_ALLE_S = 3600
|
||||
_platten: dict = {"zeit": 0.0, "stand": None}
|
||||
|
||||
|
||||
def platten() -> list[dict]:
|
||||
ergebnis = []
|
||||
for disk in _pvesh(f"/nodes/{NODE}/disks/list") or []:
|
||||
if not isinstance(disk, dict) or not disk.get("devpath"):
|
||||
continue
|
||||
try:
|
||||
smart = _pvesh(f"/nodes/{NODE}/disks/smart", "--disk", str(disk["devpath"]))
|
||||
except Exception as exc:
|
||||
smart = {"fehler": str(exc)[:200]}
|
||||
ergebnis.append({"disk": {k: disk.get(k) for k in ("devpath", "model", "serial", "type", "size", "health",
|
||||
"wearout", "rpm")},
|
||||
"smart": smart if isinstance(smart, dict) else None})
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _platten_stand() -> list[dict]:
|
||||
if _platten["stand"] is None or time.time() - _platten["zeit"] >= PLATTEN_ALLE_S:
|
||||
_platten["stand"] = platten()
|
||||
_platten["zeit"] = time.time()
|
||||
return _platten["stand"]
|
||||
|
||||
|
||||
# --- Geräte im Heimnetz (seit 26.09.2026, nur lesend) ----------------------------------------------------
|
||||
# AdGuard sieht in diesem Netz fast nur die Fritzbox (sie reicht die Anfragen der meisten Geräte weiter). Wer im
|
||||
# Heimnetz ist, verrät deshalb die Nachbartabelle dieses Hosts: Alle NETZ_ALLE_S ein Ping an jede Adresse des Netzes
|
||||
|
||||
+11
-1
@@ -149,6 +149,12 @@ Werte zu jedem Zeitpunkt; „Werte als Tabelle“ klappt die letzten Punkte als
|
||||
Anteil geblockter Anfragen, sobald unter Einstellungen ein Zugang eingetragen ist.
|
||||
- **Hinweise mit Ablauf:** „Gesehen“ nimmt einem Hinweis die Farbe, „24 h stumm“ schaltet Erinnerungen ab (wird er rot,
|
||||
meldet er sich trotzdem), „Wieder melden“ hebt das auf. Kurze Wackler lösen keinen Alarm mehr aus.
|
||||
- **Plattengesundheit** (seit 26.09.): die Platte des Proxmox-Hosts und — sobald SNMP am NAS an ist — die Platten des
|
||||
NAS: ob sie sich gesund meldet, wie viel verbraucht ist, wie warm sie ist. Gemeldet wird nur, was schlecht ist oder
|
||||
schlechter wird (etwa neue Medienfehler); „Zur Kenntnis genommen“ macht den heutigen Stand zur neuen Ausgangslage.
|
||||
- **Neue Geräte im Netz** (seit 26.09.): Der Proxmox-Host sieht alle 30 Minuten nach, wer im Heimnetz antwortet.
|
||||
Taucht ein unbekanntes Gerät auf, kommt einmal eine Nachricht und ein gelber Hinweis mit „Ist bekannt“. Handys mit
|
||||
wechselnder (zufälliger) MAC erkennt er am Namen wieder.
|
||||
|
||||
## Homelab · Updates
|
||||
|
||||
@@ -228,7 +234,8 @@ Probleme oben. Je Dienst stehen da
|
||||
- **Läuft seit** mit der Uhrzeit des letzten Starts, und **Neustarts**: wie oft systemd den Dienst nach einem Absturz
|
||||
von selbst wieder gestartet hat (am Handy nur, wenn es welche gab).
|
||||
|
||||
„Protokoll" klappt unter der Zeile auf, was der Dienst zuletzt geschrieben hat, das Neueste unten. „Neu starten"
|
||||
„Protokoll" klappt unter der Zeile auf, was der Dienst zuletzt geschrieben hat, das Neueste unten.
|
||||
Im Cockpit der KI-Box steht seit 26.09. außerdem die **Plattengesundheit** ihrer NVMe (gesund, verbraucht, Wärme). „Neu starten"
|
||||
startet ihn neu, „Starten" einen gestoppten oder gescheiterten Dienst; „Wecken" weckt einen schlafenden bis zum
|
||||
nächsten Neustart der Box. Jeder dieser Knöpfe fragt vorher nach. Die Spracherkennung und die Konsole schlafen seit
|
||||
24.09. mit Absicht; die Android-App wird sie später wieder brauchen.
|
||||
@@ -252,6 +259,9 @@ Eine eigene Seite mit fünf Karten: KI-Box, Homelab, Lucy, Meldungen, Software-S
|
||||
melden“, „Sicherheitsupdates automatisch“, „Alles automatisch im Fenster“) und je Gerät eine eigene.
|
||||
- **AdGuard Home (Homelab):** ein Zugang (am besten ein eigener Benutzer), mit dem die Kachel zeigt, wie viel AdGuard
|
||||
blockt. „Prüfen und speichern“ fragt AdGuard erst damit; das Passwort wird nie wieder angezeigt.
|
||||
- **NAS-Platten (SNMP, Homelab):** Am QNAP unter Systemsteuerung → Netzwerk- und Dateidienste → SNMP den Dienst
|
||||
einschalten (SNMP v1/v2) und einen Zugangsnamen („Community“) wählen, der nicht „public“ heißt; den hier eintragen.
|
||||
„Verbindung testen und speichern“ fragt das NAS erst damit — antwortet es, erscheinen seine Platten im Cockpit.
|
||||
|
||||
- **Arcane-API-Schlüssel (Homelab):** in Arcane unter Einstellungen → API-Schlüssel anlegen und hier einfügen. „Prüfen
|
||||
und speichern“ fragt Arcane zuerst, ob der Schlüssel gilt; erst dann wird er gespeichert — im Homelab-Teil, nicht
|
||||
|
||||
@@ -301,6 +301,19 @@ laufen lassen. Achtung: `ausfuehrer-einrichten.sh` schreibt `/etc/mc2-ausfuehrer
|
||||
gerade „Alle aktualisieren“ oder ein Update, wartet es eine Minute. Einstellungen: `/var/lib/mc2/homelab-wartung.json`
|
||||
(Oberfläche: Einstellungen → Homelab), Stand des letzten Fensters: `/var/lib/mc2/homelab-wartung-stand.json`. Die
|
||||
Läufe schreiben Oberfläche und Steward in dieselbe Datei — seit 26.09. unter `flock`.
|
||||
- **Plattengesundheit (seit 26.09.):** Der Ausführer liest stündlich `pvesh get /nodes/<node>/disks/list` und je Platte
|
||||
`disks/smart` (nur lesend) und schickt beides im Bericht (`host.platten`); ausgewertet wird in `backend/kern/platten.py`
|
||||
(rot: SMART FAILED, kritische Warnung, Reserve aufgebraucht, ≥ 90 % verbraucht; gelb: ≥ 80 %, zu heiß, gewachsene
|
||||
Fehlerzähler). Ausgangslage je Platte in `/var/lib/mc2/platten-basis.json` (Box: `/srv/models/platten-basis.json`),
|
||||
„Zur Kenntnis genommen“ setzt sie neu. Das NAS über SNMP v2c (`services/homelab/nas_snmp.py`, eigener kleiner
|
||||
GetBulk-Leser, UDP 161): QNAP NAS-MIB `hdTable`, sonst QTS-MIB `diskTable`; Zugang in `/var/lib/mc2/nas-snmp.json`
|
||||
(0600), abgefragt höchstens alle 10 min. Die KI-Box liest ihre NVMe mit `sudo -n smartctl -j -a` (Paket
|
||||
`smartmontools`, am 26.09. nachinstalliert — bei einem Neuaufbau `sudo apt install smartmontools`).
|
||||
- **Geräte im Heimnetz (seit 26.09.):** AdGuard sieht in diesem Netz fast nur die Fritzbox (sie reicht die Anfragen
|
||||
weiter), deshalb pingt der Ausführer alle 30 min jede Adresse des /24 an `vmbr0` an und liest `ip neigh` (MAC, IP,
|
||||
Rückwärtsname; nur lesend, größer als /24 nie). Register `/var/lib/mc2/netz-bekannt.json`; der erste Abgleich nimmt
|
||||
alles als bekannt, Gäste des Hosts und neue MACs mit bekanntem Namen ebenso. Neues: einmal eine Nachricht, dazu ein
|
||||
gelber Hinweis mit „Ist bekannt“. Liste: `curl -s http://192.168.178.31:9001/api/homelab/netz | jq`.
|
||||
- **Wöchentliches Suchen:** Der Steward legt für jeden freigegebenen, laufenden Container, dessen Paketlisten älter als
|
||||
7 Tage sind, den Auftrag `suchen` an (nur `apt-get update` bzw. `apk update` im Gast, es wird nichts installiert):
|
||||
nachts 02:00–05:00, höchstens einmal je Gast und Tag, nicht während eines Updates; war die Instanz nachts aus, gleich
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1 +1 @@
|
||||
import{i as e,r as t}from"./dist-aE7UmubC.js";import{G as n,K as r}from"./index-B_hvuxH0.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-DG7u1mB-.js";var l=t(),u=e();function d(e){let t=(0,l.c)(8),{onSchliessen:d}=e,f;t[0]===d?f=t[1]:(f=e=>!e&&d(),t[0]=d,t[1]=f);let p;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(p=(0,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(n,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=p):p=t[2];let m;t[3]===d?m=t[4]:(m=(0,u.jsxs)(o,{side:`left`,className:`flex flex-col gap-7 border-white/[0.06] bg-[#0a0d14] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]`,children:[p,(0,u.jsx)(r,{onGewaehlt:d})]}),t[3]=d,t[4]=m);let h;return t[5]!==f||t[6]!==m?(h=(0,u.jsx)(c,{open:!0,onOpenChange:f,children:m}),t[5]=f,t[6]=m,t[7]=h):h=t[7],h}export{d as MenueSchublade};
|
||||
import{i as e,r as t}from"./dist-aE7UmubC.js";import{G as n,K as r}from"./index-CuG_RlEw.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-DG7u1mB-.js";var l=t(),u=e();function d(e){let t=(0,l.c)(8),{onSchliessen:d}=e,f;t[0]===d?f=t[1]:(f=e=>!e&&d(),t[0]=d,t[1]=f);let p;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(p=(0,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(n,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=p):p=t[2];let m;t[3]===d?m=t[4]:(m=(0,u.jsxs)(o,{side:`left`,className:`flex flex-col gap-7 border-white/[0.06] bg-[#0a0d14] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]`,children:[p,(0,u.jsx)(r,{onGewaehlt:d})]}),t[3]=d,t[4]=m);let h;return t[5]!==f||t[6]!==m?(h=(0,u.jsx)(c,{open:!0,onOpenChange:f,children:m}),t[5]=f,t[6]=m,t[7]=h):h=t[7],h}export{d as MenueSchublade};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
-11
File diff suppressed because one or more lines are too long
+11
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
Vendored
+3
-3
@@ -9,15 +9,15 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>Homelab Orchestrator</title>
|
||||
<script type="module" crossorigin src="/assets/index-B_hvuxH0.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CuG_RlEw.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/dist-aE7UmubC.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/button-BHVKhtQ6.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/dist-CakcBaML.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/abfragen-pD_qBEtc.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/abfragen-CDCgpAto.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/useSelector-DBNxxw17.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/useMatch-DDVpPKJI.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/useRouterState-DJr4hrdj.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Cz-7Ak8T.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CyAQuGX0.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import type { Platte } from "@/lib/typen"
|
||||
import { Plattenliste } from "./Plattenliste"
|
||||
|
||||
// Plattengesundheit (Ausbauplan „Später“, Punkt 18, 26.09.2026): die echte Host-NVMe und eine kränkelnde NAS-Platte.
|
||||
const platte = (mehr: Partial<Platte>): Platte => ({
|
||||
id: "pve:1", name: "PELADN 512GB", wo: "Proxmox-Host /dev/nvme0n1", typ: "nvme", groesse: 512110190592,
|
||||
gesund: true, verbraucht: 2, temperatur: 41, stunden: 9992, fehler: { medien: 0 }, stufe: "ok", saetze: [], ...mehr,
|
||||
})
|
||||
|
||||
describe("Plattenliste", () => {
|
||||
it("gesunde Platte: Zustand, Verbrauch, Wärme, Laufzeit", () => {
|
||||
render(<Plattenliste platten={[platte({})]} />)
|
||||
expect(screen.getByText("Proxmox-Host /dev/nvme0n1 · NVMe · 512 GB")).toBeInTheDocument()
|
||||
expect(screen.getByText(/meldet sich gesund/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/2 % verbraucht · 41 °C · läuft seit 1,1 Jahren/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("was schlechter wird, steht als Satz in der Zeile", () => {
|
||||
render(<Plattenliste platten={[platte({ id: "nas:2", name: "ST4000VN008", wo: "NAS HDD2", typ: "hdd", groesse: null,
|
||||
gesund: false, verbraucht: null, stunden: null, stufe: "rot", saetze: ["meldet sich selbst als defekt (SMART)"] })]} />)
|
||||
expect(screen.getByText(/meldet sich selbst als defekt \(SMART\)/)).toHaveClass("text-rot-text")
|
||||
expect(screen.getByText("NAS HDD2 · Festplatte")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("ohne Werte ein Satz", () => {
|
||||
render(<Plattenliste platten={[]} />)
|
||||
expect(screen.getByText("Noch keine Werte.")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { cn } from "cn"
|
||||
import type { Platte } from "@/lib/typen"
|
||||
|
||||
// Plattengesundheit (Ausbauplan „Später“, Punkt 18, seit 26.09.2026): je Platte, ob sie sich gesund meldet, wie viel
|
||||
// verbraucht ist, wie warm sie ist und seit wann sie läuft. Gemeldet wird nur, was schlecht ist oder schlechter wird
|
||||
// (backend/kern/platten.py) — dann steht der Satz dazu in der Zeile.
|
||||
|
||||
const PUNKT = { ok: "bg-gruen", gelb: "bg-bernstein", rot: "bg-rot shadow-[0_0_8px_var(--rot)]" } as const
|
||||
const TEXT = { ok: "text-text-3", gelb: "text-bernstein", rot: "text-rot-text" } as const
|
||||
const TYP: Record<string, string> = { nvme: "NVMe", ssd: "SSD", hdd: "Festplatte" }
|
||||
|
||||
function groesse(bytes: number | null): string | null {
|
||||
if (!bytes) return null
|
||||
const tb = bytes / 1e12
|
||||
return tb >= 1 ? `${tb.toLocaleString("de-DE", { maximumFractionDigits: 1 })} TB` : `${Math.round(bytes / 1e9)} GB`
|
||||
}
|
||||
|
||||
function laufzeit(stunden: number | null): string | null {
|
||||
if (!stunden) return null
|
||||
const jahre = stunden / 8766
|
||||
return jahre >= 1 ? `läuft seit ${jahre.toLocaleString("de-DE", { maximumFractionDigits: 1 })} Jahren`
|
||||
: `läuft seit ${Math.round(stunden / 24)} Tagen`
|
||||
}
|
||||
|
||||
export function Plattenliste({ platten }: { platten: Platte[] }) {
|
||||
if (platten.length === 0) return <p className="text-sm text-text-3">Noch keine Werte.</p>
|
||||
return (
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{platten.map((p) => {
|
||||
const gesund = p.gesund === true ? "meldet sich gesund" : p.gesund === false ? "meldet sich krank" : "Zustand unbekannt"
|
||||
const werte = [
|
||||
p.verbraucht != null ? `${p.verbraucht} % verbraucht` : null,
|
||||
p.temperatur != null ? `${p.temperatur} °C` : null,
|
||||
laufzeit(p.stunden),
|
||||
].filter(Boolean)
|
||||
return (
|
||||
<li key={p.id} className="flex items-start gap-3 border-t border-white/[0.06] py-3 first:border-t-0">
|
||||
<span aria-hidden className={cn("mt-1.5 size-2 shrink-0 rounded-full", PUNKT[p.stufe])} />
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-[15px] font-semibold break-words">{p.name}</span>
|
||||
<span className="text-[13px] text-text-3">
|
||||
{[p.wo, TYP[p.typ] ?? null, groesse(p.groesse)].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
<span className={cn("text-sm", p.stufe === "ok" ? "text-text-2" : TEXT[p.stufe])}>
|
||||
{p.saetze.length ? p.saetze.join("; ") : gesund}
|
||||
{werte.length ? <span className="text-text-3"> · {werte.join(" · ")}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { stromVerbunden } from "./strom"
|
||||
import type {
|
||||
Ablauf, AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
|
||||
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuerStack, NeuWahl, DockerLage, Neuerungen, HomelabWartung, WartungsRegel,
|
||||
LucyAutonomie, LucyStufe, Rueckfragen, Wochenbericht, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
|
||||
LucyAutonomie, LucyStufe, Rueckfragen, Wochenbericht, Platte, HomelabPlatten, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
|
||||
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
|
||||
ZielStand, Zeitraum,
|
||||
} from "./typen"
|
||||
@@ -512,6 +512,32 @@ export const useNeuerungen = (zid: string, baustein: string, aktiv: boolean) =>
|
||||
retry: false,
|
||||
})
|
||||
|
||||
/** Plattengesundheit (seit 26.09.2026): die KI-Box selbst und das Homelab (Proxmox-Host, NAS über SNMP). */
|
||||
export const useBoxPlatten = () =>
|
||||
useQuery({ queryKey: ["platten"], queryFn: () => api<{ platten: Platte[] }>("/api/platten"), refetchInterval: 600_000, retry: false })
|
||||
|
||||
export const useHomelabPlatten = (aktiv: boolean) =>
|
||||
useQuery({
|
||||
queryKey: ["homelab-platten"],
|
||||
queryFn: () => api<HomelabPlatten>("/api/homelab/platten"),
|
||||
enabled: aktiv,
|
||||
refetchInterval: 600_000,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
/** SNMP-Zugang des NAS: speichern (erst prüft der Homelab-Teil das NAS damit) oder mit null entfernen. */
|
||||
export const useNasSnmp = () =>
|
||||
useAktion(
|
||||
(zugang: { community: string; adresse: string } | null) =>
|
||||
zugang === null
|
||||
? api<AktionsErgebnis>("/api/homelab/einstellungen/nas-snmp", { method: "DELETE" })
|
||||
: post<AktionsErgebnis>("/api/homelab/einstellungen/nas-snmp", zugang),
|
||||
{
|
||||
erfolg: (v, e) => (e as AktionsErgebnis).text ?? (v === null ? "SNMP-Zugang entfernt." : "SNMP-Zugang gespeichert."),
|
||||
neuLaden: ["homelab-einstellungen", "homelab-platten"],
|
||||
},
|
||||
)
|
||||
|
||||
/** Lucys Freiheiten je Aktionsart (seit 25.09.2026, Einstellungen → Lucy). */
|
||||
export const useLucyAutonomie = (aktiv = true) =>
|
||||
useQuery({
|
||||
|
||||
@@ -555,6 +555,8 @@ export interface HomelabEinstellungen {
|
||||
}
|
||||
/** Zugang für die Statistik von AdGuard („% geblockt“ auf der Kachel, seit 25.09.2026). */
|
||||
adguard?: { url: string; zugang: "hinterlegt" | "fehlt"; benutzer: string | null }
|
||||
/** Seit 26.09.2026: SNMP-Zugang des NAS (nie der Zugangsname selbst). */
|
||||
nas_snmp?: { zugang: "hinterlegt" | "fehlt"; adresse: string | null }
|
||||
karenz_h: number
|
||||
ausfuehrer: { verbunden: boolean; zuletzt: number | null; nur_lesen: boolean; sicherung_speicher: string | null }
|
||||
}
|
||||
@@ -891,3 +893,25 @@ export interface Wochenbericht {
|
||||
zuletzt: { zeit: number; text: string } | null
|
||||
naechster: number
|
||||
}
|
||||
|
||||
// --- Plattengesundheit (GET /api/platten, /api/homelab/platten; seit 26.09.2026) -----------------------------------
|
||||
|
||||
export interface Platte {
|
||||
id: string
|
||||
name: string
|
||||
wo: string
|
||||
typ: string
|
||||
groesse: number | null
|
||||
gesund: boolean | null
|
||||
verbraucht: number | null
|
||||
temperatur: number | null
|
||||
stunden: number | null
|
||||
fehler: Record<string, number>
|
||||
stufe: "ok" | "gelb" | "rot"
|
||||
saetze: string[]
|
||||
}
|
||||
|
||||
export interface HomelabPlatten {
|
||||
platten: Platte[]
|
||||
nas: { eingerichtet: boolean; ok: boolean; detail: string | null; system: string | null }
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Freiheiten, WochenberichtAbschnitt } from "@/components/lucy/LucyEinste
|
||||
import { OffeneRueckfragen, RueckfragenVerlauf } from "@/components/lucy/Rueckfragen"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
useAdguardZugang, useArcaneEcht, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner,
|
||||
useAdguardZugang, useArcaneEcht, useNasSnmp, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner,
|
||||
useRadarSchalten, useSoftwareStand, useTelegramTest, useUpdateFensterSetzen,
|
||||
} from "@/lib/abfragen"
|
||||
import { api, post } from "@/lib/api"
|
||||
@@ -584,6 +584,65 @@ function AdguardZugang({ h }: { h: HomelabEinstellungen }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** NAS-Platten über SNMP (seit 26.09.2026): der Zugangsname („Community“), vorher fragt der Homelab-Teil das NAS. */
|
||||
function NasSnmp({ h }: { h: HomelabEinstellungen }) {
|
||||
const speichern = useNasSnmp()
|
||||
const [community, setCommunity] = useState("")
|
||||
const [adresse, setAdresse] = useState("")
|
||||
const [loeschFrage, setLoeschFrage] = useState(false)
|
||||
const n = h.nas_snmp
|
||||
if (!n) return null
|
||||
const feld = "h-11 w-full rounded-xl border border-white/12 bg-white/[0.04] px-3 text-[15px] outline-none focus:border-lab/60 focus:bg-white/[0.06]"
|
||||
return (
|
||||
<Abschnitt titel="NAS-Platten (SNMP)">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-text-2">
|
||||
Zugang {n.zugang === "hinterlegt" ? <Chip art="gruen">hinterlegt</Chip> : <Chip art="grau">keiner</Chip>}
|
||||
{n.adresse && <span className="text-text-3">NAS {n.adresse}</span>}
|
||||
</div>
|
||||
<p className="text-sm text-text-2">
|
||||
Damit meldet der Orchestrator, wenn eine Platte im NAS kränkelt. Am QNAP: Systemsteuerung → Netzwerk- und
|
||||
Dateidienste → SNMP → „SNMP-Dienst aktivieren“, Version SNMP v1/v2, einen Zugangsnamen („Community“) wählen, der
|
||||
nicht „public“ heißt — und ihn hier eintragen.
|
||||
</p>
|
||||
<form
|
||||
className="flex flex-wrap gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (community.trim()) {
|
||||
speichern.mutate({ community: community.trim(), adresse: adresse.trim() },
|
||||
{ onSuccess: () => { setCommunity(""); setAdresse("") } })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label htmlFor="nas-community" className="sr-only">Zugangsname (Community)</label>
|
||||
<input id="nas-community" type="password" autoComplete="new-password" value={community}
|
||||
onChange={(e) => setCommunity(e.target.value)} placeholder="Zugangsname (Community)" className={cn(feld, "min-w-0 flex-1 basis-48")} />
|
||||
<label htmlFor="nas-adresse" className="sr-only">Adresse des NAS</label>
|
||||
<input id="nas-adresse" autoComplete="off" value={adresse} onChange={(e) => setAdresse(e.target.value)}
|
||||
placeholder={n.adresse ? `Adresse (${n.adresse})` : "Adresse des NAS"} className={cn(feld, "min-w-0 flex-1 basis-40")} />
|
||||
<Button type="submit" disabled={!community.trim() || speichern.isPending}>
|
||||
{speichern.isPending ? "Prüft …" : "Verbindung testen und speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="text-xs text-text-3">
|
||||
Vor dem Speichern fragt der Homelab-Teil das NAS damit. Antwortet es nicht, wird nichts gespeichert. Der
|
||||
Zugangsname wird nie wieder angezeigt; die Adresse nur ändern, wenn sie von der angezeigten abweicht.
|
||||
</p>
|
||||
{n.zugang === "hinterlegt" && (
|
||||
loeschFrage ? (
|
||||
<span className="flex flex-wrap gap-2">
|
||||
<Button variant="gefahr" size="sm" disabled={speichern.isPending}
|
||||
onClick={() => { speichern.mutate(null); setLoeschFrage(false) }}>Wirklich entfernen?</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setLoeschFrage(false)}>Nein</Button>
|
||||
</span>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="self-start" onClick={() => setLoeschFrage(true)}>Zugang entfernen</Button>
|
||||
)
|
||||
)}
|
||||
</Abschnitt>
|
||||
)
|
||||
}
|
||||
|
||||
function HomelabBetrieb({ h }: { h: HomelabEinstellungen }) {
|
||||
const f = h.ausfuehrer
|
||||
return (
|
||||
@@ -620,6 +679,7 @@ function HomelabKarte() {
|
||||
<>
|
||||
<ArcaneSchluessel h={h.data} />
|
||||
<AdguardZugang h={h.data} />
|
||||
<NasSnmp h={h.data} />
|
||||
<Abschnitt titel="Wartungsfenster und Regel je Gerät"><Wartungsfenster aktiv={aktiv} /></Abschnitt>
|
||||
<HomelabBetrieb h={h.data} />
|
||||
</>
|
||||
|
||||
@@ -11,9 +11,10 @@ import { Speicher } from "@/components/homelab/Speicher"
|
||||
import { AlleKnopf, SammellaufFortschritt } from "@/components/homelab/Sammellauf"
|
||||
import { UpdateTabelle } from "@/components/homelab/UpdateTabelle"
|
||||
import { Zertifikate } from "@/components/homelab/Zertifikate"
|
||||
import { Plattenliste } from "@/components/cockpit/Plattenliste"
|
||||
import {
|
||||
useHomelab, useHomelabDocker, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher,
|
||||
useHomelabZertifikate,
|
||||
useHomelabPlatten, useHomelabZertifikate,
|
||||
useInstanz, useNeuEntdeckt, useNeuUebernehmen, usePartner, useSammellauf, useSammellaufQuittieren, useSicherungsProbe,
|
||||
useZielAktion,
|
||||
} from "@/lib/abfragen"
|
||||
@@ -198,6 +199,7 @@ export function HomelabSeite() {
|
||||
const mess = useHomelabMesswerte("1h", !!stand)
|
||||
const speicher = useHomelabSpeicher(!!stand)
|
||||
const zertifikate = useHomelabZertifikate(!!stand)
|
||||
const platten = useHomelabPlatten(!!stand)
|
||||
const probe = useSicherungsProbe()
|
||||
const neu = useNeuEntdeckt(!!stand)
|
||||
const docker = useHomelabDocker(!!stand)
|
||||
@@ -267,6 +269,20 @@ export function HomelabSeite() {
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
{platten.data && (platten.data.platten.length > 0 || platten.data.nas.eingerichtet) && (
|
||||
<Panel titel="Plattengesundheit" id="platten">
|
||||
<Plattenliste platten={platten.data.platten} />
|
||||
{platten.data.nas.eingerichtet && !platten.data.nas.ok && platten.data.nas.detail && (
|
||||
<p className="text-sm text-bernstein">{platten.data.nas.detail}</p>
|
||||
)}
|
||||
{!platten.data.nas.eingerichtet && (
|
||||
<p className="text-xs text-text-3">
|
||||
Die Platten des NAS erscheinen, sobald am QNAP SNMP an ist und der Zugangsname unter Einstellungen → Homelab
|
||||
steht.
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
{zertifikate.data?.gelesen && zertifikate.data.zertifikate.length > 0 && (
|
||||
<Panel titel="Zertifikate" id="zertifikate">
|
||||
<Zertifikate daten={zertifikate.data} />
|
||||
|
||||
@@ -5,10 +5,11 @@ import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||
import { Flugplan } from "@/components/cockpit/Flugplan"
|
||||
import { Instrument } from "@/components/cockpit/Instrument"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
import { Plattenliste } from "@/components/cockpit/Plattenliste"
|
||||
import { Warnpanel } from "@/components/cockpit/Warnpanel"
|
||||
import { Zaehlwerk } from "@/components/cockpit/Zaehlwerk"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRadar, useStart } from "@/lib/abfragen"
|
||||
import { useBoxPlatten, useRadar, useStart } from "@/lib/abfragen"
|
||||
import { RADAR_STATUS_TEXT, temperaturFarbe } from "@/lib/anzeige"
|
||||
import { knappeVorhersage, STUFE_FARBE } from "@/lib/prognose"
|
||||
import { useLetzteMetrik } from "@/lib/strom"
|
||||
@@ -94,6 +95,7 @@ function RadarKasten({ radar }: { radar: Radar | undefined }) {
|
||||
export function StartSeite() {
|
||||
const { data: start, error, isPending } = useStart()
|
||||
const radar = useRadar()
|
||||
const platten = useBoxPlatten()
|
||||
|
||||
if (isPending) return <Zustandsfeld text="Die Box meldet sich gleich …" />
|
||||
// Scheitert nur ein späteres Nachladen, bleibt der letzte Stand stehen — die Verbindungsanzeige
|
||||
@@ -122,6 +124,11 @@ export function StartSeite() {
|
||||
<RadarKasten radar={radar.data} />
|
||||
</Panel>
|
||||
</div>
|
||||
{platten.data && platten.data.platten.length > 0 && (
|
||||
<Panel titel="Plattengesundheit" id="platten">
|
||||
<Plattenliste platten={platten.data.platten} />
|
||||
</Panel>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user