feat: add udev serial detection, comprehensive logging, and SAT fallback for SMART health checks on RAID disks
Install udev package and mount /run/udev:/host-run-udev:ro for udev by-id symlink access. Add raid_log_state global to track RAID status changes and hardware_log_once set to prevent duplicate hardware warnings. Update scan_datastore_storage() with info/error logging for scan lifecycle and disk usage. Add _read_command() debug/warning/error logging for all subprocess calls with command text
This commit is contained in:
@@ -58,6 +58,8 @@ service_status_lock = threading.Lock()
|
||||
service_check_executor = ThreadPoolExecutor(max_workers=16)
|
||||
datastore_cache_lock = threading.Lock()
|
||||
datastore_cache = {"stored_at": 0.0, "snapshot": None}
|
||||
raid_log_state = None
|
||||
hardware_log_once = set()
|
||||
|
||||
LANG_NAMES = {"de":"Deutsch", "deu":"Deutsch", "ger":"Deutsch", "en":"Englisch", "eng":"Englisch", "ja":"Japanisch", "jpn":"Japanisch", "ko":"Koreanisch", "kor":"Koreanisch", "fr":"Französisch", "fra":"Französisch", "fre":"Französisch", "es":"Spanisch", "spa":"Spanisch", "it":"Italienisch", "ita":"Italienisch", "ru":"Russisch", "rus":"Russisch", "zh":"Chinesisch", "zho":"Chinesisch", "chi":"Chinesisch", "und":"Unbekannt", "":"Unbekannt"}
|
||||
FLAGS = {"Deutsch":"🇩🇪", "Englisch":"🇬🇧", "Japanisch":"🇯🇵", "Koreanisch":"🇰🇷", "Französisch":"🇫🇷", "Spanisch":"🇪🇸", "Italienisch":"🇮🇹", "Russisch":"🇷🇺", "Chinesisch":"🇨🇳", "Unbekannt":"❓"}
|
||||
@@ -188,11 +190,13 @@ def _datastore_category(relative_path, category_roots):
|
||||
|
||||
def scan_datastore_storage():
|
||||
root = Path(DATASTORE_PATH)
|
||||
log.info("Datastore-Scan gestartet: %s", DATASTORE_PATH)
|
||||
categories = {key: {"key": key, "label": label, "bytes": 0, "files": 0} for key, label in (
|
||||
("movies", "Filme"), ("series", "Serien"), ("anime", "Anime"),
|
||||
("kdrama", "K-Dramen"), ("downloads", "Downloads"), ("other", "Sonstiges")
|
||||
)}
|
||||
if not root.exists() or not root.is_dir():
|
||||
log.error("Datastore-Pfad nicht verfügbar: %s", DATASTORE_PATH)
|
||||
return {"error": f"Datastore {DATASTORE_PATH} ist nicht verfügbar.", "path": DATASTORE_PATH, "categories": list(categories.values())}
|
||||
category_roots = {
|
||||
"movies": {"filme", "film", "movies", "movie"},
|
||||
@@ -215,6 +219,7 @@ def scan_datastore_storage():
|
||||
category["files"] += 1
|
||||
usage = shutil.disk_usage(root)
|
||||
except OSError as exc:
|
||||
log.exception("Datastore konnte nicht gelesen werden: %s", DATASTORE_PATH)
|
||||
return {"error": f"Datastore konnte nicht gelesen werden: {exc}", "path": DATASTORE_PATH, "categories": list(categories.values())}
|
||||
used = usage.used
|
||||
values = []
|
||||
@@ -222,16 +227,22 @@ def scan_datastore_storage():
|
||||
category["size"] = format_bytes(category["bytes"])
|
||||
category["percent"] = round(category["bytes"] / used * 100, 1) if used else 0
|
||||
values.append(category)
|
||||
log.info("Datastore-Scan abgeschlossen: %s belegt, %s frei, %.1f%% genutzt", format_bytes(usage.used), format_bytes(usage.free), usage.used / usage.total * 100 if usage.total else 0)
|
||||
return {"error": None, "path": DATASTORE_PATH, "total_bytes": usage.total, "used_bytes": usage.used,
|
||||
"free_bytes": usage.free, "total": format_bytes(usage.total), "used": format_bytes(usage.used),
|
||||
"free": format_bytes(usage.free), "percent": round(usage.used / usage.total * 100, 1) if usage.total else 0,
|
||||
"categories": values, "scanned_at": time.time()}
|
||||
|
||||
def _read_command(command, timeout=4):
|
||||
command_text = " ".join(str(part) for part in command)
|
||||
log.debug("Systembefehl gestartet: %s", command_text)
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False)
|
||||
if result.returncode != 0:
|
||||
log.warning("Systembefehl beendet mit Code %s: %s%s", result.returncode, command_text, f" · {result.stderr.strip()[:180]}" if result.stderr.strip() else "")
|
||||
return result.stdout, result.stderr
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
log.error("Systembefehl fehlgeschlagen: %s · %s", command_text, exc)
|
||||
return "", str(exc)
|
||||
|
||||
def _flatten_lsblk(nodes):
|
||||
@@ -251,6 +262,16 @@ def _device_serial(device):
|
||||
return value
|
||||
except OSError:
|
||||
continue
|
||||
# Some controllers expose the serial only through udev's by-id links.
|
||||
for by_id in (Path(HOST_DEVICE_PATH) / "disk/by-id", Path("/dev/disk/by-id")):
|
||||
try:
|
||||
for link in by_id.iterdir():
|
||||
if link.name.startswith(("wwn-", "scsi-", "ata-", "nvme-")) and name in str(link.resolve()):
|
||||
serial = re.sub(r"^(?:ata|scsi|nvme)-", "", link.name)
|
||||
if serial and not serial.startswith("part"):
|
||||
return serial
|
||||
except OSError:
|
||||
continue
|
||||
return ""
|
||||
|
||||
def format_transfer_rate(value):
|
||||
@@ -293,13 +314,17 @@ def format_remaining_time(value):
|
||||
return " ".join(parts) or "< 1 Min."
|
||||
|
||||
def raid_status():
|
||||
global raid_log_state
|
||||
mdstat, mdstat_error = "", ""
|
||||
try:
|
||||
mdstat = Path("/proc/mdstat").read_text()
|
||||
except OSError as exc:
|
||||
mdstat_error = str(exc)
|
||||
log.error("/proc/mdstat konnte nicht gelesen werden: %s", exc)
|
||||
array_name = Path(RAID_DEVICE).name
|
||||
array_line = next((line for line in mdstat.splitlines() if line.startswith(f"{array_name} :")), "")
|
||||
if not array_line:
|
||||
log.warning("RAID-Array %s wurde in /proc/mdstat nicht gefunden", RAID_DEVICE)
|
||||
profile_match = re.search(r"(raid\d+)", array_line)
|
||||
# Match the member count on this RAID's own line. Searching all of
|
||||
# /proc/mdstat can accidentally pick a different array's [x/y] value.
|
||||
@@ -355,11 +380,29 @@ def raid_status():
|
||||
info = {}
|
||||
serial = info.get("serial") or _device_serial(disk["device"])
|
||||
disk.update({"size": format_bytes(info.get("size") or 0) if info.get("size") else "—", "model": info.get("model") or "—", "serial": serial or "—"})
|
||||
serial_log_key = "serial:" + disk["device"]
|
||||
if not serial and serial_log_key not in hardware_log_once:
|
||||
log.warning("Keine Seriennummer für %s gefunden; Host-Controller stellt möglicherweise keine bereit", disk["device"])
|
||||
hardware_log_once.add(serial_log_key)
|
||||
if SMART_ENABLED and shutil.which("smartctl"):
|
||||
smart_device = disk["device"].replace("/dev/", f"{HOST_DEVICE_PATH.rstrip('/')}/", 1)
|
||||
smart_output, _ = _read_command(["smartctl", "-H", smart_device], timeout=6)
|
||||
smart_output, smart_error = _read_command(["smartctl", "-H", smart_device], timeout=6)
|
||||
if not re.search(r"(?:overall-health|Health Status|SMART support is)", smart_output, re.IGNORECASE):
|
||||
sat_output, sat_error = _read_command(["smartctl", "-H", "-d", "sat", smart_device], timeout=6)
|
||||
if sat_output:
|
||||
smart_output, smart_error = sat_output, sat_error
|
||||
health = re.search(r"(?:SMART overall-health self-assessment test result|SMART Health Status):\s*(.+)", smart_output, re.IGNORECASE)
|
||||
disk["smart"] = health.group(1).strip() if health else "Nicht verfügbar"
|
||||
disk["smart"] = health.group(1).strip() if health else ("Fehler: " + smart_error.strip()[:80] if smart_error.strip() else "Nicht unterstützt")
|
||||
smart_log_key = "smart:" + disk["device"]
|
||||
if health and "smart-info:" + disk["device"] not in hardware_log_once:
|
||||
log.info("SMART-Status %s: %s", disk["device"], disk["smart"])
|
||||
hardware_log_once.add("smart-info:" + disk["device"])
|
||||
if not health and smart_log_key not in hardware_log_once:
|
||||
log.warning("SMART-Status für %s nicht verfügbar: %s", disk["device"], disk["smart"])
|
||||
hardware_log_once.add(smart_log_key)
|
||||
elif SMART_ENABLED and "smartctl" not in hardware_log_once:
|
||||
log.error("smartctl ist im Container nicht verfügbar; SMART kann nicht geprüft werden")
|
||||
hardware_log_once.add("smartctl")
|
||||
missing = members_match and members_match.group(1) != members_match.group(2)
|
||||
failed = detail.get("failed_devices", 0) > 0 or any(disk["status"] == "failed" for disk in devices)
|
||||
rebuilding = bool(progress_match)
|
||||
@@ -378,6 +421,10 @@ def raid_status():
|
||||
active_devices = mdstat_active or detail.get("active_devices", 0)
|
||||
raid_devices = mdstat_total or detail.get("raid_devices", 0) or len(devices)
|
||||
failed_devices = detail.get("failed_devices", 0) or max(0, raid_devices - active_devices)
|
||||
signature = (status, active_devices, raid_devices, failed_devices, rebuilding)
|
||||
if signature != raid_log_state:
|
||||
log.info("RAID-Status %s: %s (%s/%s aktiv, %s fehlend%s)", RAID_DEVICE, status.upper(), active_devices, raid_devices, failed_devices, ", Rebuild läuft" if rebuilding else "")
|
||||
raid_log_state = signature
|
||||
return {"device": RAID_DEVICE, "status": status, "label": {"ok": "OK", "degraded": "DEGRADED", "failed": "FAILED", "rebuilding": "REBUILDING"}.get(status, "UNKNOWN"),
|
||||
"level": detail.get("level", "—"), "raid_devices": raid_devices,
|
||||
"active_devices": active_devices, "failed_devices": failed_devices,
|
||||
|
||||
Reference in New Issue
Block a user