diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx index 69edecb4..9bbb5602 100644 --- a/AppImage/components/storage-overview.tsx +++ b/AppImage/components/storage-overview.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock } from "lucide-react" +import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb } 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" @@ -42,6 +42,8 @@ interface DiskInfo { error_type?: string // 'io' | 'filesystem' } observations_count?: number + connection_type?: 'usb' | 'sata' | 'nvme' | 'sas' | 'internal' | 'unknown' + removable?: boolean } interface DiskObservation { @@ -421,21 +423,26 @@ export function StorageOverview() { const getDiskTypesBreakdown = () => { if (!storageData || !storageData.disks) { - return { nvme: 0, ssd: 0, hdd: 0 } + return { nvme: 0, ssd: 0, hdd: 0, usb: 0 } } let nvme = 0 let ssd = 0 let hdd = 0 + let usb = 0 storageData.disks.forEach((disk) => { + if (disk.connection_type === 'usb') { + usb++ + return + } const diskType = getDiskType(disk.name, disk.rotation_rate) if (diskType === "NVMe") nvme++ else if (diskType === "SSD") ssd++ else if (diskType === "HDD") hdd++ }) - return { nvme, ssd, hdd } + return { nvme, ssd, hdd, usb } } const getWearProgressColor = (wearPercent: number): string => { @@ -623,6 +630,12 @@ export function StorageOverview() { {diskTypesBreakdown.hdd} HDD )} + {diskTypesBreakdown.usb > 0 && ( + <> + {(diskTypesBreakdown.nvme > 0 || diskTypesBreakdown.ssd > 0 || diskTypesBreakdown.hdd > 0) && ", "} + {diskTypesBreakdown.usb} USB + + )}

