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:
@@ -5,6 +5,7 @@ Pool management endpoints
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from typing import List
|
||||
import re
|
||||
|
||||
from services.zfs_runner import zfs_runner
|
||||
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))
|
||||
|
||||
|
||||
@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")
|
||||
async def clear_cache(current_user: str = Depends(get_current_user)):
|
||||
"""
|
||||
|
||||
@@ -6,6 +6,8 @@ Handles subprocess execution, parsing, caching, error handling
|
||||
import subprocess
|
||||
import json
|
||||
import logging
|
||||
import glob
|
||||
import os
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
@@ -196,6 +198,70 @@ class ZFSRunner:
|
||||
|
||||
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]:
|
||||
"""
|
||||
Get detailed pool status including VDEV tree and error counters
|
||||
@@ -235,17 +301,30 @@ class ZFSRunner:
|
||||
config_lines.append(line)
|
||||
|
||||
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[0] is the pool root node; its children are the top-level vdevs
|
||||
if parsed and parsed[0]["name"] == pool_name:
|
||||
status["vdevs"] = parsed[0]["children"]
|
||||
else:
|
||||
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
|
||||
|
||||
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]:
|
||||
"""
|
||||
Start or resume scrub on pool
|
||||
|
||||
+147
-23
@@ -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,9 +218,12 @@ function VdevTree({
|
||||
return (
|
||||
<>
|
||||
{vdevs.map((v, i) => (
|
||||
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" style={{ paddingLeft: `${depth * 20 + 16}px` }}>
|
||||
<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">
|
||||
@@ -91,28 +232,11 @@ function VdevTree({
|
||||
<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>
|
||||
<td /><td /><td />
|
||||
</tr>
|
||||
{v.children && v.children.length > 0 && (
|
||||
<VdevTree
|
||||
vdevs={v.children}
|
||||
depth={depth + 1}
|
||||
poolName={poolName}
|
||||
onDiskMenu={onDiskMenu}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -253,6 +253,21 @@ export class ZFSManagerAPI {
|
||||
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
|
||||
async getDatasets(pool: string = "tank"): Promise<Dataset[]> {
|
||||
const response = await this.client.get("/api/datasets/", { params: { pool } })
|
||||
|
||||
Reference in New Issue
Block a user