Feature: Disk-ID Namen + SMART-Daten on demand im VdevTree
- Backend: get_disk_id_map() liest /dev/disk/by-id/ dynamisch aus (ata/nvme/scsi/wwn)
- Backend: _annotate_disk_ids() hängt disk_id an Leaf-Vdevs in get_pool_status()
- Backend: get_smart_info() liest smartctl --json (Modell, Temp, Health, Stunden, Sektoren)
- Backend: GET /api/pools/disks/{disk}/smart Endpoint
- Frontend: DiskRow zeigt Modellname neben sda/sdb, aufklappbar für SMART-Details
- Frontend: Temp-Spalte farbcodiert (grün/gelb/rot), SMART-Spalte zeigt PASSED/FAILED
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+159
-35
@@ -64,6 +64,144 @@ function UsageBar({ alloc, size }: { alloc: number; size: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
type SmartData = {
|
||||
model?: string
|
||||
serial?: string
|
||||
protocol?: string
|
||||
power_on_hours?: number
|
||||
temperature?: number
|
||||
passed?: boolean
|
||||
reallocated_sectors?: number
|
||||
pending_sectors?: number
|
||||
uncorrectable?: number
|
||||
}
|
||||
|
||||
function DiskRow({
|
||||
v,
|
||||
depth,
|
||||
poolName,
|
||||
onDiskMenu,
|
||||
}: {
|
||||
v: any
|
||||
depth: number
|
||||
poolName: string
|
||||
onDiskMenu: (e: React.MouseEvent, disk: string, poolName: string) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [smart, setSmart] = useState<SmartData | null>(null)
|
||||
const [loadingSmart, setLoadingSmart] = useState(false)
|
||||
|
||||
const toggleSmart = async () => {
|
||||
if (!expanded && smart === null) {
|
||||
setLoadingSmart(true)
|
||||
try {
|
||||
const data = await api.getDiskSmart(v.name)
|
||||
setSmart(data)
|
||||
} catch {
|
||||
setSmart({})
|
||||
} finally {
|
||||
setLoadingSmart(false)
|
||||
}
|
||||
}
|
||||
setExpanded((x) => !x)
|
||||
}
|
||||
|
||||
const diskLabel = v.disk_id
|
||||
? v.disk_id.replace(/^(ata|nvme|scsi|wwn)-/, "").replace(/_/g, " ").replace(/-[A-Z0-9]{12,}$/, "").trim()
|
||||
: null
|
||||
|
||||
const tempColor =
|
||||
smart?.temperature == null
|
||||
? ""
|
||||
: smart.temperature >= 55
|
||||
? "text-red-500"
|
||||
: smart.temperature >= 45
|
||||
? "text-yellow-500"
|
||||
: "text-green-600"
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className="border-b border-border/40 hover:bg-muted/20">
|
||||
<td className="px-4 py-2 font-mono text-xs" style={{ paddingLeft: `${depth * 20 + 16}px` }}>
|
||||
<button
|
||||
className="flex items-center gap-1.5 hover:text-foreground text-left"
|
||||
onClick={toggleSmart}
|
||||
title="SMART details"
|
||||
>
|
||||
{expanded ? <ChevronDown className="w-3 h-3 flex-shrink-0" /> : <ChevronRight className="w-3 h-3 flex-shrink-0" />}
|
||||
<span>{v.name}</span>
|
||||
{diskLabel && (
|
||||
<span className="text-muted-foreground font-sans ml-1 truncate max-w-[160px]" title={v.disk_id}>
|
||||
{diskLabel}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<HealthBadge health={v.state || "—"} />
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-center">{v.read ?? 0}</td>
|
||||
<td className="px-4 py-2 text-xs text-center">{v.write ?? 0}</td>
|
||||
<td className="px-4 py-2 text-xs text-center">{v.cksum ?? 0}</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">
|
||||
{smart?.temperature != null && (
|
||||
<span className={`font-medium ${tempColor}`}>{smart.temperature}°C</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">
|
||||
{smart?.passed != null && (
|
||||
<span className={smart.passed ? "text-green-600" : "text-red-500"}>
|
||||
{smart.passed ? "PASSED" : "FAILED"}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-right">
|
||||
<button
|
||||
className="p-1 rounded hover:bg-muted"
|
||||
onClick={(e) => { e.stopPropagation(); onDiskMenu(e, v.name, poolName) }}
|
||||
>
|
||||
<MoreVertical className="w-3 h-3" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="border-b border-border/40 bg-muted/10">
|
||||
<td colSpan={8} className="px-4 py-3" style={{ paddingLeft: `${depth * 20 + 36}px` }}>
|
||||
{loadingSmart ? (
|
||||
<span className="text-xs text-muted-foreground animate-pulse">Loading SMART data…</span>
|
||||
) : smart && Object.keys(smart).length > 0 ? (
|
||||
<div className="text-xs text-muted-foreground flex flex-wrap gap-x-6 gap-y-1">
|
||||
{smart.model && <span><span className="text-foreground font-medium">Model:</span> {smart.model}</span>}
|
||||
{smart.serial && <span><span className="text-foreground font-medium">Serial:</span> {smart.serial}</span>}
|
||||
{smart.power_on_hours != null && (
|
||||
<span><span className="text-foreground font-medium">Power-On:</span> {smart.power_on_hours.toLocaleString()} h</span>
|
||||
)}
|
||||
{smart.reallocated_sectors != null && (
|
||||
<span className={smart.reallocated_sectors > 0 ? "text-red-500" : ""}>
|
||||
<span className="text-foreground font-medium">Reallocated:</span> {smart.reallocated_sectors}
|
||||
</span>
|
||||
)}
|
||||
{smart.pending_sectors != null && (
|
||||
<span className={smart.pending_sectors > 0 ? "text-yellow-500" : ""}>
|
||||
<span className="text-foreground font-medium">Pending:</span> {smart.pending_sectors}
|
||||
</span>
|
||||
)}
|
||||
{smart.uncorrectable != null && (
|
||||
<span className={smart.uncorrectable > 0 ? "text-red-500" : ""}>
|
||||
<span className="text-foreground font-medium">Uncorrectable:</span> {smart.uncorrectable}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">No SMART data available</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function VdevTree({
|
||||
vdevs,
|
||||
depth = 0,
|
||||
@@ -80,39 +218,25 @@ function VdevTree({
|
||||
return (
|
||||
<>
|
||||
{vdevs.map((v, i) => (
|
||||
<>
|
||||
<tr key={`${depth}-${i}-${v.name}`} className="border-b border-border/40 hover:bg-muted/20">
|
||||
<td className="px-4 py-2 font-mono text-xs" style={{ paddingLeft: `${depth * 20 + 16}px` }}>
|
||||
{v.name}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<HealthBadge health={v.state || "—"} />
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-center">{v.read ?? 0}</td>
|
||||
<td className="px-4 py-2 text-xs text-center">{v.write ?? 0}</td>
|
||||
<td className="px-4 py-2 text-xs text-center">{v.cksum ?? 0}</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">{v.message || ""}</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground break-all">{v.product || ""}</td>
|
||||
<td className="px-4 py-2 text-xs text-right">
|
||||
{isDisk(v) && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-muted"
|
||||
onClick={(e) => { e.stopPropagation(); onDiskMenu(e, v.name, poolName) }}
|
||||
>
|
||||
<MoreVertical className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{v.children && v.children.length > 0 && (
|
||||
<VdevTree
|
||||
vdevs={v.children}
|
||||
depth={depth + 1}
|
||||
poolName={poolName}
|
||||
onDiskMenu={onDiskMenu}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
isDisk(v) ? (
|
||||
<DiskRow key={`${depth}-${i}-${v.name}`} v={v} depth={depth} poolName={poolName} onDiskMenu={onDiskMenu} />
|
||||
) : (
|
||||
<>
|
||||
<tr key={`${depth}-${i}-${v.name}`} className="border-b border-border/40 hover:bg-muted/20">
|
||||
<td className="px-4 py-2 font-mono text-xs font-medium" style={{ paddingLeft: `${depth * 20 + 16}px` }}>
|
||||
{v.name}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<HealthBadge health={v.state || "—"} />
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-center">{v.read ?? 0}</td>
|
||||
<td className="px-4 py-2 text-xs text-center">{v.write ?? 0}</td>
|
||||
<td className="px-4 py-2 text-xs text-center">{v.cksum ?? 0}</td>
|
||||
<td /><td /><td />
|
||||
</tr>
|
||||
<VdevTree vdevs={v.children} depth={depth + 1} poolName={poolName} onDiskMenu={onDiskMenu} />
|
||||
</>
|
||||
)
|
||||
))}
|
||||
</>
|
||||
)
|
||||
@@ -679,8 +803,8 @@ export default function ZfsPage() {
|
||||
<th className="px-4 py-2 text-center font-medium">Read</th>
|
||||
<th className="px-4 py-2 text-center font-medium">Write</th>
|
||||
<th className="px-4 py-2 text-center font-medium">Checksum</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Message</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Product</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Temp</th>
|
||||
<th className="px-4 py-2 text-left font-medium">SMART</th>
|
||||
<th className="px-4 py-2 w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
Reference in New Issue
Block a user