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:
2026-06-05 09:36:36 +02:00
parent 202fdfaaeb
commit ce78f0ae95
4 changed files with 273 additions and 38 deletions
+159 -35
View File
@@ -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>