diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 5d8c706cc3..1287859283 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -4120,3 +4120,23 @@ still recorded. Focused proof lives in (`TestMergeHostAgentSMARTIntoDisks_AgentWearoutDoesNotHideLowPVELife` and `TestMergeHostAgentSMARTIntoDisks_AgentWearoutFillsUnreportedPVELife`). This is synthetic merge evidence, not reporter acceptance or hardware confirmation. + +### TrueNAS system temperature rejects a synthesised CPU aggregate + +TrueNAS reports the `cputemp` series through a bare `cpu` legend entry, which +`canonicalSystemTemperatureKey` maps to the canonical `cpu_package` key. On +SCALE 25.10.7 running AMD hardware that aggregate has been observed at roughly +1.5x the hottest per-core reading (for example an 88C aggregate against a 59C +core max, with the cores matching `k10temp`/`Tctl`). Monitoring previously +trusted `cpu_package` unconditionally in `maxTrueNASSystemTemperature`, so the +false-high value reached the host temperature and fired a configured 80C +threshold alert at a real ~60C. The selector now compares `cpu_package` with +the hottest `cpu*` per-core entry and falls back to the per-core max only when +the package value exceeds it by more than 20%. A real package sensor stays +within a few degrees of the hottest core, so the margin retains genuine +package readings while discarding the synthesised aggregate. Package-only +payloads and per-core-only payloads keep their existing behaviour. Focused +proof lives in `internal/truenas/provider_test.go` +(`TestMaxTrueNASSystemTemperaturePrefersCoresOverSynthesisedAggregate`). This +is source-level proof, not reporter acceptance or appliance confirmation +(#2122). diff --git a/internal/truenas/provider.go b/internal/truenas/provider.go index f1634a8692..686391c7d9 100644 --- a/internal/truenas/provider.go +++ b/internal/truenas/provider.go @@ -1032,31 +1032,49 @@ func agentDataFromTrueNASSystem(connectionID string, system SystemInfo, disks [] return agent } +// trueNASCPUPackageOutlierRatio bounds how far the aggregated `cpu_package` +// reading may exceed the hottest individual core before Pulse treats it as a +// synthesised appliance aggregate. TrueNAS SCALE on AMD has been observed to +// report a bare `cpu` legend series roughly 1.5x the per-core values, while a +// real package sensor (for example Intel "Package id 0") stays within a few +// degrees of the hottest core. See #2122. +const trueNASCPUPackageOutlierRatio = 1.2 + func maxTrueNASSystemTemperature(system SystemInfo) *float64 { if len(system.TemperatureCelsius) == 0 { return nil } + + packageValue := 0.0 + hasPackage := false if value, ok := system.TemperatureCelsius["cpu_package"]; ok && value > 0 { - canonical := value - return &canonical + packageValue = value + hasPackage = true } - var best float64 - found := false + var coreMax float64 + hasCore := false for key, value := range system.TemperatureCelsius { key = strings.TrimSpace(strings.ToLower(key)) - if value <= 0 || !strings.HasPrefix(key, "cpu") { + if value <= 0 || key == "cpu_package" || !strings.HasPrefix(key, "cpu") { continue } - if !found || value > best { - best = value - found = true + if !hasCore || value > coreMax { + coreMax = value + hasCore = true } } - if !found { + + if hasPackage { + if hasCore && packageValue > coreMax*trueNASCPUPackageOutlierRatio { + return &coreMax + } + return &packageValue + } + if !hasCore { return nil } - return &best + return &coreMax } func sensorMetaFromTrueNASSystem(system SystemInfo, disks []Disk) *unifiedresources.HostSensorMeta { diff --git a/internal/truenas/provider_test.go b/internal/truenas/provider_test.go index 2b9474aa39..cd268a2660 100644 --- a/internal/truenas/provider_test.go +++ b/internal/truenas/provider_test.go @@ -1434,3 +1434,74 @@ func TestProviderSystemMetricHistoryUsesCanonicalAgentMetricIDs(t *testing.T) { t.Fatalf("expected netin history points, got %+v", history) } } + +func TestMaxTrueNASSystemTemperaturePrefersCoresOverSynthesisedAggregate(t *testing.T) { + cases := []struct { + name string + temps map[string]float64 + want *float64 + }{ + { + name: "synthesised cpu aggregate is ignored in favour of per-core max", + temps: map[string]float64{ + "cpu0": 59, "cpu1": 58, "cpu2": 57, "cpu3": 56, + "cpu4": 55, "cpu5": 54, "cpu6": 53, "cpu7": 52, + "cpu_package": 88, + }, + want: floatPtr(59), + }, + { + name: "real package reading near the hottest core is retained", + temps: map[string]float64{"cpu_package": 61.5, "cpu_core_0": 58.0, "cpu_core_1": 59.0}, + want: floatPtr(61.5), + }, + { + name: "package within the margin is retained even with one core present", + temps: map[string]float64{"cpu_package": 60, "cpu0": 55}, + want: floatPtr(60), + }, + { + name: "package exactly at the margin is retained", + temps: map[string]float64{"cpu_package": 66, "cpu0": 55}, + want: floatPtr(66), + }, + { + name: "package-only payload is unchanged", + temps: map[string]float64{"cpu_package": 55}, + want: floatPtr(55), + }, + { + name: "per-core-only payload still reports the hottest core", + temps: map[string]float64{"cpu0": 41, "cpu1": 47, "cpu2": 44}, + want: floatPtr(47), + }, + { + name: "empty payload has no temperature", + temps: nil, + want: nil, + }, + { + name: "non-positive readings are ignored", + temps: map[string]float64{"cpu_package": 0, "cpu0": 0}, + want: nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := maxTrueNASSystemTemperature(SystemInfo{TemperatureCelsius: tc.temps}) + switch { + case tc.want == nil && got != nil: + t.Fatalf("temperature = %v, want nil", *got) + case tc.want != nil && got == nil: + t.Fatalf("temperature = nil, want %v", *tc.want) + case tc.want != nil && got != nil && *got != *tc.want: + t.Fatalf("temperature = %v, want %v", *got, *tc.want) + } + }) + } +} + +func floatPtr(value float64) *float64 { + return &value +}