feat(webui): live-update topology fan tiles without a page reload
Each fan tile carries data-fan / data-ceil / data-measured and a small script polls /api/metrics/latest every 5s — the metrics collector's own sampling period, served from memory with no extra BMC call — updating each tile's spin rate, duty fill and tooltip in place. Polling faster would only re-read identical numbers; sampling the BMC faster would choke it under load. FanReading gains json tags for the endpoint. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VHG21rgTUiR1G3qFHTVmN
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d8b05069e0
commit
a4853a0a4f
@@ -694,21 +694,59 @@ func renderTopoFanRow(fans []schema.HardwareFanSensor, ceilByName, observedByNam
|
||||
period, glyphSz, glyphSz) + topoFanGlyphPaths() + `</svg>`
|
||||
}
|
||||
|
||||
fillBar := ""
|
||||
measured := 0
|
||||
fillH := 0.0
|
||||
if duty >= 0 {
|
||||
fillBar = fmt.Sprintf(`<div style="position:absolute;left:0;right:0;bottom:0;height:%.0f%%;background:%s;opacity:.55"></div>`, duty, stroke)
|
||||
measured = 1
|
||||
fillH = duty
|
||||
}
|
||||
fillBar := fmt.Sprintf(`<div class="topo-fan-fill" style="position:absolute;left:0;right:0;bottom:0;height:%.0f%%;background:%s;opacity:.55;transition:height .8s linear"></div>`, fillH, stroke)
|
||||
|
||||
fmt.Fprintf(&b, `<div title="%s" onclick="openComponentDetail('fan')" `+
|
||||
fmt.Fprintf(&b, `<div class="topo-fan-tile" data-fan="%s" data-ceil="%d" data-measured="%d" title="%s" onclick="openComponentDetail('fan')" `+
|
||||
`style="position:relative;overflow:hidden;width:%dpx;height:%dpx;display:flex;align-items:center;justify-content:center;`+
|
||||
`border-radius:5px;background:var(--surface-2);border:1px solid %s;color:%s;cursor:pointer">`+
|
||||
`%s<span style="position:relative;display:flex">%s</span></div>`,
|
||||
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(`</div>`)
|
||||
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 `<script>(function(){
|
||||
var tiles=document.querySelectorAll('.topo-fan-tile');
|
||||
if(!tiles.length)return;
|
||||
function period(rpm){var lo=1000,hi=13000,slow=2.2,fast=0.35;
|
||||
if(rpm<=lo)return slow;if(rpm>=hi)return fast;
|
||||
return slow+(rpm-lo)/(hi-lo)*(fast-slow);}
|
||||
function tick(){
|
||||
fetch('/api/metrics/latest',{cache:'no-store'}).then(function(r){return r.json();}).then(function(m){
|
||||
if(!m||!m.fans)return;
|
||||
var by={};m.fans.forEach(function(f){by[f.name]=f.rpm;});
|
||||
tiles.forEach(function(t){
|
||||
var rpm=by[t.dataset.fan];if(rpm==null)return;
|
||||
var ceil=parseFloat(t.dataset.ceil)||0;
|
||||
var svg=t.querySelector('.topo-fan-spin');
|
||||
if(svg&&rpm>0)svg.style.animationDuration=period(rpm).toFixed(2)+'s';
|
||||
var meas=t.dataset.measured==='1'&&ceil>0;
|
||||
var duty=meas?Math.max(0,Math.min(100,rpm/ceil*100)):-1;
|
||||
if(meas){var fill=t.querySelector('.topo-fan-fill');if(fill)fill.style.height=duty.toFixed(0)+'%';}
|
||||
t.title=t.dataset.fan+' · '+Math.round(rpm)+' RPM'+(meas?' · '+Math.round(duty)+'% duty (ceiling '+ceil+')':' · ceiling not measured — run Fan Ceiling Check');
|
||||
});
|
||||
}).catch(function(){});
|
||||
}
|
||||
setInterval(tick,5000);tick();
|
||||
})();</script>`
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user