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 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)):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+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({
|
function VdevTree({
|
||||||
vdevs,
|
vdevs,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
@@ -80,9 +218,12 @@ function VdevTree({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{vdevs.map((v, i) => (
|
{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">
|
<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}
|
{v.name}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
<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.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.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-center">{v.cksum ?? 0}</td>
|
||||||
<td className="px-4 py-2 text-xs text-muted-foreground">{v.message || ""}</td>
|
<td /><td /><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>
|
</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">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>
|
||||||
|
|||||||
@@ -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 } })
|
||||||
|
|||||||
Reference in New Issue
Block a user