From 02a5db61be70a91be5402142e7430b033d5d71df Mon Sep 17 00:00:00 2001 From: nessi Date: Sun, 16 Aug 2026 12:18:40 +0200 Subject: [PATCH] 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 --- Dockerfile | 2 +- app.py | 51 ++++++++++++++++++++++++++++++++++++++++++++-- docker-compose.yml | 1 + 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index c9e1c96..e96fd49 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM python:3.13-slim RUN apt-get update \ - && apt-get install -y --no-install-recommends ffmpeg mdadm util-linux smartmontools \ + && apt-get install -y --no-install-recommends ffmpeg mdadm util-linux smartmontools udev \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY requirements.txt . diff --git a/app.py b/app.py index f26f907..0a8d0b7 100644 --- a/app.py +++ b/app.py @@ -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, diff --git a/docker-compose.yml b/docker-compose.yml index 635482d..5d81c53 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,7 @@ services: - /dev:/host-dev:ro # Read-only Hardware-Metadaten für Seriennummern und SMART-Zuordnung. - /sys:/host-sys:ro + - /run/udev:/host-run-udev:ro - ./data:/data networks: - media