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
+17
View File
@@ -5,6 +5,7 @@ Pool management endpoints
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from typing import List from typing import List
import re
from services.zfs_runner import zfs_runner from services.zfs_runner import zfs_runner
from services.auth import auth_service from services.auth import auth_service
@@ -164,6 +165,22 @@ async def disk_detach(pool_name: str, disk: str, current_user: str = Depends(get
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/disks/{disk}/smart")
async def get_disk_smart(disk: str, current_user: str = Depends(get_current_user)):
"""
Get SMART data for a specific disk (e.g. sda, sdb, nvme0n1).
Requires smartmontools installed on the system.
"""
# Basic validation to prevent path traversal
if not re.match(r'^[a-zA-Z0-9]+$', disk):
raise HTTPException(status_code=400, detail="Invalid disk name")
try:
data = zfs_runner.get_smart_info(disk)
return data
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/clear-cache") @router.post("/clear-cache")
async def clear_cache(current_user: str = Depends(get_current_user)): async def clear_cache(current_user: str = Depends(get_current_user)):
""" """
+82 -3
View File
@@ -6,6 +6,8 @@ Handles subprocess execution, parsing, caching, error handling
import subprocess import subprocess
import json import json
import logging import logging
import glob
import os
from typing import Dict, List, Any, Optional, Tuple from typing import Dict, List, Any, Optional, Tuple
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -196,6 +198,70 @@ class ZFSRunner:
return roots return roots
def get_disk_id_map(self) -> Dict[str, str]:
"""
Build mapping {sda: ata-WDC_WD20EZRZ-..., nvme0n1: nvme-Samsung_...} from /dev/disk/by-id/.
Prefers ata- prefix, then nvme-, then scsi-, then wwn-.
"""
mapping: Dict[str, str] = {}
priority = ["ata-", "nvme-", "scsi-", "wwn-"]
for pattern in priority:
for link in glob.glob(f"/dev/disk/by-id/{pattern}*"):
if "-part" in os.path.basename(link):
continue
try:
target = os.path.realpath(link)
disk = os.path.basename(target)
if disk not in mapping:
mapping[disk] = os.path.basename(link)
except OSError:
continue
return mapping
def get_smart_info(self, disk: str) -> Dict[str, Any]:
"""
Run smartctl -A -i --json on /dev/disk and return parsed health data.
Returns empty dict if smartctl unavailable or disk not SMART-capable.
"""
stdout, stderr, rc = self.run_command(["smartctl", "-A", "-i", "--json", f"/dev/{disk}"])
if not stdout:
return {}
try:
data = json.loads(stdout)
except json.JSONDecodeError:
return {}
result: Dict[str, Any] = {}
# Device info
device = data.get("device", {})
model_info = data.get("model_name") or data.get("model_family", "")
serial = data.get("serial_number", "")
result["model"] = model_info
result["serial"] = serial
result["protocol"] = device.get("protocol", "")
# Power-on hours
result["power_on_hours"] = data.get("power_on_time", {}).get("hours")
# Temperature
temp = data.get("temperature", {})
result["temperature"] = temp.get("current")
# Overall health (from -H flag data embedded in -i)
smart_status = data.get("smart_status", {})
result["passed"] = smart_status.get("passed")
# Key attributes from ata_smart_attributes
attrs = {a["name"]: a for a in data.get("ata_smart_attributes", {}).get("table", [])}
result["reallocated_sectors"] = attrs.get("Reallocated_Sector_Ct", {}).get("raw", {}).get("value", 0)
result["pending_sectors"] = attrs.get("Current_Pending_Sector", {}).get("raw", {}).get("value", 0)
result["uncorrectable"] = attrs.get("Offline_Uncorrectable", {}).get("raw", {}).get("value", 0)
return result
def get_pool_status(self, pool_name: str) -> Dict[str, Any]: def get_pool_status(self, pool_name: str) -> Dict[str, Any]:
""" """
Get detailed pool status including VDEV tree and error counters Get detailed pool status including VDEV tree and error counters
@@ -235,17 +301,30 @@ class ZFSRunner:
config_lines.append(line) config_lines.append(line)
if config_lines: if config_lines:
# Remove the pool name itself (first non-empty line after "NAME" header)
# and parse only child vdevs
parsed = self._parse_vdev_tree(config_lines) parsed = self._parse_vdev_tree(config_lines)
# parsed[0] is the pool root node; its children are the top-level vdevs
if parsed and parsed[0]["name"] == pool_name: if parsed and parsed[0]["name"] == pool_name:
status["vdevs"] = parsed[0]["children"] status["vdevs"] = parsed[0]["children"]
else: else:
status["vdevs"] = parsed status["vdevs"] = parsed
# Annotate leaf vdevs with disk_id from /dev/disk/by-id/
disk_id_map = self.get_disk_id_map()
self._annotate_disk_ids(status["vdevs"], disk_id_map)
return status return status
def _annotate_disk_ids(self, vdevs: List[Dict], disk_id_map: Dict[str, str]) -> None:
"""Recursively annotate leaf vdev nodes with disk_id from by-id map."""
for vdev in vdevs:
children = vdev.get("children", [])
if not children:
name = vdev.get("name", "")
# Strip partition suffix (sda1 → sda)
base = re.sub(r'\d+$', '', name) if name[-1:].isdigit() else name
vdev["disk_id"] = disk_id_map.get(name) or disk_id_map.get(base)
else:
self._annotate_disk_ids(children, disk_id_map)
def scrub_pool(self, pool_name: str) -> Dict[str, str]: def scrub_pool(self, pool_name: str) -> Dict[str, str]:
""" """
Start or resume scrub on pool Start or resume scrub on pool
+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({ function VdevTree({
vdevs, vdevs,
depth = 0, depth = 0,
@@ -80,39 +218,25 @@ function VdevTree({
return ( return (
<> <>
{vdevs.map((v, i) => ( {vdevs.map((v, i) => (
<> isDisk(v) ? (
<tr key={`${depth}-${i}-${v.name}`} className="border-b border-border/40 hover:bg-muted/20"> <DiskRow key={`${depth}-${i}-${v.name}`} v={v} depth={depth} poolName={poolName} onDiskMenu={onDiskMenu} />
<td className="px-4 py-2 font-mono text-xs" style={{ paddingLeft: `${depth * 20 + 16}px` }}> ) : (
{v.name} <>
</td> <tr key={`${depth}-${i}-${v.name}`} className="border-b border-border/40 hover:bg-muted/20">
<td className="px-4 py-2"> <td className="px-4 py-2 font-mono text-xs font-medium" style={{ paddingLeft: `${depth * 20 + 16}px` }}>
<HealthBadge health={v.state || "—"} /> {v.name}
</td> </td>
<td className="px-4 py-2 text-xs text-center">{v.read ?? 0}</td> <td className="px-4 py-2">
<td className="px-4 py-2 text-xs text-center">{v.write ?? 0}</td> <HealthBadge health={v.state || "—"} />
<td className="px-4 py-2 text-xs text-center">{v.cksum ?? 0}</td> </td>
<td className="px-4 py-2 text-xs text-muted-foreground">{v.message || ""}</td> <td className="px-4 py-2 text-xs text-center">{v.read ?? 0}</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-center">{v.write ?? 0}</td>
<td className="px-4 py-2 text-xs text-right"> <td className="px-4 py-2 text-xs text-center">{v.cksum ?? 0}</td>
{isDisk(v) && ( <td /><td /><td />
<button </tr>
className="p-1 rounded hover:bg-muted" <VdevTree vdevs={v.children} depth={depth + 1} poolName={poolName} onDiskMenu={onDiskMenu} />
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}
/>
)}
</>
))} ))}
</> </>
) )
@@ -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">Read</th>
<th className="px-4 py-2 text-center font-medium">Write</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-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">Temp</th>
<th className="px-4 py-2 text-left font-medium">Product</th> <th className="px-4 py-2 text-left font-medium">SMART</th>
<th className="px-4 py-2 w-8" /> <th className="px-4 py-2 w-8" />
</tr> </tr>
</thead> </thead>
+15
View File
@@ -253,6 +253,21 @@ export class ZFSManagerAPI {
return response.data return response.data
} }
async getDiskSmart(disk: string): Promise<{
model?: string
serial?: string
protocol?: string
power_on_hours?: number
temperature?: number
passed?: boolean
reallocated_sectors?: number
pending_sectors?: number
uncorrectable?: number
}> {
const response = await this.client.get(`/api/pools/disks/${disk}/smart`)
return response.data
}
// Datasets // Datasets
async getDatasets(pool: string = "tank"): Promise<Dataset[]> { async getDatasets(pool: string = "tank"): Promise<Dataset[]> {
const response = await this.client.get("/api/datasets/", { params: { pool } }) const response = await this.client.get("/api/datasets/", { params: { pool } })