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.
This commit is contained in:
pulse-triage[bot]
2026-10-02 02:09:48 +01:00
parent 579eee2907
commit 45eec9a20e
3 changed files with 31 additions and 6 deletions
@@ -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.
+12
View File
@@ -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 {
+12 -5
View File
@@ -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)
}
}