Enumerate physical drives behind RAID via smartctl megaraid passthrough
This commit is contained in:
@@ -595,39 +595,15 @@ const driveTypeBadge = (type, t) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function HostDrivesCard({ drives, onSelect, t }) {
|
function HostDriveRow({ d, onSelect, t, indent }) {
|
||||||
if (!drives || drives.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
background: t.cardBg, borderRadius: 16,
|
|
||||||
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
|
|
||||||
border: `1px solid ${t.cardBorder}`,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
padding: "16px 20px",
|
|
||||||
borderBottom: `1px solid ${t.divider}`,
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
|
||||||
}}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 16, fontWeight: 700, color: t.text }}>Host Drives</div>
|
|
||||||
<div style={{ fontSize: 12, color: t.textSecondary, marginTop: 2 }}>
|
|
||||||
{drives.length} drive{drives.length !== 1 ? "s" : ""} · non-enclosure
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: 16 }}>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
||||||
{drives.map((d) => {
|
|
||||||
const healthStatus = d.health_status || "healthy";
|
const healthStatus = d.health_status || "healthy";
|
||||||
const c = t.health[healthStatus] || t.health.healthy;
|
const c = t.health[healthStatus] || t.health.healthy;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={d.device}
|
onClick={() => onSelect({ slot: d.megaraid_id || "-", device: d.device, populated: true, drive: d })}
|
||||||
onClick={() => onSelect({ slot: "-", device: d.device, populated: true, drive: d })}
|
|
||||||
style={{
|
style={{
|
||||||
display: "flex", alignItems: "center", gap: 16,
|
display: "flex", alignItems: "center", gap: 16,
|
||||||
padding: "12px 16px", borderRadius: 12,
|
padding: indent ? "10px 16px 10px 44px" : "12px 16px", borderRadius: 12,
|
||||||
background: c.bg, border: `1px solid ${c.border}`,
|
background: c.bg, border: `1px solid ${c.border}`,
|
||||||
cursor: "pointer", width: "100%", textAlign: "left",
|
cursor: "pointer", width: "100%", textAlign: "left",
|
||||||
transition: "all 0.18s",
|
transition: "all 0.18s",
|
||||||
@@ -637,10 +613,10 @@ function HostDrivesCard({ drives, onSelect, t }) {
|
|||||||
>
|
>
|
||||||
<span style={{ width: 9, height: 9, borderRadius: "50%", background: c.dot, flexShrink: 0 }} />
|
<span style={{ width: 9, height: 9, borderRadius: "50%", background: c.dot, flexShrink: 0 }} />
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: 14, fontWeight: 700, color: t.text,
|
fontSize: indent ? 13 : 14, fontWeight: 700, color: t.text,
|
||||||
fontFamily: "'JetBrains Mono', monospace", minWidth: 80,
|
fontFamily: "'JetBrains Mono', monospace", minWidth: 80,
|
||||||
}}>
|
}}>
|
||||||
{d.device}
|
{indent && d.megaraid_id != null ? `disk ${d.megaraid_id}` : d.device}
|
||||||
</span>
|
</span>
|
||||||
{driveTypeBadge(d.drive_type, t)}
|
{driveTypeBadge(d.drive_type, t)}
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -663,6 +639,11 @@ function HostDrivesCard({ drives, onSelect, t }) {
|
|||||||
{d.temperature_c}°C
|
{d.temperature_c}°C
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{d.power_on_hours != null && indent && (
|
||||||
|
<span style={{ fontSize: 11, color: t.textSecondary }}>
|
||||||
|
{formatHours(d.power_on_hours)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{d.zfs_pool && (
|
{d.zfs_pool && (
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: 11, fontWeight: 700, color: t.accent,
|
fontSize: 11, fontWeight: 700, color: t.accent,
|
||||||
@@ -674,7 +655,52 @@ function HostDrivesCard({ drives, onSelect, t }) {
|
|||||||
<StatusPill status={healthStatus} t={t} />
|
<StatusPill status={healthStatus} t={t} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{
|
||||||
|
background: t.cardBg, borderRadius: 16,
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
|
||||||
|
border: `1px solid ${t.cardBorder}`,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
padding: "16px 20px",
|
||||||
|
borderBottom: `1px solid ${t.divider}`,
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 700, color: t.text }}>Host Drives</div>
|
||||||
|
<div style={{ fontSize: 12, color: t.textSecondary, marginTop: 2 }}>
|
||||||
|
{totalCount} drive{totalCount !== 1 ? "s" : ""} · non-enclosure
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{drives.map((d) => (
|
||||||
|
<React.Fragment key={d.device}>
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<HostDriveRow d={d} onSelect={onSelect} t={t} />
|
||||||
|
{d.physical_drives?.length > 0 && (
|
||||||
|
<span style={{
|
||||||
|
position: "absolute", top: 8, right: 8,
|
||||||
|
fontSize: 10, fontWeight: 700, color: t.textMuted,
|
||||||
|
background: t.health.empty.bg, border: `1px solid ${t.cardBorder}`,
|
||||||
|
padding: "2px 6px", borderRadius: 4,
|
||||||
|
}}>
|
||||||
|
{d.physical_drives.length} disks
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{d.physical_drives?.map((pd, i) => (
|
||||||
|
<HostDriveRow key={pd.serial || i} d={pd} onSelect={onSelect} t={t} indent />
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user