diff --git a/audit/internal/platform/sat_fan_stress.go b/audit/internal/platform/sat_fan_stress.go
index ccaf30d..5639b30 100644
--- a/audit/internal/platform/sat_fan_stress.go
+++ b/audit/internal/platform/sat_fan_stress.go
@@ -32,8 +32,8 @@ type FanCheckOptions struct {
// FanReading holds one fan sensor reading.
type FanReading struct {
- Name string
- RPM float64
+ Name string `json:"name"`
+ RPM float64 `json:"rpm"`
}
type cachedPowerReading struct {
diff --git a/audit/internal/webui/page_topo_diagram.go b/audit/internal/webui/page_topo_diagram.go
index 71bbf22..96eae8f 100644
--- a/audit/internal/webui/page_topo_diagram.go
+++ b/audit/internal/webui/page_topo_diagram.go
@@ -694,21 +694,59 @@ func renderTopoFanRow(fans []schema.HardwareFanSensor, ceilByName, observedByNam
period, glyphSz, glyphSz) + topoFanGlyphPaths() + ``
}
- fillBar := ""
+ measured := 0
+ fillH := 0.0
if duty >= 0 {
- fillBar = fmt.Sprintf(`
`, duty, stroke)
+ measured = 1
+ fillH = duty
}
+ fillBar := fmt.Sprintf(``, fillH, stroke)
- fmt.Fprintf(&b, ``+
`%s%s
`,
- html.EscapeString(title), side, side, stroke, text, fillBar, glyph)
+ html.EscapeString(name), int(ceil), measured, html.EscapeString(title),
+ side, side, stroke, text, fillBar, glyph)
}
b.WriteString(``)
+ b.WriteString(topoFanLiveScript())
return b.String()
}
+// topoFanLiveScript polls the already-collected live-metrics snapshot
+// (/api/metrics/latest — served from memory, no BMC call) every 5s and
+// updates each fan tile's spin rate, duty fill and tooltip in place. 5s is
+// the metrics collector's own sampling period, so polling faster would only
+// re-read identical numbers; the endpoint is a mutex read + small JSON, so
+// this is cheap even with many viewers.
+func topoFanLiveScript() string {
+ return ``
+}
+
// fanSpinPeriodSec maps an absolute fan RPM to a CSS animation period (one
// full turn of the glyph, in seconds). The real period would be 60/RPM — a
// blur at any real fan speed — so it is compressed into a band the eye can
diff --git a/audit/internal/webui/page_topo_test.go b/audit/internal/webui/page_topo_test.go
index acf908e..c0df23f 100644
--- a/audit/internal/webui/page_topo_test.go
+++ b/audit/internal/webui/page_topo_test.go
@@ -206,6 +206,13 @@ func TestTopoPageRendersCoolingFansAsFlexRow(t *testing.T) {
if !strings.Contains(body, "ceiling not measured") {
t.Fatalf("topo fan tooltip should note the ceiling is unmeasured: %s", body)
}
+ // Live-update: each tile is addressable and the poll script is present.
+ if n := strings.Count(body, `class="topo-fan-tile"`); n != 2 {
+ t.Fatalf("expected 2 addressable fan tiles, got %d", n)
+ }
+ if !strings.Contains(body, `data-fan="FAN1"`) || !strings.Contains(body, "/api/metrics/latest") {
+ t.Fatalf("topo fan row missing live-update wiring: %s", body)
+ }
// Component-detail fallback endpoint must resolve the "fan" type.
rec2 := httptest.NewRecorder()