diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 2b21f4c57..e43557f3d 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -1113,9 +1113,12 @@ host agent reports SMART health, SMART identity, ZFS pool membership, or NVMe trustworthy health, wearout, model, serial, WWN, type, size, or pool data, the merge path in `internal/monitoring/monitor.go` must promote that missing data into the canonical physical-disk model without overwriting provider truth. The -Proxmox polling runtime in `internal/monitoring/monitor_pve.go` must evaluate -disk alerts only after that merged disk view exists, so controller-backed disks -do not lose health and endurance coverage between collection and alerting. +read-state sensor conversion must preserve SMART `SizeBytes` so subsequent +refreshes keep whole-disk capacity evidence available for Proxmox disk merges. +The Proxmox polling runtime in `internal/monitoring/monitor_pve.go` must +evaluate disk alerts only after that merged disk view exists, so +controller-backed disks do not lose health and endurance coverage between +collection and alerting. That same host-agent temperature boundary must prefer a recent linked host-agent payload over legacy SSH collection once the agent provides any usable CPU, NVMe, GPU, or SMART temperature reading. `internal/monitoring/monitor_polling_node_helpers.go` diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 19361c746..26635bd4e 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -1592,7 +1592,11 @@ The shared PVE setup-script SMART wrapper remains a storage/recovery dependency only for disk-temperature evidence. Storage surfaces may depend on its explicit `-d sat` and `-d scsi` retries for active direct Linux SATA/SAT-style disks, but they must not fork a storage-local disk-temperature collector or replace the -API-owned setup-script contract. +API-owned setup-script contract. Storage physical-disk rows also depend on the +unified-resource disk contract preserving Proxmox node/instance metadata and +SMART capacity when Proxmox inventory and host-agent SMART telemetry merge; +storage/recovery consumers must read that canonical merged physical-disk +resource rather than rebuilding a local Proxmox/S.M.A.R.T. join. Notification webhook management changes on shared `internal/api/` handlers are likewise adjacent only: the webhook `signingSecret` payload field and its diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index d43bf82e5..38491007a 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -28,6 +28,10 @@ reported as 0..100 percentages. Unified-resource host metric payloads and host-derived storage adapters must clamp those reported percentages directly rather than passing them through ratio-to-percent normalization, which is reserved for providers that report 0..1 usage ratios. +Physical-disk resources own cross-source disk identity. When Proxmox inventory +and host-agent SMART telemetry describe the same device, the merged resource +must retain Proxmox node/instance source payloads while carrying SMART +temperature, capacity, health, and identity enrichment. ## Canonical Files @@ -2842,6 +2846,14 @@ Canonical physical-disk views now expose the full disk identity and SMART metadata needed by monitoring refresh paths, so physical-disk temperature and SMART merges can run from unified `ReadState` instead of from snapshot-owned disk arrays. +When host-agent SMART and Proxmox physical-disk rows merge, the unified +resource must preserve both the enriched `PhysicalDiskMeta` and the Proxmox +source payload (`ProxmoxData.NodeName`, `Instance`, and source id). A +SMART-enriched disk must not lose Proxmox node membership, or later read-state +refreshes and Proxmox-scoped views can drop the disk even though inventory +still exists. `HostSMARTMeta` must carry `sizeBytes` through adapter, clone, +read-state, and API transport so disk capacity remains available after +registry rebuilds. That same canonical physical-disk view must also expose source-independent host context. When a disk is API-backed rather than node-backed, typed views should fall back to canonical host identity such as `identity.hostnames` instead of diff --git a/internal/models/converters.go b/internal/models/converters.go index d4869fe97..a9ec8ee2e 100644 --- a/internal/models/converters.go +++ b/internal/models/converters.go @@ -884,6 +884,7 @@ func hostSensorSummaryToFrontend(src HostSensorSummary) *HostSensorSummaryFronte Serial: disk.Serial, WWN: disk.WWN, Type: disk.Type, + SizeBytes: disk.SizeBytes, Temperature: disk.Temperature, Health: disk.Health, Standby: disk.Standby, diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index c13b15f57..3e36645b1 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -792,11 +792,12 @@ func (s HostSensorSummaryFrontend) NormalizeCollections() HostSensorSummaryFront // HostDiskSMARTFrontend represents S.M.A.R.T. data for a disk from a host agent. type HostDiskSMARTFrontend struct { - Device string `json:"device"` // Device name (e.g., sda) - Model string `json:"model,omitempty"` // Disk model - Serial string `json:"serial,omitempty"` // Serial number - WWN string `json:"wwn,omitempty"` // World Wide Name - Type string `json:"type,omitempty"` // Transport type: sata, sas, nvme + Device string `json:"device"` // Device name (e.g., sda) + Model string `json:"model,omitempty"` // Disk model + Serial string `json:"serial,omitempty"` // Serial number + WWN string `json:"wwn,omitempty"` // World Wide Name + Type string `json:"type,omitempty"` // Transport type: sata, sas, nvme + SizeBytes int64 `json:"sizeBytes,omitempty"` Temperature int `json:"temperature"` // Temperature in Celsius Health string `json:"health,omitempty"` // PASSED, FAILED, UNKNOWN Standby bool `json:"standby,omitempty"` // True if disk was in standby diff --git a/internal/monitoring/host_agent_temps.go b/internal/monitoring/host_agent_temps.go index fd35a6787..5d10cabf3 100644 --- a/internal/monitoring/host_agent_temps.go +++ b/internal/monitoring/host_agent_temps.go @@ -188,6 +188,7 @@ func convertUnifiedHostSMART(smart []unifiedresources.HostSMARTMeta) []models.Ho Serial: disk.Serial, WWN: disk.WWN, Type: disk.Type, + SizeBytes: disk.SizeBytes, Temperature: disk.Temperature, Health: disk.Health, Standby: disk.Standby, diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index e05487a1a..8cf06ea88 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -3430,6 +3430,7 @@ func hostSensorsFromReadStateView(sensors *unifiedresources.HostSensorMeta) mode Serial: smart.Serial, WWN: smart.WWN, Type: smart.Type, + SizeBytes: smart.SizeBytes, Temperature: smart.Temperature, Health: smart.Health, Standby: smart.Standby, diff --git a/internal/monitoring/monitor_host_agents_test.go b/internal/monitoring/monitor_host_agents_test.go index 696d28a42..ec50d9ffb 100644 --- a/internal/monitoring/monitor_host_agents_test.go +++ b/internal/monitoring/monitor_host_agents_test.go @@ -211,6 +211,33 @@ func TestFindLinkedProxmoxEntityWithHints_UsesExactEndpointHostnameBeforeNameFal } } +func TestHostSensorsFromReadStateViewPreservesSMARTSizeBytes(t *testing.T) { + sensors := &unifiedresources.HostSensorMeta{ + SMART: []unifiedresources.HostSMARTMeta{ + { + Device: "/dev/sda", + Model: "CT240BX500SSD1", + Serial: "SATA-SERIAL-1", + Type: "sata", + SizeBytes: 240_057_409_536, + Temperature: 32, + Health: "PASSED", + }, + }, + } + + got := hostSensorsFromReadStateView(sensors) + if len(got.SMART) != 1 { + t.Fatalf("SMART row count = %d, want 1", len(got.SMART)) + } + if got.SMART[0].SizeBytes != 240_057_409_536 { + t.Fatalf("SMART SizeBytes = %d, want 240057409536", got.SMART[0].SizeBytes) + } + if got.SMART[0].Temperature != 32 { + t.Fatalf("SMART temperature = %d, want 32", got.SMART[0].Temperature) + } +} + func TestApplyDockerReportNormalizesContainerCPUCapacityAcceptedIngestProof(t *testing.T) { monitor := newTestMonitor(t) token := &config.APITokenRecord{ID: "token-docker-cpu", Name: "Docker Token"} diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index dd766a38f..2340410f8 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -219,6 +219,7 @@ func resourceFromHost(host models.Host) (Resource, ResourceIdentity) { Serial: s.Serial, WWN: s.WWN, Type: s.Type, + SizeBytes: s.SizeBytes, Temperature: s.Temperature, Health: s.Health, Standby: s.Standby, diff --git a/internal/unifiedresources/canonical_ids_types_test.go b/internal/unifiedresources/canonical_ids_types_test.go index 45ad029a3..5fedbd238 100644 --- a/internal/unifiedresources/canonical_ids_types_test.go +++ b/internal/unifiedresources/canonical_ids_types_test.go @@ -22,6 +22,32 @@ func TestCanonicalResourceTypeDoesNotAliasHost(t *testing.T) { } } +func TestHostSMARTMetaCarriesSizeBytesJSONContract(t *testing.T) { + payload := HostSMARTMeta{ + Device: "/dev/sda", + Model: "CT240BX500SSD1", + Serial: "SATA-SERIAL-1", + Type: "sata", + SizeBytes: 240_057_409_536, + } + + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal HostSMARTMeta: %v", err) + } + if !strings.Contains(string(data), `"sizeBytes":240057409536`) { + t.Fatalf("HostSMARTMeta JSON did not carry sizeBytes: %s", data) + } + + var decoded HostSMARTMeta + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal HostSMARTMeta: %v", err) + } + if decoded.SizeBytes != payload.SizeBytes { + t.Fatalf("decoded sizeBytes = %d, want %d", decoded.SizeBytes, payload.SizeBytes) + } +} + func TestContractResourceType(t *testing.T) { tests := []struct { name string diff --git a/internal/unifiedresources/monitor_adapter_read_state_test.go b/internal/unifiedresources/monitor_adapter_read_state_test.go index 7b4b0198b..2e0b11e7c 100644 --- a/internal/unifiedresources/monitor_adapter_read_state_test.go +++ b/internal/unifiedresources/monitor_adapter_read_state_test.go @@ -99,6 +99,64 @@ func TestMonitorAdapterReadStateForwardsToRegistry(t *testing.T) { } } +func TestMonitorAdapterPhysicalDiskReadStateRetainsProxmoxIdentityAfterSMARTMerge(t *testing.T) { + adapter := NewMonitorAdapter(NewRegistry(nil)) + now := time.Date(2026, 7, 7, 10, 0, 0, 0, time.UTC) + + adapter.PopulateFromSnapshot(models.StateSnapshot{ + Hosts: []models.Host{ + { + ID: "host-pve", + Hostname: "pve", + Status: "online", + LastSeen: now, + Sensors: models.HostSensorSummary{ + SMART: []models.HostDiskSMART{ + { + Device: "/dev/nvme0n1", + Model: "KINGSTON SNV3S2000G", + Serial: "SERIAL-NVME-0", + Type: "nvme", + SizeBytes: 2_000_398_934_016, + Temperature: 37, + Health: "PASSED", + }, + }, + }, + }, + }, + PhysicalDisks: []models.PhysicalDisk{ + { + ID: "homelab-pve--dev-nvme0n1", + Node: "pve", + Instance: "homelab", + DevPath: "/dev/nvme0n1", + Model: "KINGSTON SNV3S2000G", + Serial: "SERIAL-NVME-0", + Type: "nvme", + Size: 2_000_398_934_016, + Health: "PASSED", + LastChecked: now, + }, + }, + }) + + disks := adapter.PhysicalDisks() + if len(disks) != 1 { + t.Fatalf("physical disk count = %d, want 1", len(disks)) + } + disk := disks[0] + if disk.Node() != "pve" || disk.Instance() != "homelab" { + t.Fatalf("merged disk lost Proxmox identity: node=%q instance=%q", disk.Node(), disk.Instance()) + } + if disk.Temperature() != 37 { + t.Fatalf("merged disk temperature = %d, want 37", disk.Temperature()) + } + if disk.SizeBytes() != 2_000_398_934_016 { + t.Fatalf("merged disk sizeBytes = %d, want 2000398934016", disk.SizeBytes()) + } +} + func TestMonitorAdapterReadStateReturnsClonedIncidents(t *testing.T) { adapter := NewMonitorAdapter(NewRegistry(nil)) now := time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC) diff --git a/internal/unifiedresources/registry.go b/internal/unifiedresources/registry.go index d778d9e5f..82e718f55 100644 --- a/internal/unifiedresources/registry.go +++ b/internal/unifiedresources/registry.go @@ -2649,9 +2649,6 @@ func (rr *ResourceRegistry) mergeInto(existing *Resource, incoming Resource, sou // Update source payload switch source { case SourceProxmox: - if mergedPhysicalDisk { - break - } existing.Proxmox = mergeProxmoxData(existing.Proxmox, incoming.Proxmox) case SourceAgent: if mergedPhysicalDisk { diff --git a/internal/unifiedresources/registry_test.go b/internal/unifiedresources/registry_test.go index 29b34f2f2..eb40666b5 100644 --- a/internal/unifiedresources/registry_test.go +++ b/internal/unifiedresources/registry_test.go @@ -2206,6 +2206,9 @@ func TestResourceRegistry_IngestSnapshotMergesAgentAndProxmoxPhysicalDisksByIden if disk.PhysicalDisk == nil || disk.PhysicalDisk.Temperature != 37 { t.Fatalf("expected merged SMART temperature from agent disk, got %+v", disk.PhysicalDisk) } + if disk.Proxmox == nil || disk.Proxmox.NodeName != "tower" || disk.Proxmox.Instance != "pve-tower" { + t.Fatalf("expected merged disk to retain Proxmox node identity, got %+v", disk.Proxmox) + } } // Regression for issue #1483: a legacy host agent reports an NVMe disk keyed by diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index b04461cd5..63a3bf12c 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -568,6 +568,7 @@ type HostSMARTMeta struct { Serial string `json:"serial,omitempty"` WWN string `json:"wwn,omitempty"` Type string `json:"type,omitempty"` + SizeBytes int64 `json:"sizeBytes,omitempty"` Temperature int `json:"temperature"` Health string `json:"health"` Standby bool `json:"standby,omitempty"`