From 2ab49cc5459bbf8e414c5c7c689d39f7c9fd9427 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Thu, 2 Oct 2025 23:20:59 +0200 Subject: [PATCH] Update AppImage --- AppImage/components/storage-overview.tsx | 206 +++++++++++++++++++++-- AppImage/components/ui/dialog.tsx | 97 +++++++++++ AppImage/scripts/flask_server.py | 166 ++++++++++++------ 3 files changed, 400 insertions(+), 69 deletions(-) create mode 100644 AppImage/components/ui/dialog.tsx diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx index 9c2ddd8..beb99b6 100644 --- a/AppImage/components/storage-overview.tsx +++ b/AppImage/components/storage-overview.tsx @@ -2,9 +2,10 @@ import { useEffect, useState } from "react" 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 { Progress } from "@/components/ui/progress" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" interface DiskInfo { name: string @@ -21,6 +22,9 @@ interface DiskInfo { used?: number available?: number usage_percent?: number + reallocated_sectors?: number + pending_sectors?: number + crc_errors?: number } interface ZFSPool { @@ -37,12 +41,18 @@ interface StorageData { available: number disks: DiskInfo[] zfs_pools: ZFSPool[] + disk_count: number + healthy_disks: number + warning_disks: number + critical_disks: number error?: string } export function StorageOverview() { const [storageData, setStorageData] = useState(null) const [loading, setLoading] = useState(true) + const [selectedDisk, setSelectedDisk] = useState(null) + const [detailsOpen, setDetailsOpen] = useState(false) const fetchStorageData = async () => { try { @@ -50,6 +60,7 @@ export function StorageOverview() { typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : "" const response = await fetch(`${baseUrl}/api/storage`) const data = await response.json() + console.log("[v0] Storage data received:", data) setStorageData(data) } catch (error) { console.error("Error fetching storage data:", error) @@ -60,7 +71,7 @@ export function StorageOverview() { useEffect(() => { fetchStorageData() - const interval = setInterval(fetchStorageData, 15000) // Update every 15 seconds + const interval = setInterval(fetchStorageData, 30000) // Update every 30 seconds return () => clearInterval(interval) }, []) @@ -105,6 +116,21 @@ export function StorageOverview() { 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) { return (
@@ -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 (
{/* Storage Summary */} -
+
Total Storage -
{storageData.total} GB
+
{storageData.total.toFixed(1)} TB
+

{storageData.disk_count} physical disks

@@ -141,20 +173,40 @@ export function StorageOverview() { -
{storageData.used} GB
+
{storageData.used.toFixed(1)} GB

- {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

- Available Storage - + Disk Health + -
{storageData.available} GB
+
{storageData.healthy_disks}
+

+ {storageData.warning_disks > 0 && ( + {storageData.warning_disks} warning + )} + {storageData.critical_disks > 0 && ( + {storageData.critical_disks} critical + )} + {storageData.warning_disks === 0 && storageData.critical_disks === 0 && "All disks healthy"} +

+
+
+ + + + Avg Temperature + + + +
{avgTemp > 0 ? `${avgTemp}°C` : "N/A"}
+

Across all disks

@@ -211,7 +263,11 @@ export function StorageOverview() {
{storageData.disks.map((disk) => ( -
+
handleDiskClick(disk)} + >
@@ -232,6 +288,7 @@ export function StorageOverview() {
)} {getHealthBadge(disk.health)} +
@@ -248,10 +305,10 @@ export function StorageOverview() {

{disk.smart_status}

)} - {disk.power_on_hours && disk.power_on_hours > 0 && ( + {disk.power_on_hours !== undefined && disk.power_on_hours > 0 && (
-

Power On Hours

-

{disk.power_on_hours.toLocaleString()}h

+

Power On Time

+

{formatHours(disk.power_on_hours)}

)} {disk.serial && disk.serial !== "Unknown" && ( @@ -290,6 +347,129 @@ export function StorageOverview() {
+ + {/* Disk Details Dialog */} + + + + + + Disk Details: /dev/{selectedDisk?.name} + + Complete SMART information and health status + + {selectedDisk && ( +
+
+
+

Model

+

{selectedDisk.model}

+
+
+

Serial Number

+

{selectedDisk.serial}

+
+
+

Capacity

+

{selectedDisk.size}

+
+
+

Health Status

+
{getHealthBadge(selectedDisk.health)}
+
+
+ +
+

SMART Attributes

+
+
+

Temperature

+

+ {selectedDisk.temperature > 0 ? `${selectedDisk.temperature}°C` : "N/A"} +

+
+
+

Power On Hours

+

+ {selectedDisk.power_on_hours && selectedDisk.power_on_hours > 0 + ? `${selectedDisk.power_on_hours.toLocaleString()}h (${formatHours(selectedDisk.power_on_hours)})` + : "N/A"} +

+
+
+

SMART Status

+

{selectedDisk.smart_status}

+
+
+

Reallocated Sectors

+

0 ? "text-yellow-500" : ""}`} + > + {selectedDisk.reallocated_sectors ?? 0} +

+
+
+

Pending Sectors

+

0 ? "text-yellow-500" : ""}`} + > + {selectedDisk.pending_sectors ?? 0} +

+
+
+

CRC Errors

+

0 ? "text-yellow-500" : ""}`} + > + {selectedDisk.crc_errors ?? 0} +

+
+
+
+ + {selectedDisk.mountpoint && ( +
+

Mount Information

+
+
+ Mount Point: + {selectedDisk.mountpoint} +
+
+ Filesystem: + {selectedDisk.fstype} +
+ {selectedDisk.total && ( + <> +
+ Total: + {selectedDisk.total} GB +
+
+ Used: + {selectedDisk.used} GB +
+
+ Available: + {selectedDisk.available} GB +
+ {selectedDisk.usage_percent !== undefined && ( +
+ +

+ {selectedDisk.usage_percent}% used +

+
+ )} + + )} +
+
+ )} +
+ )} +
+
) } diff --git a/AppImage/components/ui/dialog.tsx b/AppImage/components/ui/dialog.tsx new file mode 100644 index 0000000..e6dcf4a --- /dev/null +++ b/AppImage/components/ui/dialog.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index b33ff5c..14589e4 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -343,45 +343,70 @@ def get_storage_info(): 'used': 0, 'available': 0, '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 = {} + total_disk_size_bytes = 0 + try: # 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) if result.returncode == 0: for line in result.stdout.strip().split('\n'): parts = line.split() if len(parts) >= 3 and parts[2] == 'disk': 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 + print(f"[v0] Getting SMART data for {disk_name}...") smart_data = get_smart_data(disk_name) + print(f"[v0] SMART data for {disk_name}: {smart_data}") physical_disks[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), 'health': smart_data.get('health', 'unknown'), 'power_on_hours': smart_data.get('power_on_hours', 0), 'smart_status': smart_data.get('smart_status', '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: 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: disk_partitions = psutil.disk_partitions() + total_used = 0 + total_available = 0 + for partition in disk_partitions: try: # Skip special filesystems @@ -389,37 +414,38 @@ def get_storage_info(): continue 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/', '') - # Remove partition number (sda1 -> sda, nvme0n1p1 -> nvme0n1) if device_name[-1].isdigit(): 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] else: - # For regular devices: sda1 -> sda base_disk = device_name.rstrip('0123456789') else: base_disk = device_name # Find corresponding physical disk disk_info = physical_disks.get(base_disk) - if disk_info: - # Add mount information to the physical disk - if 'mountpoint' not in disk_info: - disk_info['mountpoint'] = partition.mountpoint - disk_info['fstype'] = partition.fstype - disk_info['total'] = round(partition_usage.total / (1024**3), 1) - disk_info['used'] = round(partition_usage.used / (1024**3), 1) - disk_info['available'] = round(partition_usage.free / (1024**3), 1) - disk_info['usage_percent'] = round(partition_usage.percent, 1) + if disk_info and 'mountpoint' not in disk_info: + disk_info['mountpoint'] = partition.mountpoint + disk_info['fstype'] = partition.fstype + disk_info['total'] = round(partition_usage.total / (1024**3), 1) + disk_info['used'] = round(partition_usage.used / (1024**3), 1) + disk_info['available'] = round(partition_usage.free / (1024**3), 1) + disk_info['usage_percent'] = round(partition_usage.percent, 1) except PermissionError: continue except Exception as e: print(f"Error accessing partition {partition.device}: {e}") continue + + storage_data['used'] = round(total_used / (1024**3), 1) + storage_data['available'] = round(total_available / (1024**3), 1) + except Exception as e: print(f"Error getting partition info: {e}") @@ -441,6 +467,8 @@ def get_storage_info(): 'health': parts[4] } storage_data['zfs_pools'].append(pool_info) + except FileNotFoundError: + print("Note: ZFS not installed") except Exception as e: print(f"Note: ZFS not available or no pools: {e}") @@ -454,7 +482,11 @@ def get_storage_info(): 'used': 0, 'available': 0, 'disks': [], - 'zfs_pools': [] + 'zfs_pools': [], + 'disk_count': 0, + 'healthy_disks': 0, + 'warning_disks': 0, + 'critical_disks': 0 } def get_smart_data(disk_name): @@ -465,15 +497,18 @@ def get_smart_data(disk_name): 'power_on_hours': 0, 'smart_status': 'unknown', 'model': 'Unknown', - 'serial': 'Unknown' + 'serial': 'Unknown', + 'reallocated_sectors': 0, + 'pending_sectors': 0, + 'crc_errors': 0 } try: - # Try to get SMART data using smartctl with JSON output for better parsing result = subprocess.run(['smartctl', '-a', '-j', f'/dev/{disk_name}'], 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 JSON parsing first (newer smartctl versions) data = json.loads(result.stdout) @@ -496,13 +531,19 @@ def get_smart_data(disk_name): if 'temperature' in data and 'current' in data['temperature']: 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']: 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'] - 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'] + 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 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'] except json.JSONDecodeError: - # Fallback to text parsing if JSON not available output = result.stdout # Parse SMART status @@ -538,7 +578,6 @@ def get_smart_data(disk_name): elif line.startswith('Model Family:') and smart_data['model'] == 'Unknown': smart_data['model'] = line.split(':', 1)[1].strip() - # Parse SMART attributes table in_attributes = False for line in output.split('\n'): line = line.strip() @@ -547,48 +586,63 @@ def get_smart_data(disk_name): in_attributes = True continue - if in_attributes and line: + if in_attributes and line and not line.startswith('SMART'): parts = line.split() if len(parts) >= 10: - attr_id = parts[0] - attr_name = parts[1] - raw_value = parts[9] - - # Power On Hours (attribute 9) - if attr_id == '9' and 'Power_On_Hours' in attr_name: - try: + try: + attr_id = parts[0] + attr_name = parts[1] + raw_value = parts[9] + + # Power On Hours (attribute 9) + if attr_id == '9': smart_data['power_on_hours'] = int(raw_value) - except ValueError: - pass - - # Temperature (attribute 194) - elif attr_id == '194' and 'Temperature' in attr_name: - try: - # Raw value might be like "32 (Min/Max 18/45)" - temp_str = raw_value.split()[0] + + # Temperature (attribute 194) + elif attr_id == '194': + # Raw value might be like "32 (Min/Max 18/45)" or just "32" + temp_str = raw_value.split()[0] if ' ' in raw_value else raw_value 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 if smart_data['temperature'] == 0: for line in output.split('\n'): if 'Temperature:' in line: try: - # Format: "Temperature: 45 Celsius" temp_str = line.split(':')[1].strip().split()[0] smart_data['temperature'] = int(temp_str) except (ValueError, IndexError): pass - # Determine health based on temperature - if smart_data['temperature'] > 0: + if smart_data['reallocated_sectors'] > 0 or smart_data['pending_sectors'] > 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: smart_data['health'] = 'critical' elif smart_data['temperature'] >= 60: smart_data['health'] = 'warning' - elif smart_data['smart_status'] == 'passed': - smart_data['health'] = 'healthy' except FileNotFoundError: print(f"smartctl not found - install smartmontools package")