Update notification service

This commit is contained in:
MacRimi
2026-03-05 20:44:51 +01:00
parent 5a79556ab2
commit d927b462b6
5 changed files with 329 additions and 15 deletions
+60 -7
View File
@@ -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/<disk>/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:
+45 -2
View File
@@ -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',
+45
View File
@@ -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.
+10
View File
@@ -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: