Add host drives section for non-enclosure drives
This commit is contained in:
@@ -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" : ""} · 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}°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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
111
services/host.py
Normal 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
|
||||
Reference in New Issue
Block a user