Feature: Disk-ID Namen + SMART-Daten on demand im VdevTree
- Backend: get_disk_id_map() liest /dev/disk/by-id/ dynamisch aus (ata/nvme/scsi/wwn)
- Backend: _annotate_disk_ids() hängt disk_id an Leaf-Vdevs in get_pool_status()
- Backend: get_smart_info() liest smartctl --json (Modell, Temp, Health, Stunden, Sektoren)
- Backend: GET /api/pools/disks/{disk}/smart Endpoint
- Frontend: DiskRow zeigt Modellname neben sda/sdb, aufklappbar für SMART-Details
- Frontend: Temp-Spalte farbcodiert (grün/gelb/rot), SMART-Spalte zeigt PASSED/FAILED
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@ Handles subprocess execution, parsing, caching, error handling
|
||||
import subprocess
|
||||
import json
|
||||
import logging
|
||||
import glob
|
||||
import os
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
@@ -196,6 +198,70 @@ class ZFSRunner:
|
||||
|
||||
return roots
|
||||
|
||||
def get_disk_id_map(self) -> Dict[str, str]:
|
||||
"""
|
||||
Build mapping {sda: ata-WDC_WD20EZRZ-..., nvme0n1: nvme-Samsung_...} from /dev/disk/by-id/.
|
||||
Prefers ata- prefix, then nvme-, then scsi-, then wwn-.
|
||||
"""
|
||||
mapping: Dict[str, str] = {}
|
||||
priority = ["ata-", "nvme-", "scsi-", "wwn-"]
|
||||
|
||||
for pattern in priority:
|
||||
for link in glob.glob(f"/dev/disk/by-id/{pattern}*"):
|
||||
if "-part" in os.path.basename(link):
|
||||
continue
|
||||
try:
|
||||
target = os.path.realpath(link)
|
||||
disk = os.path.basename(target)
|
||||
if disk not in mapping:
|
||||
mapping[disk] = os.path.basename(link)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
return mapping
|
||||
|
||||
def get_smart_info(self, disk: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Run smartctl -A -i --json on /dev/disk and return parsed health data.
|
||||
Returns empty dict if smartctl unavailable or disk not SMART-capable.
|
||||
"""
|
||||
stdout, stderr, rc = self.run_command(["smartctl", "-A", "-i", "--json", f"/dev/{disk}"])
|
||||
if not stdout:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(stdout)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
result: Dict[str, Any] = {}
|
||||
|
||||
# Device info
|
||||
device = data.get("device", {})
|
||||
model_info = data.get("model_name") or data.get("model_family", "")
|
||||
serial = data.get("serial_number", "")
|
||||
result["model"] = model_info
|
||||
result["serial"] = serial
|
||||
result["protocol"] = device.get("protocol", "")
|
||||
|
||||
# Power-on hours
|
||||
result["power_on_hours"] = data.get("power_on_time", {}).get("hours")
|
||||
|
||||
# Temperature
|
||||
temp = data.get("temperature", {})
|
||||
result["temperature"] = temp.get("current")
|
||||
|
||||
# Overall health (from -H flag data embedded in -i)
|
||||
smart_status = data.get("smart_status", {})
|
||||
result["passed"] = smart_status.get("passed")
|
||||
|
||||
# Key attributes from ata_smart_attributes
|
||||
attrs = {a["name"]: a for a in data.get("ata_smart_attributes", {}).get("table", [])}
|
||||
result["reallocated_sectors"] = attrs.get("Reallocated_Sector_Ct", {}).get("raw", {}).get("value", 0)
|
||||
result["pending_sectors"] = attrs.get("Current_Pending_Sector", {}).get("raw", {}).get("value", 0)
|
||||
result["uncorrectable"] = attrs.get("Offline_Uncorrectable", {}).get("raw", {}).get("value", 0)
|
||||
|
||||
return result
|
||||
|
||||
def get_pool_status(self, pool_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get detailed pool status including VDEV tree and error counters
|
||||
@@ -235,17 +301,30 @@ class ZFSRunner:
|
||||
config_lines.append(line)
|
||||
|
||||
if config_lines:
|
||||
# Remove the pool name itself (first non-empty line after "NAME" header)
|
||||
# and parse only child vdevs
|
||||
parsed = self._parse_vdev_tree(config_lines)
|
||||
# parsed[0] is the pool root node; its children are the top-level vdevs
|
||||
if parsed and parsed[0]["name"] == pool_name:
|
||||
status["vdevs"] = parsed[0]["children"]
|
||||
else:
|
||||
status["vdevs"] = parsed
|
||||
|
||||
# Annotate leaf vdevs with disk_id from /dev/disk/by-id/
|
||||
disk_id_map = self.get_disk_id_map()
|
||||
self._annotate_disk_ids(status["vdevs"], disk_id_map)
|
||||
|
||||
return status
|
||||
|
||||
def _annotate_disk_ids(self, vdevs: List[Dict], disk_id_map: Dict[str, str]) -> None:
|
||||
"""Recursively annotate leaf vdev nodes with disk_id from by-id map."""
|
||||
for vdev in vdevs:
|
||||
children = vdev.get("children", [])
|
||||
if not children:
|
||||
name = vdev.get("name", "")
|
||||
# Strip partition suffix (sda1 → sda)
|
||||
base = re.sub(r'\d+$', '', name) if name[-1:].isdigit() else name
|
||||
vdev["disk_id"] = disk_id_map.get(name) or disk_id_map.get(base)
|
||||
else:
|
||||
self._annotate_disk_ids(children, disk_id_map)
|
||||
|
||||
def scrub_pool(self, pool_name: str) -> Dict[str, str]:
|
||||
"""
|
||||
Start or resume scrub on pool
|
||||
|
||||
Reference in New Issue
Block a user