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 }) {
|
function EnclosureCard({ enclosure, view, onSelect, selectedSerial, t }) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -762,6 +863,13 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -76,12 +76,34 @@ class EnclosureWithDrives(BaseModel):
|
|||||||
slots: list[SlotWithDrive]
|
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):
|
class Overview(BaseModel):
|
||||||
healthy: bool
|
healthy: bool
|
||||||
drive_count: int
|
drive_count: int
|
||||||
warning_count: int
|
warning_count: int
|
||||||
error_count: int
|
error_count: int
|
||||||
enclosures: list[EnclosureWithDrives]
|
enclosures: list[EnclosureWithDrives]
|
||||||
|
host_drives: list[HostDrive] = []
|
||||||
|
|
||||||
|
|
||||||
class HealthCheck(BaseModel):
|
class HealthCheck(BaseModel):
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ from fastapi import APIRouter
|
|||||||
from models.schemas import (
|
from models.schemas import (
|
||||||
DriveHealthSummary,
|
DriveHealthSummary,
|
||||||
EnclosureWithDrives,
|
EnclosureWithDrives,
|
||||||
|
HostDrive,
|
||||||
Overview,
|
Overview,
|
||||||
SlotWithDrive,
|
SlotWithDrive,
|
||||||
)
|
)
|
||||||
from services.enclosure import discover_enclosures, list_slots
|
from services.enclosure import discover_enclosures, list_slots
|
||||||
|
from services.host import get_host_drives
|
||||||
from services.smart import get_smart_data
|
from services.smart import get_smart_data
|
||||||
from services.zfs import get_zfs_pool_map
|
from services.zfs import get_zfs_pool_map
|
||||||
|
|
||||||
@@ -119,10 +121,24 @@ async def get_overview():
|
|||||||
slots=slots_out,
|
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(
|
return Overview(
|
||||||
healthy=all_healthy and errors == 0,
|
healthy=all_healthy and errors == 0,
|
||||||
drive_count=total_drives,
|
drive_count=total_drives,
|
||||||
warning_count=warnings,
|
warning_count=warnings,
|
||||||
error_count=errors,
|
error_count=errors,
|
||||||
enclosures=enc_results,
|
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