diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 60f9539..459ea96 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -595,8 +595,71 @@ const driveTypeBadge = (type, t) => {
);
};
+function HostDriveRow({ d, onSelect, t, indent }) {
+ const healthStatus = d.health_status || "healthy";
+ const c = t.health[healthStatus] || t.health.healthy;
+ return (
+
+ );
+}
+
function HostDrivesCard({ drives, onSelect, t }) {
if (!drives || drives.length === 0) return null;
+ const totalCount = drives.reduce((n, d) => n + 1 + (d.physical_drives?.length || 0), 0);
return (
Host Drives
- {drives.length} drive{drives.length !== 1 ? "s" : ""} · non-enclosure
+ {totalCount} drive{totalCount !== 1 ? "s" : ""} · non-enclosure
- {drives.map((d) => {
- const healthStatus = d.health_status || "healthy";
- const c = t.health[healthStatus] || t.health.healthy;
- return (
-
diff --git a/models/schemas.py b/models/schemas.py
index 618473c..6f45a68 100644
--- a/models/schemas.py
+++ b/models/schemas.py
@@ -95,6 +95,8 @@ class HostDrive(BaseModel):
zfs_vdev: str | None = None
zfs_state: str | None = None
health_status: str = "healthy"
+ megaraid_id: str | None = None
+ physical_drives: list["HostDrive"] = []
class Overview(BaseModel):
diff --git a/routers/overview.py b/routers/overview.py
index c640796..28c884d 100644
--- a/routers/overview.py
+++ b/routers/overview.py
@@ -132,6 +132,15 @@ async def get_overview():
all_healthy = False
elif hs == "warning":
warnings += 1
+ # Count physical drives behind RAID controllers
+ for pd in hd.get("physical_drives", []):
+ total_drives += 1
+ pd_hs = pd.get("health_status", "healthy")
+ if pd_hs == "error":
+ errors += 1
+ all_healthy = False
+ elif pd_hs == "warning":
+ warnings += 1
host_drives_out.append(HostDrive(**hd))
return Overview(
diff --git a/services/host.py b/services/host.py
index 462bfb7..781a745 100644
--- a/services/host.py
+++ b/services/host.py
@@ -3,7 +3,7 @@ import json
import logging
from services.enclosure import discover_enclosures, list_slots
-from services.smart import get_smart_data
+from services.smart import get_smart_data, scan_megaraid_drives
from services.zfs import get_zfs_pool_map
logger = logging.getLogger(__name__)
@@ -106,6 +106,31 @@ async def get_host_drives() -> list[dict]:
"zfs_vdev": zfs_info.get("vdev"),
"zfs_state": zfs_info.get("state"),
"health_status": health_status,
+ "physical_drives": [],
})
+ # Discover physical drives behind RAID controllers
+ has_raid = any(r["drive_type"] == "raid" and not r["smart_supported"] for r in results)
+ if has_raid:
+ megaraid_drives = await scan_megaraid_drives()
+ for pd in megaraid_drives:
+ pd_healthy = pd.get("smart_healthy")
+ pd_realloc = pd.get("reallocated_sectors") or 0
+ pd_pending = pd.get("pending_sectors") or 0
+ pd_unc = pd.get("uncorrectable_errors") or 0
+ if pd_healthy is False:
+ pd["health_status"] = "error"
+ elif pd_realloc > 0 or pd_pending > 0 or pd_unc > 0:
+ pd["health_status"] = "warning"
+ else:
+ pd["health_status"] = "healthy"
+ pd["drive_type"] = "physical"
+ pd["physical_drives"] = []
+
+ # Attach to the first RAID host drive
+ for r in results:
+ if r["drive_type"] == "raid" and not r["smart_supported"]:
+ r["physical_drives"] = megaraid_drives
+ break
+
return results
diff --git a/services/smart.py b/services/smart.py
index b0b700e..30716f9 100644
--- a/services/smart.py
+++ b/services/smart.py
@@ -145,6 +145,60 @@ def _parse_smart_json(device: str, data: dict) -> dict:
return result
+async def scan_megaraid_drives() -> list[dict]:
+ """Discover physical drives behind MegaRAID controllers via smartctl --scan."""
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ "smartctl", "--scan", "-j",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ stdout, _ = await proc.communicate()
+ scan_data = json.loads(stdout)
+ except (FileNotFoundError, json.JSONDecodeError) as e:
+ logger.warning("smartctl --scan failed: %s", e)
+ return []
+
+ devices = scan_data.get("devices", [])
+ megaraid_entries = [
+ d for d in devices
+ if "megaraid" in (d.get("type") or "")
+ ]
+
+ if not megaraid_entries:
+ return []
+
+ # Query SMART for each physical drive concurrently
+ async def _query(entry: dict) -> dict | None:
+ dev_path = entry["name"]
+ dev_type = entry["type"]
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ "smartctl", "-a", "-j", "-d", dev_type, dev_path,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ stdout, _ = await proc.communicate()
+ if not stdout:
+ return None
+ data = json.loads(stdout)
+ except (FileNotFoundError, json.JSONDecodeError):
+ return None
+
+ # Extract the disk number from type like "sat+megaraid,0"
+ megaraid_id = dev_type.split("megaraid,")[-1] if "megaraid," in dev_type else dev_type
+
+ result = _parse_smart_json(f"megaraid:{megaraid_id}", data)
+ result["megaraid_id"] = megaraid_id
+ result["megaraid_type"] = dev_type
+ result["megaraid_device"] = dev_path
+ return result
+
+ tasks = [_query(e) for e in megaraid_entries]
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+ return [r for r in results if isinstance(r, dict)]
+
+
def _get_attr_raw(attrs: list[dict], attr_id: int) -> int | None:
"""Get the raw_value for a SMART attribute by ID."""
for attr in attrs: