diff --git a/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts index cf98e27d5..9c1913ecb 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts @@ -234,6 +234,8 @@ describe('diskPresentation', () => { }), ).toBe('Pending sectors detected.'); expect(getPhysicalDiskHealthStatus(makeDiskData({ health: 'UNKNOWN' })).label).toBe('Unknown'); + // PVE reports SCSI/SAS drives as OK; older servers pass it through raw (#1595) + expect(getPhysicalDiskHealthStatus(makeDiskData({ health: 'OK' })).label).toBe('Healthy'); expect( getPhysicalDiskEmptyStatePresentation({ diff --git a/frontend-modern/src/features/storageBackups/diskPresentation.ts b/frontend-modern/src/features/storageBackups/diskPresentation.ts index f91f60aae..5002e6f94 100644 --- a/frontend-modern/src/features/storageBackups/diskPresentation.ts +++ b/frontend-modern/src/features/storageBackups/diskPresentation.ts @@ -188,6 +188,10 @@ const PHYSICAL_DISK_BAD_HEALTH_STATES = new Set([ 'UNHEALTHY', ]); +// PVE reports SCSI/SAS drives as OK (ATA drives say PASSED); older server +// builds pass that raw value through, so accept it here as well (#1595). +const PHYSICAL_DISK_HEALTHY_STATES = new Set(['PASSED', 'GOOD', 'OK']); + const normalizePhysicalDiskState = (value: string | undefined | null): string => (value || '').trim().toLowerCase(); @@ -559,11 +563,10 @@ export function getPhysicalDiskHealthStatus( } return { - label: normalizedHealth === 'PASSED' || normalizedHealth === 'GOOD' ? 'Healthy' : 'Unknown', - summary: - normalizedHealth === 'PASSED' || normalizedHealth === 'GOOD' - ? 'No active disk-health issues.' - : 'Health state is not reported.', + label: PHYSICAL_DISK_HEALTHY_STATES.has(normalizedHealth) ? 'Healthy' : 'Unknown', + summary: PHYSICAL_DISK_HEALTHY_STATES.has(normalizedHealth) + ? 'No active disk-health issues.' + : 'Health state is not reported.', tone: 'text-base-content', }; } diff --git a/internal/monitoring/issue1595_pve_health_normalization_test.go b/internal/monitoring/issue1595_pve_health_normalization_test.go new file mode 100644 index 000000000..c38574d0d --- /dev/null +++ b/internal/monitoring/issue1595_pve_health_normalization_test.go @@ -0,0 +1,30 @@ +package monitoring + +import "testing" + +// PVE's disks/list endpoint reports ATA drives as PASSED/FAILED! and SCSI/SAS +// drives as OK or a failure sentence. The raw OK previously reached the UI +// untouched and rendered as Unknown (#1595). +func TestNormalizeProxmoxDiskHealth(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"scsi ok", "OK", "PASSED"}, + {"scsi ok lowercase", "ok", "PASSED"}, + {"ata passed", "PASSED", "PASSED"}, + {"ata failed bang", "FAILED!", "FAILED"}, + {"scsi failure sentence", "FAILURE PREDICTION THRESHOLD EXCEEDED", "FAILED"}, + {"unknown passthrough", "UNKNOWN", "UNKNOWN"}, + {"empty passthrough", "", ""}, + {"whitespace trimmed", " OK ", "PASSED"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := normalizeProxmoxDiskHealth(tc.in); got != tc.want { + t.Fatalf("normalizeProxmoxDiskHealth(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} diff --git a/internal/monitoring/monitor_pve.go b/internal/monitoring/monitor_pve.go index 44965dd76..f4850492e 100644 --- a/internal/monitoring/monitor_pve.go +++ b/internal/monitoring/monitor_pve.go @@ -1033,7 +1033,7 @@ func (m *Monitor) maybePollPhysicalDisksAsync( WWN: disk.WWN, Type: disk.Type, Size: disk.Size, - Health: disk.Health, + Health: normalizeProxmoxDiskHealth(disk.Health), Wearout: disk.Wearout, RPM: disk.RPM, Used: disk.Used, @@ -1120,6 +1120,25 @@ func (m *Monitor) maybePollPhysicalDisksAsync( }(instanceName, client, nodes, nodeEffectiveStatus, modelNodes) } +// normalizeProxmoxDiskHealth maps the raw health strings the Proxmox disks API +// reports onto the canonical PASSED/FAILED vocabulary the disk model carries. +// ATA drives come back as PASSED or FAILED!, while SCSI/SAS drives report OK +// or a failure sentence, and the raw OK previously rendered as Unknown in the +// UI (#1595). Unrecognized values pass through untouched so nothing real is +// masked. +func normalizeProxmoxDiskHealth(health string) string { + trimmed := strings.TrimSpace(health) + upper := strings.ToUpper(trimmed) + switch { + case upper == "OK", strings.Contains(upper, "PASS"): + return "PASSED" + case strings.Contains(upper, "FAIL"): + return "FAILED" + default: + return trimmed + } +} + // physicalDisksFromHostAgentSMART builds PhysicalDisk entries for a node from // its linked host agent's SMART inventory. This is the fallback when the // Proxmox disks/list query fails: PVE probes SMART per disk inside that call,