Add host drives section for non-enclosure drives

This commit is contained in:
2026-03-07 05:16:30 +00:00
parent 927a5ccf3a
commit 798308d2bf
4 changed files with 257 additions and 0 deletions

View File

@@ -580,6 +580,107 @@ function DriveDetail({ slot, onClose, t }) {
);
}
const driveTypeBadge = (type, t) => {
const labels = { nvme: "NVMe", raid: "RAID", ssd: "SSD", hdd: "HDD", disk: "Disk" };
return (
<span style={{
fontSize: 10, fontWeight: 700, textTransform: "uppercase", letterSpacing: 0.5,
padding: "2px 8px", borderRadius: 4,
background: type === "nvme" ? t.toggleActive + "22" : t.health.empty.bg,
color: type === "nvme" ? t.toggleActive : t.textSecondary,
border: `1px solid ${type === "nvme" ? t.toggleActive + "44" : t.cardBorder}`,
}}>
{labels[type] || type}
</span>
);
};
function HostDrivesCard({ drives, onSelect, t }) {
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" : ""} &middot; 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 c = t.health[healthStatus] || t.health.healthy;
return (
<button
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={{
fontSize: 12, fontWeight: 600,
color: d.temperature_c >= 40 ? t.health.warning.text : t.text,
}}>
{d.temperature_c}&deg;C
</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>
);
})}
</div>
</div>
</div>
);
}
function EnclosureCard({ enclosure, view, onSelect, selectedSerial, t }) {
return (
<div style={{
@@ -762,6 +863,13 @@ export default function App() {
/>
))}
</div>
{/* Host Drives */}
{data.host_drives && data.host_drives.length > 0 && (
<div style={{ marginTop: 20 }}>
<HostDrivesCard drives={data.host_drives} onSelect={setSelected} t={t} />
</div>
)}
</>
)}
</div>

View File

@@ -76,12 +76,34 @@ class EnclosureWithDrives(BaseModel):
slots: list[SlotWithDrive]
class HostDrive(BaseModel):
device: str
drive_type: str = "disk"
model: str | None = None
serial: str | None = None
wwn: str | None = None
firmware: str | None = None
capacity_bytes: int | None = None
smart_healthy: bool | None = None
smart_supported: bool = True
temperature_c: int | None = None
power_on_hours: int | None = None
reallocated_sectors: int | None = None
pending_sectors: int | None = None
uncorrectable_errors: int | None = None
zfs_pool: str | None = None
zfs_vdev: str | None = None
zfs_state: str | None = None
health_status: str = "healthy"
class Overview(BaseModel):
healthy: bool
drive_count: int
warning_count: int
error_count: int
enclosures: list[EnclosureWithDrives]
host_drives: list[HostDrive] = []
class HealthCheck(BaseModel):

View File

@@ -6,10 +6,12 @@ from fastapi import APIRouter
from models.schemas import (
DriveHealthSummary,
EnclosureWithDrives,
HostDrive,
Overview,
SlotWithDrive,
)
from services.enclosure import discover_enclosures, list_slots
from services.host import get_host_drives
from services.smart import get_smart_data
from services.zfs import get_zfs_pool_map
@@ -119,10 +121,24 @@ async def get_overview():
slots=slots_out,
))
# Host drives (non-enclosure)
host_drives_raw = await get_host_drives()
host_drives_out: list[HostDrive] = []
for hd in host_drives_raw:
total_drives += 1
hs = hd.get("health_status", "healthy")
if hs == "error":
errors += 1
all_healthy = False
elif hs == "warning":
warnings += 1
host_drives_out.append(HostDrive(**hd))
return Overview(
healthy=all_healthy and errors == 0,
drive_count=total_drives,
warning_count=warnings,
error_count=errors,
enclosures=enc_results,
host_drives=host_drives_out,
)

111
services/host.py Normal file
View File

@@ -0,0 +1,111 @@
import asyncio
import json
import logging
from services.enclosure import discover_enclosures, list_slots
from services.smart import get_smart_data
from services.zfs import get_zfs_pool_map
logger = logging.getLogger(__name__)
async def get_host_drives() -> list[dict]:
"""Discover non-enclosure block devices and return SMART data for each."""
# Get all block devices via lsblk
try:
proc = await asyncio.create_subprocess_exec(
"lsblk", "-d", "-o", "NAME,SIZE,TYPE,MODEL,ROTA,TRAN", "-J",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
lsblk_data = json.loads(stdout)
except (FileNotFoundError, json.JSONDecodeError) as e:
logger.warning("lsblk failed: %s", e)
return []
# Collect all enclosure-mapped devices
enclosure_devices: set[str] = set()
for enc in discover_enclosures():
for slot in list_slots(enc["id"]):
if slot["device"]:
enclosure_devices.add(slot["device"])
# Filter to host-only disks
host_devices: list[dict] = []
for dev in lsblk_data.get("blockdevices", []):
name = dev.get("name", "")
dev_type = dev.get("type", "")
# Skip non-disk types and enclosure drives
if dev_type != "disk":
continue
if name in enclosure_devices:
continue
# Determine drive type from transport/model
tran = (dev.get("tran") or "").lower()
model = (dev.get("model") or "").lower()
rota = dev.get("rota")
if tran == "nvme" or name.startswith("nvme"):
drive_type = "nvme"
elif "perc" in model or "raid" in model or "megaraid" in model:
drive_type = "raid"
elif rota is False or rota == "0" or rota == 0:
drive_type = "ssd"
else:
drive_type = "hdd"
host_devices.append({"name": name, "drive_type": drive_type})
# Fetch SMART + ZFS data concurrently
pool_map = await get_zfs_pool_map()
smart_tasks = [get_smart_data(d["name"]) for d in host_devices]
smart_results = await asyncio.gather(*smart_tasks, return_exceptions=True)
results: list[dict] = []
for dev_info, smart in zip(host_devices, smart_results):
name = dev_info["name"]
if isinstance(smart, Exception):
logger.warning("SMART query failed for host drive %s: %s", name, smart)
smart = {"device": name, "smart_supported": False}
# Compute health_status (same logic as overview.py)
healthy = smart.get("smart_healthy")
realloc = smart.get("reallocated_sectors") or 0
pending = smart.get("pending_sectors") or 0
unc = smart.get("uncorrectable_errors") or 0
if healthy is False:
health_status = "error"
elif realloc > 0 or pending > 0 or unc > 0 or (healthy is None and smart.get("smart_supported", True)):
health_status = "warning"
else:
health_status = "healthy"
zfs_info = pool_map.get(name, {})
results.append({
"device": name,
"drive_type": dev_info["drive_type"],
"model": smart.get("model"),
"serial": smart.get("serial"),
"wwn": smart.get("wwn"),
"firmware": smart.get("firmware"),
"capacity_bytes": smart.get("capacity_bytes"),
"smart_healthy": healthy,
"smart_supported": smart.get("smart_supported", True),
"temperature_c": smart.get("temperature_c"),
"power_on_hours": smart.get("power_on_hours"),
"reallocated_sectors": smart.get("reallocated_sectors"),
"pending_sectors": smart.get("pending_sectors"),
"uncorrectable_errors": smart.get("uncorrectable_errors"),
"zfs_pool": zfs_info.get("pool"),
"zfs_vdev": zfs_info.get("vdev"),
"zfs_state": zfs_info.get("state"),
"health_status": health_status,
})
return results