Update AppImage

This commit is contained in:
MacRimi
2025-10-02 23:20:59 +02:00
parent a39fe5ff3b
commit 2ab49cc545
3 changed files with 400 additions and 69 deletions

View File

@@ -2,9 +2,10 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Thermometer } from "lucide-react" import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Thermometer, Info } from "lucide-react"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress" import { Progress } from "@/components/ui/progress"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
interface DiskInfo { interface DiskInfo {
name: string name: string
@@ -21,6 +22,9 @@ interface DiskInfo {
used?: number used?: number
available?: number available?: number
usage_percent?: number usage_percent?: number
reallocated_sectors?: number
pending_sectors?: number
crc_errors?: number
} }
interface ZFSPool { interface ZFSPool {
@@ -37,12 +41,18 @@ interface StorageData {
available: number available: number
disks: DiskInfo[] disks: DiskInfo[]
zfs_pools: ZFSPool[] zfs_pools: ZFSPool[]
disk_count: number
healthy_disks: number
warning_disks: number
critical_disks: number
error?: string error?: string
} }
export function StorageOverview() { export function StorageOverview() {
const [storageData, setStorageData] = useState<StorageData | null>(null) const [storageData, setStorageData] = useState<StorageData | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [selectedDisk, setSelectedDisk] = useState<DiskInfo | null>(null)
const [detailsOpen, setDetailsOpen] = useState(false)
const fetchStorageData = async () => { const fetchStorageData = async () => {
try { try {
@@ -50,6 +60,7 @@ export function StorageOverview() {
typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : "" typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
const response = await fetch(`${baseUrl}/api/storage`) const response = await fetch(`${baseUrl}/api/storage`)
const data = await response.json() const data = await response.json()
console.log("[v0] Storage data received:", data)
setStorageData(data) setStorageData(data)
} catch (error) { } catch (error) {
console.error("Error fetching storage data:", error) console.error("Error fetching storage data:", error)
@@ -60,7 +71,7 @@ export function StorageOverview() {
useEffect(() => { useEffect(() => {
fetchStorageData() fetchStorageData()
const interval = setInterval(fetchStorageData, 15000) // Update every 15 seconds const interval = setInterval(fetchStorageData, 30000) // Update every 30 seconds
return () => clearInterval(interval) return () => clearInterval(interval)
}, []) }, [])
@@ -105,6 +116,21 @@ export function StorageOverview() {
return "text-red-500" return "text-red-500"
} }
const formatHours = (hours: number) => {
if (hours === 0) return "N/A"
const years = Math.floor(hours / 8760)
const days = Math.floor((hours % 8760) / 24)
if (years > 0) {
return `${years}y ${days}d`
}
return `${days}d`
}
const handleDiskClick = (disk: DiskInfo) => {
setSelectedDisk(disk)
setDetailsOpen(true)
}
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
@@ -121,17 +147,23 @@ export function StorageOverview() {
) )
} }
const avgTemp =
storageData.disks.length > 0
? Math.round(storageData.disks.reduce((sum, disk) => sum + disk.temperature, 0) / storageData.disks.length)
: 0
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Storage Summary */} {/* Storage Summary */}
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Storage</CardTitle> <CardTitle className="text-sm font-medium">Total Storage</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" /> <HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{storageData.total} GB</div> <div className="text-2xl font-bold">{storageData.total.toFixed(1)} TB</div>
<p className="text-xs text-muted-foreground mt-1">{storageData.disk_count} physical disks</p>
</CardContent> </CardContent>
</Card> </Card>
@@ -141,20 +173,40 @@ export function StorageOverview() {
<Database className="h-4 w-4 text-muted-foreground" /> <Database className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{storageData.used} GB</div> <div className="text-2xl font-bold">{storageData.used.toFixed(1)} GB</div>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{storageData.total > 0 ? Math.round((storageData.used / storageData.total) * 100) : 0}% used {storageData.total > 0 ? ((storageData.used / (storageData.total * 1024)) * 100).toFixed(1) : 0}% used
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Available Storage</CardTitle> <CardTitle className="text-sm font-medium">Disk Health</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" /> <CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{storageData.available} GB</div> <div className="text-2xl font-bold text-green-500">{storageData.healthy_disks}</div>
<p className="text-xs text-muted-foreground mt-1">
{storageData.warning_disks > 0 && (
<span className="text-yellow-500">{storageData.warning_disks} warning </span>
)}
{storageData.critical_disks > 0 && (
<span className="text-red-500">{storageData.critical_disks} critical</span>
)}
{storageData.warning_disks === 0 && storageData.critical_disks === 0 && "All disks healthy"}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Temperature</CardTitle>
<Thermometer className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${getTempColor(avgTemp)}`}>{avgTemp > 0 ? `${avgTemp}°C` : "N/A"}</div>
<p className="text-xs text-muted-foreground mt-1">Across all disks</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -211,7 +263,11 @@ export function StorageOverview() {
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{storageData.disks.map((disk) => ( {storageData.disks.map((disk) => (
<div key={disk.name} className="border rounded-lg p-4"> <div
key={disk.name}
className="border rounded-lg p-4 cursor-pointer hover:bg-accent/50 transition-colors"
onClick={() => handleDiskClick(disk)}
>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<HardDrive className="h-5 w-5 text-muted-foreground" /> <HardDrive className="h-5 w-5 text-muted-foreground" />
@@ -232,6 +288,7 @@ export function StorageOverview() {
</div> </div>
)} )}
{getHealthBadge(disk.health)} {getHealthBadge(disk.health)}
<Info className="h-4 w-4 text-muted-foreground" />
</div> </div>
</div> </div>
@@ -248,10 +305,10 @@ export function StorageOverview() {
<p className="font-medium capitalize">{disk.smart_status}</p> <p className="font-medium capitalize">{disk.smart_status}</p>
</div> </div>
)} )}
{disk.power_on_hours && disk.power_on_hours > 0 && ( {disk.power_on_hours !== undefined && disk.power_on_hours > 0 && (
<div> <div>
<p className="text-muted-foreground">Power On Hours</p> <p className="text-muted-foreground">Power On Time</p>
<p className="font-medium">{disk.power_on_hours.toLocaleString()}h</p> <p className="font-medium">{formatHours(disk.power_on_hours)}</p>
</div> </div>
)} )}
{disk.serial && disk.serial !== "Unknown" && ( {disk.serial && disk.serial !== "Unknown" && (
@@ -290,6 +347,129 @@ export function StorageOverview() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Disk Details Dialog */}
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<HardDrive className="h-5 w-5" />
Disk Details: /dev/{selectedDisk?.name}
</DialogTitle>
<DialogDescription>Complete SMART information and health status</DialogDescription>
</DialogHeader>
{selectedDisk && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Model</p>
<p className="font-medium">{selectedDisk.model}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Serial Number</p>
<p className="font-medium">{selectedDisk.serial}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Capacity</p>
<p className="font-medium">{selectedDisk.size}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Health Status</p>
<div className="mt-1">{getHealthBadge(selectedDisk.health)}</div>
</div>
</div>
<div className="border-t pt-4">
<h4 className="font-semibold mb-3">SMART Attributes</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Temperature</p>
<p className={`font-medium ${getTempColor(selectedDisk.temperature)}`}>
{selectedDisk.temperature > 0 ? `${selectedDisk.temperature}°C` : "N/A"}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Power On Hours</p>
<p className="font-medium">
{selectedDisk.power_on_hours && selectedDisk.power_on_hours > 0
? `${selectedDisk.power_on_hours.toLocaleString()}h (${formatHours(selectedDisk.power_on_hours)})`
: "N/A"}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">SMART Status</p>
<p className="font-medium capitalize">{selectedDisk.smart_status}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Reallocated Sectors</p>
<p
className={`font-medium ${selectedDisk.reallocated_sectors && selectedDisk.reallocated_sectors > 0 ? "text-yellow-500" : ""}`}
>
{selectedDisk.reallocated_sectors ?? 0}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Pending Sectors</p>
<p
className={`font-medium ${selectedDisk.pending_sectors && selectedDisk.pending_sectors > 0 ? "text-yellow-500" : ""}`}
>
{selectedDisk.pending_sectors ?? 0}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">CRC Errors</p>
<p
className={`font-medium ${selectedDisk.crc_errors && selectedDisk.crc_errors > 0 ? "text-yellow-500" : ""}`}
>
{selectedDisk.crc_errors ?? 0}
</p>
</div>
</div>
</div>
{selectedDisk.mountpoint && (
<div className="border-t pt-4">
<h4 className="font-semibold mb-3">Mount Information</h4>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Mount Point:</span>
<span className="font-medium">{selectedDisk.mountpoint}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Filesystem:</span>
<span className="font-medium">{selectedDisk.fstype}</span>
</div>
{selectedDisk.total && (
<>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total:</span>
<span className="font-medium">{selectedDisk.total} GB</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Used:</span>
<span className="font-medium">{selectedDisk.used} GB</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Available:</span>
<span className="font-medium">{selectedDisk.available} GB</span>
</div>
{selectedDisk.usage_percent !== undefined && (
<div className="mt-2">
<Progress value={selectedDisk.usage_percent} className="h-2" />
<p className="text-xs text-muted-foreground text-center mt-1">
{selectedDisk.usage_percent}% used
</p>
</div>
)}
</>
)}
</div>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</div> </div>
) )
} }

View File

@@ -0,0 +1,97 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -343,45 +343,70 @@ def get_storage_info():
'used': 0, 'used': 0,
'available': 0, 'available': 0,
'disks': [], 'disks': [],
'zfs_pools': [] 'zfs_pools': [],
'disk_count': 0,
'healthy_disks': 0,
'warning_disks': 0,
'critical_disks': 0
} }
# Get disk usage for root partition
disk_usage = psutil.disk_usage('/')
storage_data['total'] = round(disk_usage.total / (1024**3), 1) # GB
storage_data['used'] = round(disk_usage.used / (1024**3), 1) # GB
storage_data['available'] = round(disk_usage.free / (1024**3), 1) # GB
physical_disks = {} physical_disks = {}
total_disk_size_bytes = 0
try: try:
# List all block devices # List all block devices
result = subprocess.run(['lsblk', '-d', '-n', '-o', 'NAME,SIZE,TYPE'], result = subprocess.run(['lsblk', '-b', '-d', '-n', '-o', 'NAME,SIZE,TYPE'],
capture_output=True, text=True, timeout=5) capture_output=True, text=True, timeout=5)
if result.returncode == 0: if result.returncode == 0:
for line in result.stdout.strip().split('\n'): for line in result.stdout.strip().split('\n'):
parts = line.split() parts = line.split()
if len(parts) >= 3 and parts[2] == 'disk': if len(parts) >= 3 and parts[2] == 'disk':
disk_name = parts[0] disk_name = parts[0]
disk_size = parts[1] disk_size_bytes = int(parts[1])
disk_size_gb = round(disk_size_bytes / (1024**3), 1)
total_disk_size_bytes += disk_size_bytes
# Get SMART data for this disk # Get SMART data for this disk
print(f"[v0] Getting SMART data for {disk_name}...")
smart_data = get_smart_data(disk_name) smart_data = get_smart_data(disk_name)
print(f"[v0] SMART data for {disk_name}: {smart_data}")
physical_disks[disk_name] = { physical_disks[disk_name] = {
'name': disk_name, 'name': disk_name,
'size': disk_size, 'size': f"{disk_size_gb}T" if disk_size_gb >= 1000 else f"{disk_size_gb}G",
'size_bytes': disk_size_bytes,
'temperature': smart_data.get('temperature', 0), 'temperature': smart_data.get('temperature', 0),
'health': smart_data.get('health', 'unknown'), 'health': smart_data.get('health', 'unknown'),
'power_on_hours': smart_data.get('power_on_hours', 0), 'power_on_hours': smart_data.get('power_on_hours', 0),
'smart_status': smart_data.get('smart_status', 'unknown'), 'smart_status': smart_data.get('smart_status', 'unknown'),
'model': smart_data.get('model', 'Unknown'), 'model': smart_data.get('model', 'Unknown'),
'serial': smart_data.get('serial', 'Unknown') 'serial': smart_data.get('serial', 'Unknown'),
'reallocated_sectors': smart_data.get('reallocated_sectors', 0),
'pending_sectors': smart_data.get('pending_sectors', 0),
'crc_errors': smart_data.get('crc_errors', 0)
} }
storage_data['disk_count'] += 1
health = smart_data.get('health', 'unknown').lower()
if health == 'healthy':
storage_data['healthy_disks'] += 1
elif health == 'warning':
storage_data['warning_disks'] += 1
elif health in ['critical', 'failed']:
storage_data['critical_disks'] += 1
except Exception as e: except Exception as e:
print(f"Error getting disk list: {e}") print(f"Error getting disk list: {e}")
storage_data['total'] = round(total_disk_size_bytes / (1024**3), 1)
# Get disk usage for mounted partitions
try: try:
disk_partitions = psutil.disk_partitions() disk_partitions = psutil.disk_partitions()
total_used = 0
total_available = 0
for partition in disk_partitions: for partition in disk_partitions:
try: try:
# Skip special filesystems # Skip special filesystems
@@ -389,37 +414,38 @@ def get_storage_info():
continue continue
partition_usage = psutil.disk_usage(partition.mountpoint) partition_usage = psutil.disk_usage(partition.mountpoint)
total_used += partition_usage.used
total_available += partition_usage.free
# Extract disk name from partition device (e.g., /dev/sda1 -> sda) # Extract disk name from partition device
device_name = partition.device.replace('/dev/', '') device_name = partition.device.replace('/dev/', '')
# Remove partition number (sda1 -> sda, nvme0n1p1 -> nvme0n1)
if device_name[-1].isdigit(): if device_name[-1].isdigit():
if 'nvme' in device_name or 'mmcblk' in device_name: if 'nvme' in device_name or 'mmcblk' in device_name:
# For nvme and mmc devices: nvme0n1p1 -> nvme0n1
base_disk = device_name.rsplit('p', 1)[0] base_disk = device_name.rsplit('p', 1)[0]
else: else:
# For regular devices: sda1 -> sda
base_disk = device_name.rstrip('0123456789') base_disk = device_name.rstrip('0123456789')
else: else:
base_disk = device_name base_disk = device_name
# Find corresponding physical disk # Find corresponding physical disk
disk_info = physical_disks.get(base_disk) disk_info = physical_disks.get(base_disk)
if disk_info: if disk_info and 'mountpoint' not in disk_info:
# Add mount information to the physical disk disk_info['mountpoint'] = partition.mountpoint
if 'mountpoint' not in disk_info: disk_info['fstype'] = partition.fstype
disk_info['mountpoint'] = partition.mountpoint disk_info['total'] = round(partition_usage.total / (1024**3), 1)
disk_info['fstype'] = partition.fstype disk_info['used'] = round(partition_usage.used / (1024**3), 1)
disk_info['total'] = round(partition_usage.total / (1024**3), 1) disk_info['available'] = round(partition_usage.free / (1024**3), 1)
disk_info['used'] = round(partition_usage.used / (1024**3), 1) disk_info['usage_percent'] = round(partition_usage.percent, 1)
disk_info['available'] = round(partition_usage.free / (1024**3), 1)
disk_info['usage_percent'] = round(partition_usage.percent, 1)
except PermissionError: except PermissionError:
continue continue
except Exception as e: except Exception as e:
print(f"Error accessing partition {partition.device}: {e}") print(f"Error accessing partition {partition.device}: {e}")
continue continue
storage_data['used'] = round(total_used / (1024**3), 1)
storage_data['available'] = round(total_available / (1024**3), 1)
except Exception as e: except Exception as e:
print(f"Error getting partition info: {e}") print(f"Error getting partition info: {e}")
@@ -441,6 +467,8 @@ def get_storage_info():
'health': parts[4] 'health': parts[4]
} }
storage_data['zfs_pools'].append(pool_info) storage_data['zfs_pools'].append(pool_info)
except FileNotFoundError:
print("Note: ZFS not installed")
except Exception as e: except Exception as e:
print(f"Note: ZFS not available or no pools: {e}") print(f"Note: ZFS not available or no pools: {e}")
@@ -454,7 +482,11 @@ def get_storage_info():
'used': 0, 'used': 0,
'available': 0, 'available': 0,
'disks': [], 'disks': [],
'zfs_pools': [] 'zfs_pools': [],
'disk_count': 0,
'healthy_disks': 0,
'warning_disks': 0,
'critical_disks': 0
} }
def get_smart_data(disk_name): def get_smart_data(disk_name):
@@ -465,15 +497,18 @@ def get_smart_data(disk_name):
'power_on_hours': 0, 'power_on_hours': 0,
'smart_status': 'unknown', 'smart_status': 'unknown',
'model': 'Unknown', 'model': 'Unknown',
'serial': 'Unknown' 'serial': 'Unknown',
'reallocated_sectors': 0,
'pending_sectors': 0,
'crc_errors': 0
} }
try: try:
# Try to get SMART data using smartctl with JSON output for better parsing
result = subprocess.run(['smartctl', '-a', '-j', f'/dev/{disk_name}'], result = subprocess.run(['smartctl', '-a', '-j', f'/dev/{disk_name}'],
capture_output=True, text=True, timeout=10) capture_output=True, text=True, timeout=10)
if result.returncode in [0, 4]: # 0 = success, 4 = some SMART values exceeded threshold # smartctl returns 0 for success, 4 if some SMART values exceeded threshold (still valid)
if result.returncode in [0, 4]:
try: try:
# Try JSON parsing first (newer smartctl versions) # Try JSON parsing first (newer smartctl versions)
data = json.loads(result.stdout) data = json.loads(result.stdout)
@@ -496,13 +531,19 @@ def get_smart_data(disk_name):
if 'temperature' in data and 'current' in data['temperature']: if 'temperature' in data and 'current' in data['temperature']:
smart_data['temperature'] = data['temperature']['current'] smart_data['temperature'] = data['temperature']['current']
# Get power on hours from SMART attributes
if 'ata_smart_attributes' in data and 'table' in data['ata_smart_attributes']: if 'ata_smart_attributes' in data and 'table' in data['ata_smart_attributes']:
for attr in data['ata_smart_attributes']['table']: for attr in data['ata_smart_attributes']['table']:
if attr['id'] == 9: # Power_On_Hours attr_id = attr.get('id')
if attr_id == 9: # Power_On_Hours
smart_data['power_on_hours'] = attr['raw']['value'] smart_data['power_on_hours'] = attr['raw']['value']
elif attr['id'] == 194 and smart_data['temperature'] == 0: # Temperature_Celsius elif attr_id == 194 and smart_data['temperature'] == 0: # Temperature_Celsius
smart_data['temperature'] = attr['raw']['value'] smart_data['temperature'] = attr['raw']['value']
elif attr_id == 5: # Reallocated_Sector_Ct
smart_data['reallocated_sectors'] = attr['raw']['value']
elif attr_id == 197: # Current_Pending_Sector
smart_data['pending_sectors'] = attr['raw']['value']
elif attr_id == 199: # UDMA_CRC_Error_Count
smart_data['crc_errors'] = attr['raw']['value']
# For NVMe drives # For NVMe drives
if 'nvme_smart_health_information_log' in data: if 'nvme_smart_health_information_log' in data:
@@ -513,7 +554,6 @@ def get_smart_data(disk_name):
smart_data['power_on_hours'] = nvme_data['power_on_hours'] smart_data['power_on_hours'] = nvme_data['power_on_hours']
except json.JSONDecodeError: except json.JSONDecodeError:
# Fallback to text parsing if JSON not available
output = result.stdout output = result.stdout
# Parse SMART status # Parse SMART status
@@ -538,7 +578,6 @@ def get_smart_data(disk_name):
elif line.startswith('Model Family:') and smart_data['model'] == 'Unknown': elif line.startswith('Model Family:') and smart_data['model'] == 'Unknown':
smart_data['model'] = line.split(':', 1)[1].strip() smart_data['model'] = line.split(':', 1)[1].strip()
# Parse SMART attributes table
in_attributes = False in_attributes = False
for line in output.split('\n'): for line in output.split('\n'):
line = line.strip() line = line.strip()
@@ -547,48 +586,63 @@ def get_smart_data(disk_name):
in_attributes = True in_attributes = True
continue continue
if in_attributes and line: if in_attributes and line and not line.startswith('SMART'):
parts = line.split() parts = line.split()
if len(parts) >= 10: if len(parts) >= 10:
attr_id = parts[0] try:
attr_name = parts[1] attr_id = parts[0]
raw_value = parts[9] attr_name = parts[1]
raw_value = parts[9]
# Power On Hours (attribute 9) # Power On Hours (attribute 9)
if attr_id == '9' and 'Power_On_Hours' in attr_name: if attr_id == '9':
try:
smart_data['power_on_hours'] = int(raw_value) smart_data['power_on_hours'] = int(raw_value)
except ValueError:
pass
# Temperature (attribute 194) # Temperature (attribute 194)
elif attr_id == '194' and 'Temperature' in attr_name: elif attr_id == '194':
try: # Raw value might be like "32 (Min/Max 18/45)" or just "32"
# Raw value might be like "32 (Min/Max 18/45)" temp_str = raw_value.split()[0] if ' ' in raw_value else raw_value
temp_str = raw_value.split()[0]
smart_data['temperature'] = int(temp_str) smart_data['temperature'] = int(temp_str)
except (ValueError, IndexError):
pass # Reallocated Sectors (attribute 5)
elif attr_id == '5':
smart_data['reallocated_sectors'] = int(raw_value)
# Current Pending Sector (attribute 197)
elif attr_id == '197':
smart_data['pending_sectors'] = int(raw_value)
# UDMA CRC Error Count (attribute 199)
elif attr_id == '199':
smart_data['crc_errors'] = int(raw_value)
except (ValueError, IndexError) as e:
print(f"[v0] Error parsing SMART attribute line '{line}': {e}")
continue
# For NVMe drives, look for temperature in different format # For NVMe drives, look for temperature in different format
if smart_data['temperature'] == 0: if smart_data['temperature'] == 0:
for line in output.split('\n'): for line in output.split('\n'):
if 'Temperature:' in line: if 'Temperature:' in line:
try: try:
# Format: "Temperature: 45 Celsius"
temp_str = line.split(':')[1].strip().split()[0] temp_str = line.split(':')[1].strip().split()[0]
smart_data['temperature'] = int(temp_str) smart_data['temperature'] = int(temp_str)
except (ValueError, IndexError): except (ValueError, IndexError):
pass pass
# Determine health based on temperature if smart_data['reallocated_sectors'] > 0 or smart_data['pending_sectors'] > 0:
if smart_data['temperature'] > 0: smart_data['health'] = 'warning'
if smart_data['reallocated_sectors'] > 10 or smart_data['pending_sectors'] > 10:
smart_data['health'] = 'critical'
if smart_data['smart_status'] == 'failed':
smart_data['health'] = 'critical'
# Temperature-based health (only if not already critical/warning from SMART)
if smart_data['health'] == 'healthy' and smart_data['temperature'] > 0:
if smart_data['temperature'] >= 70: if smart_data['temperature'] >= 70:
smart_data['health'] = 'critical' smart_data['health'] = 'critical'
elif smart_data['temperature'] >= 60: elif smart_data['temperature'] >= 60:
smart_data['health'] = 'warning' smart_data['health'] = 'warning'
elif smart_data['smart_status'] == 'passed':
smart_data['health'] = 'healthy'
except FileNotFoundError: except FileNotFoundError:
print(f"smartctl not found - install smartmontools package") print(f"smartctl not found - install smartmontools package")