From 45eec9a20eac55a356acfb1c17833cd6c8d067ad Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:09:48 +0100 Subject: [PATCH] Keep native TrueNAS Thermals in local and persisted host History Finish the connected #2077 outcome: persist an observed TrueNAS host temperature through the existing canonical writer, so local chart coverage cannot suppress the Thermals panel. Extend the pinned REST-to-chart control to cover the write, persistent readback and full local-window fast path; preserve source and absence gates. Change-source: pulse-maintainer Contract-Neutral: Restores existing TrueNAS Thermals history through its canonical writer; no agent lifecycle, schema, route, resource identity or alert-policy delta. --- .../v6/internal/subsystems/monitoring.md | 8 +++++++- internal/monitoring/monitor.go | 12 ++++++++++++ internal/monitoring/truenas_poller_test.go | 17 ++++++++++++----- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 312bb58e6e..af091c6477 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -319,6 +319,11 @@ assuming a missing member was idle. Other graphs remain independently usable. Native CPU temperature maps into the existing canonical host temperature and `temperature` History key, using the same package-versus-core outlier safeguard as current readings. Disk-temperature History uses the same envelope decoder. +The canonical writer also records each positive finite TrueNAS host temperature +in local and persistent `temperature` History, under the existing source/dedup +gates. A full local CPU window must not make the existing Thermals panel depend +on native fallback or lose its observed temperature. Missing temperature is not +written as zero, and no additional sensor writes are added to other sources. No frontend route, resource identity, alert suppression policy, credential scope or recognised-legacy transport boundary changes. @@ -333,7 +338,8 @@ shortened controls, not complete native windows. Extended `TestTrueNASPartialReportingPipeline` in `internal/monitoring/truenas_poller_test.go` crosses pinned-certificate HTTP, poller, canonical registry, writer, in-memory/persistent chart readbacks and -native fallback, including temperature. Existing CORE isolation, recognised +native fallback, including temperature and the sufficiently covered local +chart fast path. Existing CORE isolation, recognised transport/auth/TLS and modern SCALE aggregation tests remain required. Source proofs do not substitute for containing-release qualification or installed CORE live/collapsed/expanded/History acceptance. diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index b0f3e2e5fb..e2cf45886b 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -5539,6 +5539,18 @@ func (m *Monitor) syncUnifiedAgentMetrics(store ResourceStoreInterface, sinks .. metricKey := fmt.Sprintf("agent:%s", targetID) observedAt := unifiedResourceObservedAt(resource, now) + // Native TrueNAS temperature must also survive the local History path. + // A sufficiently covered ring/store read need not query native fallback. + if monitorHasSource(resource.Sources, unifiedresources.SourceTrueNAS) && resource.Temperature != nil { + value := *resource.Temperature + if value > 0 && !math.IsNaN(value) && !math.IsInf(value, 0) { + if m.metricsHistory != nil { + m.metricsHistory.AddGuestMetric(metricKey, "temperature", value, observedAt) + } + appendStoreWrite("agent", targetID, "temperature", value, observedAt) + } + } + if metric := resource.Metrics.CPU; metric != nil { value := metric.Percent if value == 0 { diff --git a/internal/monitoring/truenas_poller_test.go b/internal/monitoring/truenas_poller_test.go index bf8fe12a8f..bd99a72e19 100644 --- a/internal/monitoring/truenas_poller_test.go +++ b/internal/monitoring/truenas_poller_test.go @@ -2234,7 +2234,7 @@ func TestTrueNASPartialReportingPipeline(t *testing.T) { {"name":"cputemp","legend":["cputemp0","cputemp1"],"data":[[41,42]]}, {"name":"interface","identifier":"nic-a","legend":["rx","tx","overlap"],"data":[[8,16,8]]}, {"name":"disk","identifier":"disk-a","legend":["disk_octets_read","disk_octets_write"],"data":[[0,32]]} - ]`, 200, map[string]float64{"cpu": 10, "memory": 62.5, "netin": 8, "netout": 16, "diskread": 0, "diskwrite": 32}, "", true}, + ]`, 200, map[string]float64{"cpu": 10, "memory": 62.5, "netin": 8, "netout": 16, "diskread": 0, "diskwrite": 32, "temperature": 42}, "", true}, } var current atomic.Int64 var nativeEnd atomic.Int64 @@ -2389,7 +2389,7 @@ func TestTrueNASPartialReportingPipeline(t *testing.T) { persistent.WriteBatchSync(writes) inMemory := monitor.GetGuestMetrics("agent:"+instance.ID, time.Hour) chart := monitor.GetGuestMetricsForChart("agent:"+instance.ID, "agent", instance.ID, time.Hour) - for _, key := range []string{"cpu", "memory", "disk", "netin", "netout", "diskread", "diskwrite"} { + for _, key := range []string{"cpu", "memory", "disk", "netin", "netout", "diskread", "diskwrite", "temperature"} { if len(inMemory[key]) != counts[key] || len(chart[key]) != counts[key] { t.Errorf("cycle %d %s chart contains fabricated/lost points: memory=%d chart=%d want=%d", index, key, len(inMemory[key]), len(chart[key]), counts[key]) } @@ -2402,9 +2402,6 @@ func TestTrueNASPartialReportingPipeline(t *testing.T) { continue } wantNative := len(cycle.want) - if cycle.native { - wantNative++ - } if err != nil || id != instance.ID || len(native) != wantNative { t.Fatalf("cycle %d native History=%+v id=%q err=%v", index, native, id, err) } @@ -2430,4 +2427,14 @@ func TestTrueNASPartialReportingPipeline(t *testing.T) { } } } + // Once local CPU History covers the window, the chart's fast path must + // still retain Thermals without relying on another appliance request. + now := time.Now() + for i := 0; i <= 60; i++ { + monitor.metricsHistory.AddGuestMetric("agent:"+instance.ID, "cpu", 10, now.Add(time.Duration(i-60)*time.Minute)) + } + chart := monitor.GetGuestMetricsForChart("agent:"+instance.ID, "agent", instance.ID, time.Hour) + if points := chart["temperature"]; len(points) != 1 || points[0].Value != 42 { + t.Fatalf("sufficiently covered local History lost Thermals: %+v", points) + } }