Enumerate physical drives behind RAID via smartctl megaraid passthrough

This commit is contained in:
2026-03-07 05:32:08 +00:00
parent 98e435674c
commit 1dd40a1181
5 changed files with 171 additions and 55 deletions

View File

@@ -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 (
<button
onClick={() => onSelect({ slot: d.megaraid_id || "-", device: d.device, populated: true, drive: d })}
style={{
display: "flex", alignItems: "center", gap: 16,
padding: indent ? "10px 16px 10px 44px" : "12px 16px", borderRadius: 12,
background: c.bg, border: `1px solid ${c.border}`,
cursor: "pointer", width: "100%", textAlign: "left",
transition: "all 0.18s",
}}
onMouseEnter={(e) => { e.currentTarget.style.transform = "scale(1.01)"; e.currentTarget.style.boxShadow = "0 4px 12px rgba(0,0,0,0.15)"; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = "scale(1)"; e.currentTarget.style.boxShadow = "none"; }}
>
<span style={{ width: 9, height: 9, borderRadius: "50%", background: c.dot, flexShrink: 0 }} />
<span style={{
fontSize: indent ? 13 : 14, fontWeight: 700, color: t.text,
fontFamily: "'JetBrains Mono', monospace", minWidth: 80,
}}>
{indent && d.megaraid_id != null ? `disk ${d.megaraid_id}` : d.device}
</span>
{driveTypeBadge(d.drive_type, t)}
<span style={{
fontSize: 13, color: t.text, flex: 1,
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
}}>
{d.model || "\u2014"}
</span>
<span style={{
fontSize: 12, color: t.textSecondary,
fontFamily: "'JetBrains Mono', monospace",
}}>
{formatCapacity(d.capacity_bytes)}
</span>
{d.temperature_c != null && (
<span style={{
fontSize: 12, fontWeight: 600,
color: d.temperature_c >= 40 ? t.health.warning.text : t.text,
}}>
{d.temperature_c}&deg;C
</span>
)}
{d.power_on_hours != null && indent && (
<span style={{ fontSize: 11, color: t.textSecondary }}>
{formatHours(d.power_on_hours)}
</span>
)}
{d.zfs_pool && (
<span style={{
fontSize: 11, fontWeight: 700, color: t.accent,
fontFamily: "'JetBrains Mono', monospace",
}}>
{d.zfs_pool}
</span>
)}
<StatusPill status={healthStatus} t={t} />
</button>
);
}
function HostDrivesCard({ drives, onSelect, t }) { function HostDrivesCard({ drives, onSelect, t }) {
if (!drives || drives.length === 0) return null; if (!drives || drives.length === 0) return null;
const totalCount = drives.reduce((n, d) => n + 1 + (d.physical_drives?.length || 0), 0);
return ( return (
<div style={{ <div style={{
background: t.cardBg, borderRadius: 16, background: t.cardBg, borderRadius: 16,
@@ -612,69 +675,32 @@ function HostDrivesCard({ drives, onSelect, t }) {
<div> <div>
<div style={{ fontSize: 16, fontWeight: 700, color: t.text }}>Host Drives</div> <div style={{ fontSize: 16, fontWeight: 700, color: t.text }}>Host Drives</div>
<div style={{ fontSize: 12, color: t.textSecondary, marginTop: 2 }}> <div style={{ fontSize: 12, color: t.textSecondary, marginTop: 2 }}>
{drives.length} drive{drives.length !== 1 ? "s" : ""} &middot; non-enclosure {totalCount} drive{totalCount !== 1 ? "s" : ""} &middot; non-enclosure
</div> </div>
</div> </div>
</div> </div>
<div style={{ padding: 16 }}> <div style={{ padding: 16 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}> <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{drives.map((d) => { {drives.map((d) => (
const healthStatus = d.health_status || "healthy"; <React.Fragment key={d.device}>
const c = t.health[healthStatus] || t.health.healthy; <div style={{ position: "relative" }}>
return ( <HostDriveRow d={d} onSelect={onSelect} t={t} />
<button {d.physical_drives?.length > 0 && (
key={d.device}
onClick={() => onSelect({ slot: "-", device: d.device, populated: true, drive: d })}
style={{
display: "flex", alignItems: "center", gap: 16,
padding: "12px 16px", borderRadius: 12,
background: c.bg, border: `1px solid ${c.border}`,
cursor: "pointer", width: "100%", textAlign: "left",
transition: "all 0.18s",
}}
onMouseEnter={(e) => { e.currentTarget.style.transform = "scale(1.01)"; e.currentTarget.style.boxShadow = "0 4px 12px rgba(0,0,0,0.15)"; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = "scale(1)"; e.currentTarget.style.boxShadow = "none"; }}
>
<span style={{ width: 9, height: 9, borderRadius: "50%", background: c.dot, flexShrink: 0 }} />
<span style={{
fontSize: 14, fontWeight: 700, color: t.text,
fontFamily: "'JetBrains Mono', monospace", minWidth: 80,
}}>
{d.device}
</span>
{driveTypeBadge(d.drive_type, t)}
<span style={{
fontSize: 13, color: t.text, flex: 1,
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
}}>
{d.model || "\u2014"}
</span>
<span style={{
fontSize: 12, color: t.textSecondary,
fontFamily: "'JetBrains Mono', monospace",
}}>
{formatCapacity(d.capacity_bytes)}
</span>
{d.temperature_c != null && (
<span style={{ <span style={{
fontSize: 12, fontWeight: 600, position: "absolute", top: 8, right: 8,
color: d.temperature_c >= 40 ? t.health.warning.text : t.text, fontSize: 10, fontWeight: 700, color: t.textMuted,
background: t.health.empty.bg, border: `1px solid ${t.cardBorder}`,
padding: "2px 6px", borderRadius: 4,
}}> }}>
{d.temperature_c}&deg;C {d.physical_drives.length} disks
</span> </span>
)} )}
{d.zfs_pool && ( </div>
<span style={{ {d.physical_drives?.map((pd, i) => (
fontSize: 11, fontWeight: 700, color: t.accent, <HostDriveRow key={pd.serial || i} d={pd} onSelect={onSelect} t={t} indent />
fontFamily: "'JetBrains Mono', monospace", ))}
}}> </React.Fragment>
{d.zfs_pool} ))}
</span>
)}
<StatusPill status={healthStatus} t={t} />
</button>
);
})}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -95,6 +95,8 @@ class HostDrive(BaseModel):
zfs_vdev: str | None = None zfs_vdev: str | None = None
zfs_state: str | None = None zfs_state: str | None = None
health_status: str = "healthy" health_status: str = "healthy"
megaraid_id: str | None = None
physical_drives: list["HostDrive"] = []
class Overview(BaseModel): class Overview(BaseModel):

View File

@@ -132,6 +132,15 @@ async def get_overview():
all_healthy = False all_healthy = False
elif hs == "warning": elif hs == "warning":
warnings += 1 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)) host_drives_out.append(HostDrive(**hd))
return Overview( return Overview(

View File

@@ -3,7 +3,7 @@ import json
import logging import logging
from services.enclosure import discover_enclosures, list_slots 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 from services.zfs import get_zfs_pool_map
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -106,6 +106,31 @@ async def get_host_drives() -> list[dict]:
"zfs_vdev": zfs_info.get("vdev"), "zfs_vdev": zfs_info.get("vdev"),
"zfs_state": zfs_info.get("state"), "zfs_state": zfs_info.get("state"),
"health_status": health_status, "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 return results

View File

@@ -145,6 +145,60 @@ def _parse_smart_json(device: str, data: dict) -> dict:
return result 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: def _get_attr_raw(attrs: list[dict], attr_id: int) -> int | None:
"""Get the raw_value for a SMART attribute by ID.""" """Get the raw_value for a SMART attribute by ID."""
for attr in attrs: for attr in attrs: