diff --git a/backend/routers/pools.py b/backend/routers/pools.py index 9780e34..1feeb42 100644 --- a/backend/routers/pools.py +++ b/backend/routers/pools.py @@ -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)): """ diff --git a/backend/services/zfs_runner.py b/backend/services/zfs_runner.py index 23860cf..32dc54c 100644 --- a/backend/services/zfs_runner.py +++ b/backend/services/zfs_runner.py @@ -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 diff --git a/frontend/app/zfs/page.tsx b/frontend/app/zfs/page.tsx index a07cff0..93c944e 100644 --- a/frontend/app/zfs/page.tsx +++ b/frontend/app/zfs/page.tsx @@ -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(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 ( + <> + + + + + + + + {v.read ?? 0} + {v.write ?? 0} + {v.cksum ?? 0} + + {smart?.temperature != null && ( + {smart.temperature}°C + )} + + + {smart?.passed != null && ( + + {smart.passed ? "PASSED" : "FAILED"} + + )} + + + + + + {expanded && ( + + + {loadingSmart ? ( + Loading SMART data… + ) : smart && Object.keys(smart).length > 0 ? ( +
+ {smart.model && Model: {smart.model}} + {smart.serial && Serial: {smart.serial}} + {smart.power_on_hours != null && ( + Power-On: {smart.power_on_hours.toLocaleString()} h + )} + {smart.reallocated_sectors != null && ( + 0 ? "text-red-500" : ""}> + Reallocated: {smart.reallocated_sectors} + + )} + {smart.pending_sectors != null && ( + 0 ? "text-yellow-500" : ""}> + Pending: {smart.pending_sectors} + + )} + {smart.uncorrectable != null && ( + 0 ? "text-red-500" : ""}> + Uncorrectable: {smart.uncorrectable} + + )} +
+ ) : ( + No SMART data available + )} + + + )} + + ) +} + function VdevTree({ vdevs, depth = 0, @@ -80,39 +218,25 @@ function VdevTree({ return ( <> {vdevs.map((v, i) => ( - <> - - - {v.name} - - - - - {v.read ?? 0} - {v.write ?? 0} - {v.cksum ?? 0} - {v.message || ""} - {v.product || ""} - - {isDisk(v) && ( - - )} - - - {v.children && v.children.length > 0 && ( - - )} - + isDisk(v) ? ( + + ) : ( + <> + + + {v.name} + + + + + {v.read ?? 0} + {v.write ?? 0} + {v.cksum ?? 0} + + + + + ) ))} ) @@ -679,8 +803,8 @@ export default function ZfsPage() { Read Write Checksum - Message - Product + Temp + SMART diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 1aa954f..d101af7 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -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 { const response = await this.client.get("/api/datasets/", { params: { pool } })