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:
Mikhail Chusavitin
2026-09-04 12:09:37 +03:00
co-authored by Claude Sonnet 5
parent d8b05069e0
commit a4853a0a4f
3 changed files with 51 additions and 6 deletions
+42 -4
View File
@@ -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