mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 18:16:43 +00:00
fix: Health Monitor storage and disk notices, SELinux-safe host backup, Frigate detector keys
This commit is contained in:
@@ -347,6 +347,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
if (match) return t("healthStatus.details.startupStorageUnavailable", { storage: match[1] })
|
||||
match = value.match(/^\[Startup\] Storage '(.+)' has status: (.+)\. \(checking\.\.\.\)$/)
|
||||
if (match) return t("healthStatus.details.startupStorageStatus", { storage: match[1], status: match[2] })
|
||||
match = value.match(/^([\d.]+)% used$/)
|
||||
if (match) return t("healthStatus.details.storageUsage", { percent: match[1] })
|
||||
match = value.match(/^(.+) storage available$/)
|
||||
if (match) return t("healthStatus.details.storageAvailable", { type: match[1] })
|
||||
match = value.match(/^(.+) mount reachable$/)
|
||||
|
||||
@@ -4699,6 +4699,7 @@
|
||||
"pendingPackages": "{count} Pakete stehen aus",
|
||||
"updatedDaysAgo": "Zuletzt aktualisiert vor {count} Tagen",
|
||||
"storageAvailable": "{type} Speicher ist verfügbar",
|
||||
"storageUsage": "{percent} % belegt",
|
||||
"mountReachable": "{type} Mount ist erreichbar",
|
||||
"rootfsUsed": "Rootfs-Nutzung: {percent} % ({size})",
|
||||
"runningCtsSafe": "{count} laufende CTs haben eine sichere Rootfs-Nutzung",
|
||||
|
||||
@@ -4703,6 +4703,7 @@
|
||||
"startupStorageUnavailable": "Checking Proxmox storage after startup: {storage} is not ready yet",
|
||||
"startupStorageStatus": "Checking Proxmox storage after startup: {storage} (current status: {status})",
|
||||
"storageAvailable": "{type} storage is available",
|
||||
"storageUsage": "{percent}% used",
|
||||
"mountReachable": "{type} mount is reachable",
|
||||
"rootfsUsed": "rootfs usage: {percent}% ({size})",
|
||||
"runningCtsSafe": "{count} running CTs have safe rootfs usage",
|
||||
|
||||
@@ -4698,8 +4698,9 @@
|
||||
"uptimeDays": "Uptime: {count} días",
|
||||
"pendingPackages": "{count} paquetes pendientes",
|
||||
"updatedDaysAgo": "Última actualización hace {count} días",
|
||||
"storageAvailable": "{type} almacenamiento está disponible",
|
||||
"mountReachable": "Se puede acceder al soporte {type}",
|
||||
"storageAvailable": "Almacenamiento {type} disponible",
|
||||
"storageUsage": "{percent} % en uso",
|
||||
"mountReachable": "Montaje {type} accesible",
|
||||
"rootfsUsed": "Uso de rootfs: {percent}% ({size})",
|
||||
"runningCtsSafe": "{count} los CT en ejecución tienen un uso seguro de rootfs",
|
||||
"pveStorageSafe": "{count} Los objetivos de almacenamiento en bloque PVE tienen un uso seguro",
|
||||
|
||||
@@ -4699,6 +4699,7 @@
|
||||
"pendingPackages": "{count} colis en attente",
|
||||
"updatedDaysAgo": "Dernière mise à jour il y a {count} jours",
|
||||
"storageAvailable": "Le stockage {type} est disponible",
|
||||
"storageUsage": "{percent} % utilisé",
|
||||
"mountReachable": "La monture {type} est accessible",
|
||||
"rootfsUsed": "Utilisation de rootfs : {percent}% ({size})",
|
||||
"runningCtsSafe": "{count} les CT en cours d'exécution ont une utilisation sécurisée de rootfs",
|
||||
|
||||
@@ -4699,6 +4699,7 @@
|
||||
"pendingPackages": "{count} pacchetti in sospeso",
|
||||
"updatedDaysAgo": "Ultimo aggiornamento {count} giorni fa",
|
||||
"storageAvailable": "{type} spazio di archiviazione disponibile",
|
||||
"storageUsage": "{percent}% utilizzato",
|
||||
"mountReachable": "La montatura {type} è raggiungibile",
|
||||
"rootfsUsed": "Utilizzo di rootfs: {percent}% ({size})",
|
||||
"runningCtsSafe": "I {count} CT in esecuzione hanno un utilizzo sicuro di rootfs",
|
||||
|
||||
@@ -4699,6 +4699,7 @@
|
||||
"pendingPackages": "{count} pacotes pendentes",
|
||||
"updatedDaysAgo": "Última atualização há {count} dias",
|
||||
"storageAvailable": "O armazenamento {type} está disponível",
|
||||
"storageUsage": "{percent}% em uso",
|
||||
"mountReachable": "A montagem {type} está acessível",
|
||||
"rootfsUsed": "Uso de rootfs: {percent}% ({size})",
|
||||
"runningCtsSafe": "{count} CTs em execução têm uso seguro de rootfs",
|
||||
|
||||
@@ -4703,6 +4703,7 @@
|
||||
"startupStorageUnavailable": "Po štarte sa ešte overuje úložisko {storage}; zatiaľ nie je pripravené",
|
||||
"startupStorageStatus": "Po štarte sa ešte overuje úložisko {storage} (aktuálny stav: {status})",
|
||||
"storageAvailable": "Úložisko {type} je dostupné",
|
||||
"storageUsage": "{percent} % využité",
|
||||
"mountReachable": "Pripojenie {type} je dostupné",
|
||||
"rootfsUsed": "Využitie rootfs: {percent} % ({size})",
|
||||
"runningCtsSafe": "Bežiace CT s bezpečným využitím rootfs: {count}",
|
||||
|
||||
@@ -4699,6 +4699,7 @@
|
||||
"pendingPackages": "{count} paket väntar",
|
||||
"updatedDaysAgo": "Senast uppdaterad för {count} dagar sedan",
|
||||
"storageAvailable": "{type} lagringsutrymme är tillgängligt",
|
||||
"storageUsage": "{percent} % använt",
|
||||
"mountReachable": "{type}-monteringen kan nåbar",
|
||||
"rootfsUsed": "rootfs-användning: {percent}% ({size})",
|
||||
"runningCtsSafe": "{count} som kör CT:er har säker rootfs-användning",
|
||||
|
||||
@@ -199,6 +199,16 @@ def _is_disk_usb(disk_name: str) -> bool:
|
||||
"""True when sysfs places the disk behind a USB bus."""
|
||||
return resolver_is_usb_disk(disk_name)
|
||||
|
||||
|
||||
# smartd lines about its own configuration, not about a disk's health.
|
||||
_SMARTD_CONFIGURATION_NOTICES = (
|
||||
'ignoring -n directive',
|
||||
'not found in smartd database',
|
||||
'is in standby mode',
|
||||
'is in sleep mode',
|
||||
)
|
||||
|
||||
|
||||
class HealthMonitor:
|
||||
"""
|
||||
Monitors system health across multiple components with minimal impact.
|
||||
@@ -5496,6 +5506,12 @@ class HealthMonitor:
|
||||
if not line.strip():
|
||||
continue
|
||||
line_lower = line.lower()
|
||||
# smartd reports its own configuration at the same priority as
|
||||
# a failing disk: a USB bridge without ATA CHECK POWER STATUS
|
||||
# makes it ignore -n, a disk it does not know is monitored with
|
||||
# defaults. Those lines say nothing about the disk's health.
|
||||
if 'smartd' in line_lower and any(text in line_lower for text in _SMARTD_CONFIGURATION_NOTICES):
|
||||
continue
|
||||
|
||||
# Extract disk name -- multiple patterns for different log formats:
|
||||
# /dev/sdh, /dev/nvme0n1
|
||||
@@ -6609,6 +6625,7 @@ class HealthMonitor:
|
||||
name = st.get('name', 'unknown')
|
||||
label = f'{name} ({stype})'
|
||||
entry = {
|
||||
'detail': f'{pct:.1f}% used',
|
||||
'usage_percent': round(pct, 1),
|
||||
'storage_name': name,
|
||||
'storage_type': stype,
|
||||
@@ -6732,6 +6749,7 @@ class HealthMonitor:
|
||||
except ValueError:
|
||||
continue
|
||||
entry = {
|
||||
'detail': f'{pct}% used',
|
||||
'usage_percent': pct,
|
||||
'pool_name': name,
|
||||
}
|
||||
|
||||
@@ -265,6 +265,22 @@ def capture_journal_context(keywords: list, lines: int = 30,
|
||||
return ""
|
||||
|
||||
|
||||
# Errors fstrim reports for a filesystem whose device cannot discard.
|
||||
_FSTRIM_UNSUPPORTED = ('remote i/o error', 'operation not supported', 'inappropriate ioctl for device')
|
||||
|
||||
|
||||
def _fstrim_failed_only_unsupported() -> bool:
|
||||
"""True when every filesystem the last fstrim run failed on cannot discard."""
|
||||
try:
|
||||
output = subprocess.run(
|
||||
['journalctl', '-u', 'fstrim.service', '--since', '-15min', '-o', 'cat', '--no-pager'],
|
||||
capture_output=True, text=True, timeout=5).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
failures = [line.lower() for line in output.splitlines() if 'fitrim ioctl failed' in line.lower()]
|
||||
return bool(failures) and all(any(text in line for text in _FSTRIM_UNSUPPORTED) for line in failures)
|
||||
|
||||
|
||||
# ─── smartd observation helper (shared by JournalWatcher & ProxmoxHookWatcher) ──
|
||||
#
|
||||
# Both watchers receive smartd messages — JournalWatcher via local journal,
|
||||
@@ -1316,6 +1332,12 @@ class JournalWatcher:
|
||||
for noise in _NOISE_PATTERNS:
|
||||
if re.search(noise, msg) or re.search(noise, unit):
|
||||
return
|
||||
|
||||
# fstrim exits non-zero when a mounted disk does not support discard,
|
||||
# as a USB bridge or a remote share does, every time the timer runs.
|
||||
# That is what the disk can do, not a failure to act on.
|
||||
if ('fstrim.service' in msg or unit == 'fstrim.service') and _fstrim_failed_only_unsupported():
|
||||
return
|
||||
|
||||
service_patterns = [
|
||||
r'Failed to start (.+)',
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
import health_monitor
|
||||
import notification_events
|
||||
|
||||
NO_POWER_STATUS = ('Sep 27 09:08:50 proxmox smartd[893]: Device: /dev/sdc [SAT], '
|
||||
'no ATA CHECK POWER STATUS support, ignoring -n Directive')
|
||||
PENDING = ('Sep 27 09:08:50 proxmox smartd[893]: Device: /dev/sdc [SAT], '
|
||||
'8 Currently unreadable (pending) sectors')
|
||||
|
||||
|
||||
class SmartdConfigurationNotices(unittest.TestCase):
|
||||
def issues(self, *lines):
|
||||
monitor = health_monitor.HealthMonitor.__new__(health_monitor.HealthMonitor)
|
||||
monitor._get_journalctl_1hour_warnings = lambda: '\n'.join(lines)
|
||||
monitor._get_disk_identity = lambda disk: None
|
||||
with patch.object(health_monitor.os.path, 'exists', return_value=True):
|
||||
return monitor._check_disk_health_from_events()
|
||||
|
||||
def test_configuration_notice_is_not_a_disk_problem(self):
|
||||
self.assertEqual(self.issues(NO_POWER_STATUS), {})
|
||||
|
||||
def test_a_real_warning_on_the_same_disk_is_still_reported(self):
|
||||
issues = self.issues(NO_POWER_STATUS, PENDING)
|
||||
self.assertEqual(list(issues), ['/dev/sdc'])
|
||||
self.assertIn('pending', issues['/dev/sdc']['reason'])
|
||||
self.assertNotIn('CHECK POWER STATUS', issues['/dev/sdc']['reason'])
|
||||
|
||||
|
||||
class StorageUsageDetail(unittest.TestCase):
|
||||
def test_a_full_pbs_storage_reads_its_usage(self):
|
||||
# The storage row merges this entry over the availability one, so it
|
||||
# must carry its own text or it reads "pbs storage available".
|
||||
monitor = health_monitor.HealthMonitor.__new__(health_monitor.HealthMonitor)
|
||||
monitor._read_capacity_thresholds = lambda section: (85, 95)
|
||||
status = {'available': [{'name': 'PBS-Cloud', 'type': 'pbs', 'total': 1000, 'used': 924}]}
|
||||
module = MagicMock()
|
||||
module.proxmox_storage_monitor.get_storage_status.return_value = status
|
||||
persistence = health_monitor.health_persistence
|
||||
with patch.dict(sys.modules, {'proxmox_storage_monitor': module}), \
|
||||
patch.object(health_monitor, 'PROXMOX_STORAGE_AVAILABLE', True), \
|
||||
patch.object(persistence, 'is_error_acknowledged', return_value=False), \
|
||||
patch.object(persistence, 'record_error'), \
|
||||
patch.object(persistence, 'get_active_errors', return_value=[]):
|
||||
result = monitor._check_pve_storage_capacity()
|
||||
entry = result['checks']['PBS-Cloud (pbs)']
|
||||
self.assertEqual((entry['status'], entry['detail']), ('WARNING', '92.4% used'))
|
||||
|
||||
|
||||
class FstrimUnsupportedDevices(unittest.TestCase):
|
||||
def run_with(self, journal):
|
||||
result = MagicMock(stdout=journal)
|
||||
with patch.object(notification_events.subprocess, 'run', return_value=result):
|
||||
return notification_events._fstrim_failed_only_unsupported()
|
||||
|
||||
def test_devices_that_cannot_discard(self):
|
||||
self.assertTrue(self.run_with(
|
||||
'fstrim: /mnt/nas1_con_backup: FITRIM ioctl failed: Remote I/O error\n'
|
||||
'fstrim: /mnt/usb2: FITRIM ioctl failed: Operation not supported\n'
|
||||
'/: 20 GiB (21474836480 bytes) trimmed on /dev/mapper/pve-root\n'))
|
||||
|
||||
def test_a_real_failure_is_still_reported(self):
|
||||
self.assertFalse(self.run_with(
|
||||
'fstrim: /mnt/nas1_con_backup: FITRIM ioctl failed: Remote I/O error\n'
|
||||
'fstrim: /mnt/data: FITRIM ioctl failed: Input/output error\n'))
|
||||
|
||||
def test_failure_without_fitrim_lines_or_journal_is_reported(self):
|
||||
self.assertFalse(self.run_with('fstrim: cannot open /etc/fstab\n'))
|
||||
with patch.object(notification_events.subprocess, 'run', side_effect=subprocess.TimeoutExpired('journalctl', 5)):
|
||||
self.assertFalse(notification_events._fstrim_failed_only_unsupported())
|
||||
|
||||
def test_the_service_failure_is_not_emitted(self):
|
||||
watcher = notification_events.JournalWatcher.__new__(notification_events.JournalWatcher)
|
||||
watcher._emit = MagicMock()
|
||||
with patch.object(notification_events, 'is_apt_active_on_host', return_value=False), \
|
||||
patch.object(notification_events, '_fstrim_failed_only_unsupported', return_value=True):
|
||||
watcher._check_service_failure('fstrim.service: Main process exited, code=exited, status=32/n/a',
|
||||
'init.scope')
|
||||
watcher._emit.assert_not_called()
|
||||
with patch.object(notification_events, 'is_apt_active_on_host', return_value=False), \
|
||||
patch.object(notification_events, '_fstrim_failed_only_unsupported', return_value=False):
|
||||
watcher._check_service_failure('fstrim.service: Main process exited, code=exited, status=32/n/a',
|
||||
'init.scope')
|
||||
self.assertEqual(watcher._emit.call_args.args[0], 'service_fail')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user