{diskHealthBreakdown.normal} normal @@ -780,7 +793,7 @@ export function StorageOverview() { )} - {/* Physical Disks */} + {/* Physical Disks (internal only) */} @@ -790,7 +803,7 @@ export function StorageOverview() {

- {storageData.disks.map((disk) => ( + {storageData.disks.filter(d => d.connection_type !== 'usb').map((disk) => (
+ {/* External Storage (USB) */} + {storageData.disks.filter(d => d.connection_type === 'usb').length > 0 && ( + + + + + External Storage (USB) + + + +
+ {storageData.disks.filter(d => d.connection_type === 'usb').map((disk) => ( +
+ {/* Mobile card */} +
handleDiskClick(disk)} + > +
+
+ +

/dev/{disk.name}

+ USB +
+
+ {disk.model && disk.model !== "Unknown" && ( +

{disk.model}

+ )} +
+ {disk.temperature > 0 && ( +
+ + + {disk.temperature}°C + +
+ )} + {getHealthBadge(disk.health)} + {disk.observations_count && disk.observations_count > 0 && ( + + + {disk.observations_count} + + )} +
+
+
+
+ + {/* Desktop card */} +
handleDiskClick(disk)} + > +
+
+ +

/dev/{disk.name}

+ USB +
+
+ {disk.temperature > 0 && ( +
+ + + {disk.temperature}°C + +
+ )} + {getHealthBadge(disk.health)} + {disk.observations_count && disk.observations_count > 0 && ( + + + {disk.observations_count} + + )} +
+
+ {disk.model && disk.model !== "Unknown" && ( +

{disk.model}

+ )} + + {disk.io_errors && disk.io_errors.count > 0 && ( +
+ +
+ {disk.io_errors.error_type === 'filesystem' ? ( + <> + Filesystem corruption detected + {disk.io_errors.reason && ( +

{disk.io_errors.reason}

+ )} + + ) : ( + <> + {disk.io_errors.count} I/O error{disk.io_errors.count !== 1 ? 's' : ''} in 5 min + {disk.io_errors.sample && ( +

{disk.io_errors.sample}

+ )} + + )} +
+
+ )} + +
+ {disk.size_formatted && ( +
+

Size

+

{disk.size_formatted}

+
+ )} + {disk.smart_status && disk.smart_status !== "unknown" && ( +
+

SMART Status

+

{disk.smart_status}

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

Power On Time

+

{formatHours(disk.power_on_hours)}

+
+ )} + {disk.serial && disk.serial !== "Unknown" && ( +
+

Serial

+

{disk.serial}

+
+ )} +
+
+
+ ))} +
+
+
+ )} + {/* Disk Details Dialog */} - + {selectedDisk?.connection_type === 'usb' ? ( + + ) : ( + + )} Disk Details: /dev/{selectedDisk?.name} + {selectedDisk?.connection_type === 'usb' && ( + USB + )} Complete SMART information and health status diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index b332cb67..f047f903 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -1160,6 +1160,54 @@ def serve_images(filename): # Moved helper functions for system info up # def get_system_info(): ... (moved up) +def get_disk_connection_type(disk_name): + """Detect how a disk is connected: usb, sata, nvme, sas, or unknown. + + Uses /sys/block//device symlink to resolve the bus path. + Examples: + /sys/.../usb3/... -> 'usb' + /sys/.../ata2/... -> 'sata' + nvme0n1 -> 'nvme' + /sys/.../host0/... -> 'sas' (SAS/SCSI) + """ + try: + if disk_name.startswith('nvme'): + return 'nvme' + + device_path = f'/sys/block/{disk_name}/device' + if os.path.exists(device_path): + real_path = os.path.realpath(device_path) + if '/usb' in real_path: + return 'usb' + if '/ata' in real_path: + return 'sata' + if '/sas' in real_path: + return 'sas' + + # Fallback: check removable flag + removable_path = f'/sys/block/{disk_name}/removable' + if os.path.exists(removable_path): + with open(removable_path) as f: + if f.read().strip() == '1': + return 'usb' + + return 'internal' + except Exception: + return 'unknown' + + +def is_disk_removable(disk_name): + """Check if a disk is removable (USB sticks, external drives, etc.).""" + try: + removable_path = f'/sys/block/{disk_name}/removable' + if os.path.exists(removable_path): + with open(removable_path) as f: + return f.read().strip() == '1' + return False + except Exception: + return False + + def get_storage_info(): """Get storage and disk information""" try: @@ -1213,6 +1261,9 @@ def get_storage_info(): else: size_str = f"{disk_size_gb:.1f}G" + conn_type = get_disk_connection_type(disk_name) + removable = is_disk_removable(disk_name) + physical_disks[disk_name] = { 'name': disk_name, 'size': disk_size_kb, # In KB for formatMemory() in Storage Summary @@ -1227,13 +1278,15 @@ def get_storage_info(): 'reallocated_sectors': smart_data.get('reallocated_sectors', 0), 'pending_sectors': smart_data.get('pending_sectors', 0), 'crc_errors': smart_data.get('crc_errors', 0), - 'rotation_rate': smart_data.get('rotation_rate', 0), # Added - 'power_cycles': smart_data.get('power_cycles', 0), # Added - 'percentage_used': smart_data.get('percentage_used'), # Added - 'media_wearout_indicator': smart_data.get('media_wearout_indicator'), # Added - 'wear_leveling_count': smart_data.get('wear_leveling_count'), # Added - 'total_lbas_written': smart_data.get('total_lbas_written'), # Added - 'ssd_life_left': smart_data.get('ssd_life_left') # Added + 'rotation_rate': smart_data.get('rotation_rate', 0), + 'power_cycles': smart_data.get('power_cycles', 0), + 'percentage_used': smart_data.get('percentage_used'), + 'media_wearout_indicator': smart_data.get('media_wearout_indicator'), + 'wear_leveling_count': smart_data.get('wear_leveling_count'), + 'total_lbas_written': smart_data.get('total_lbas_written'), + 'ssd_life_left': smart_data.get('ssd_life_left'), + 'connection_type': conn_type, + 'removable': removable, } except Exception as e: diff --git a/AppImage/scripts/health_monitor.py b/AppImage/scripts/health_monitor.py index 9c32b8b1..e3e6be91 100644 --- a/AppImage/scripts/health_monitor.py +++ b/AppImage/scripts/health_monitor.py @@ -1098,15 +1098,21 @@ class HealthMonitor: if smart_warnings_found: # Collect the actual warning details for the sub-check smart_details_parts = [] + smart_error_keys = [] for disk_path, issue in disk_health_issues.items(): for sl in (issue.get('smart_lines') or [])[:3]: smart_details_parts.append(sl) + if issue.get('error_key'): + smart_error_keys.append(issue['error_key']) detail_text = '; '.join(smart_details_parts[:3]) if smart_details_parts else 'SMART warning in journal' + # Use the same error_key as the per-disk check so a single dismiss + # covers both the /Dev/Sda sub-check AND the SMART Health sub-check + shared_key = smart_error_keys[0] if smart_error_keys else 'smart_health_journal' checks['smart_health'] = { 'status': 'WARNING', 'detail': detail_text, 'dismissable': True, - 'error_key': 'smart_health_journal', + 'error_key': shared_key, } else: checks['smart_health'] = {'status': 'OK', 'detail': 'No SMART warnings in journal'} @@ -1118,8 +1124,45 @@ class HealthMonitor: if not issues: return {'status': 'OK', 'checks': checks} + # ── Mark dismissed checks ── + # If an error_key in a check has been acknowledged (dismissed) in the + # persistence DB, mark the check as dismissed so the frontend renders + # it in blue instead of showing WARNING + Dismiss button. + # Also recalculate category status: if ALL warning/critical checks are + # dismissed, downgrade the category to OK. + try: + all_dismissed = True + for check_key, check_val in checks.items(): + ek = check_val.get('error_key') + if not ek: + continue + check_status = (check_val.get('status') or 'OK').upper() + if check_status in ('WARNING', 'CRITICAL'): + if health_persistence.is_error_acknowledged(ek): + check_val['dismissed'] = True + else: + all_dismissed = False + + # If every non-OK check is dismissed, downgrade the category + non_ok_checks = [v for v in checks.values() + if (v.get('status') or 'OK').upper() in ('WARNING', 'CRITICAL')] + if non_ok_checks and all(v.get('dismissed') for v in non_ok_checks): + # All issues are dismissed -- category shows as OK to avoid + # persistent WARNING after user has acknowledged. + return { + 'status': 'OK', + 'reason': '; '.join(issues[:3]), + 'details': storage_details, + 'checks': checks, + 'all_dismissed': True, + } + except Exception: + pass + # Determine overall status - has_critical = any(d.get('status') == 'CRITICAL' for d in storage_details.values()) + has_critical = any( + d.get('status') == 'CRITICAL' for d in storage_details.values() + ) return { 'status': 'CRITICAL' if has_critical else 'WARNING', diff --git a/AppImage/scripts/health_persistence.py b/AppImage/scripts/health_persistence.py index 937ce5ab..423d8316 100644 --- a/AppImage/scripts/health_persistence.py +++ b/AppImage/scripts/health_persistence.py @@ -580,6 +580,35 @@ class HealthPersistence: conn.close() return result + def is_error_acknowledged(self, error_key: str) -> bool: + """Check if an error_key has been acknowledged and is still within suppression window.""" + try: + conn = self._get_conn() + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute( + 'SELECT acknowledged, resolved_at, suppression_hours FROM errors WHERE error_key = ?', + (error_key,)) + row = cursor.fetchone() + conn.close() + if not row: + return False + if not row['acknowledged']: + return False + # Check if still within suppression window + resolved_at = row['resolved_at'] + sup_hours = row['suppression_hours'] or self.DEFAULT_SUPPRESSION_HOURS + if resolved_at: + try: + resolved_dt = datetime.fromisoformat(resolved_at) + if datetime.now() > resolved_dt + timedelta(hours=sup_hours): + return False # Suppression expired + except Exception: + pass + return True + except Exception: + return False + def get_active_errors(self, category: Optional[str] = None) -> List[Dict[str, Any]]: """Get all active (unresolved) errors, optionally filtered by category""" conn = self._get_conn() @@ -1358,6 +1387,22 @@ class HealthPersistence: print(f"[HealthPersistence] Error getting observations: {e}") return [] + def get_all_observed_devices(self) -> List[Dict[str, Any]]: + """Return a list of unique device_name + serial pairs that have observations.""" + try: + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute(''' + SELECT DISTINCT device_name, serial + FROM disk_observations + WHERE dismissed = 0 + ''') + rows = cursor.fetchall() + conn.close() + return [{'device_name': r[0], 'serial': r[1] or ''} for r in rows] + except Exception: + return [] + def get_disks_observation_counts(self) -> Dict[str, int]: """Return {device_name: count} of active observations per disk. diff --git a/AppImage/scripts/notification_events.py b/AppImage/scripts/notification_events.py index ba3b63b8..1eb4cb82 100644 --- a/AppImage/scripts/notification_events.py +++ b/AppImage/scripts/notification_events.py @@ -1731,6 +1731,16 @@ class PollingCollector: self._last_notified.pop(key, None) continue + # Skip recovery if the error was manually acknowledged (dismissed) + # by the user. Acknowledged != resolved -- the problem may still + # exist, the user just chose to suppress notifications for it. + try: + if health_persistence.is_error_acknowledged(key): + self._last_notified.pop(key, None) + continue + except Exception: + pass + # Calculate duration duration = '' if first_seen: