feat(webui): break the topology PSU row into one card per supply
Each PSU is its own card, coloured by its own status — a failed unit goes red on its own instead of dragging a single grouped card down — and shows input voltage + draw (measured output/input, else nameplate rating). Cards click through to the PSU detail modal and carry data-psu so the shared topoLiveScript refreshes their wattage from /api/metrics/latest on the same 5s poll as the fan tiles. 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
a4853a0a4f
commit
59271fa674
@@ -592,33 +592,16 @@ func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string
|
||||
}
|
||||
b.WriteString(renderTopoFlexRow("Firmware", firmwareItems))
|
||||
|
||||
if len(hw.PowerSupplies) > 0 {
|
||||
var tally topoStatusTally
|
||||
watt := 0
|
||||
for _, psu := range hw.PowerSupplies {
|
||||
tally.add(classifyTopoSeverity(psu.Status))
|
||||
if psu.WattageW != nil {
|
||||
watt = *psu.WattageW
|
||||
}
|
||||
}
|
||||
fill, stroke, text := topoSeverityColors(tally.worst())
|
||||
sublabel := ""
|
||||
if watt > 0 {
|
||||
sublabel = fmt.Sprintf("%dW each", watt)
|
||||
}
|
||||
b.WriteString(renderTopoFlexRow("Power Supplies", []topoCardInfo{{
|
||||
label: "Power Supplies", sublabel: sublabel, count: len(hw.PowerSupplies),
|
||||
statusLine: tally.line(),
|
||||
fillVar: fill, strokeVar: stroke, textVar: text,
|
||||
detailType: "psu",
|
||||
}}))
|
||||
hasPSU := len(hw.PowerSupplies) > 0
|
||||
if hasPSU {
|
||||
b.WriteString(renderTopoPSURow(hw.PowerSupplies))
|
||||
}
|
||||
|
||||
// Cooling fans — one small clickable square per fan (no PCIe/CPU affinity,
|
||||
// arbitrary count, so a wrapping flex row like PSUs rather than SVG boxes).
|
||||
// Square SIZE encodes the fan's ceiling RPM (its class); the coloured FILL
|
||||
// rising from the bottom encodes live duty cycle (current / ceiling).
|
||||
if fans := dedupeFansByName(hw.Sensors); len(fans) > 0 {
|
||||
// Cooling fans — one small clickable square per fan. Square SIZE encodes
|
||||
// the fan's ceiling RPM (its class); the coloured FILL rising from the
|
||||
// bottom encodes live duty cycle (current / ceiling).
|
||||
fans := dedupeFansByName(hw.Sensors)
|
||||
if len(fans) > 0 {
|
||||
current := map[string]float64{}
|
||||
for _, f := range fans {
|
||||
if f.RPM != nil {
|
||||
@@ -628,9 +611,75 @@ func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string
|
||||
b.WriteString(renderTopoFanRow(fans, platform.ResolveFanMaxRPM(current), platform.ObservedFanMaxRPM()))
|
||||
}
|
||||
|
||||
if hasPSU || len(fans) > 0 {
|
||||
b.WriteString(topoLiveScript())
|
||||
}
|
||||
|
||||
return topoCard("Topology", b.String())
|
||||
}
|
||||
|
||||
// renderTopoPSURow renders the POWER SUPPLIES row: one card per PSU, coloured
|
||||
// by that PSU's own status (a failed unit goes red on its own), showing input
|
||||
// voltage and draw. Cards click through to the shared PSU detail modal and
|
||||
// carry data-psu so topoLiveScript can refresh the wattage in place.
|
||||
func renderTopoPSURow(psus []schema.HardwarePowerSupply) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(topoRowHeading("Power Supplies"))
|
||||
b.WriteString(`<div style="display:flex;flex-wrap:wrap;gap:8px">`)
|
||||
for i, p := range psus {
|
||||
sev := classifyTopoSeverity(p.Status)
|
||||
fill, stroke, text := topoSeverityColors(sev)
|
||||
|
||||
label := fmt.Sprintf("PSU %d", i)
|
||||
if p.Slot != nil && strings.TrimSpace(*p.Slot) != "" {
|
||||
label = strings.TrimSpace(*p.Slot)
|
||||
}
|
||||
|
||||
var parts []string
|
||||
if p.InputVoltage != nil && *p.InputVoltage > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%.0f V", *p.InputVoltage))
|
||||
}
|
||||
if w := psuWatts(p); w != "" {
|
||||
parts = append(parts, w)
|
||||
}
|
||||
detail := strings.Join(parts, " · ")
|
||||
|
||||
statusWord := ""
|
||||
if sev >= 2 {
|
||||
statusWord = topoSeverityStatus(p.Status)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, `<div class="topo-psu-tile" data-psu="%d" onclick="openComponentDetail('psu')" `+
|
||||
`style="cursor:pointer;min-width:96px;padding:8px 11px;border-radius:6px;background:%s;border:1px solid %s;color:%s">`+
|
||||
`<div style="font-size:13px;font-weight:700">%s</div>`,
|
||||
i, fill, stroke, text, html.EscapeString(label))
|
||||
if detail != "" {
|
||||
fmt.Fprintf(&b, `<div class="topo-psu-detail" style="font-size:11px;opacity:.85;margin-top:2px">%s</div>`, html.EscapeString(detail))
|
||||
}
|
||||
if statusWord != "" {
|
||||
fmt.Fprintf(&b, `<div style="font-size:10px;font-weight:700;margin-top:3px">%s</div>`, html.EscapeString(strings.ToUpper(statusWord)))
|
||||
}
|
||||
b.WriteString(`</div>`)
|
||||
}
|
||||
b.WriteString(`</div>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// psuWatts picks the most meaningful power figure for a PSU card: measured
|
||||
// output, else measured input, else the nameplate rating.
|
||||
func psuWatts(p schema.HardwarePowerSupply) string {
|
||||
switch {
|
||||
case p.OutputPowerW != nil && *p.OutputPowerW > 0:
|
||||
return fmt.Sprintf("%.0f W", *p.OutputPowerW)
|
||||
case p.InputPowerW != nil && *p.InputPowerW > 0:
|
||||
return fmt.Sprintf("%.0f W", *p.InputPowerW)
|
||||
case p.WattageW != nil && *p.WattageW > 0:
|
||||
return fmt.Sprintf("%d W rated", *p.WattageW)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// renderTopoFanRow renders the COOLING row. ceilByName (from
|
||||
// platform.ResolveFanMaxRPM) has a value for every fan and drives tile size.
|
||||
// observedByName (from platform.ObservedFanMaxRPM) holds only ceilings that
|
||||
@@ -710,37 +759,46 @@ func renderTopoFanRow(fans []schema.HardwareFanSensor, ceilByName, observedByNam
|
||||
side, side, stroke, text, fillBar, glyph)
|
||||
}
|
||||
b.WriteString(`</div>`)
|
||||
b.WriteString(topoFanLiveScript())
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// topoFanLiveScript polls the already-collected live-metrics snapshot
|
||||
// topoLiveScript 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 {
|
||||
// refreshes the fan tiles (spin rate, duty fill, tooltip) and PSU tiles
|
||||
// (wattage) in place. 5s is the metrics collector's own sampling period, so
|
||||
// polling faster only re-reads identical numbers; the endpoint is a mutex
|
||||
// read + small JSON, so this stays cheap with many viewers.
|
||||
func topoLiveScript() string {
|
||||
return `<script>(function(){
|
||||
var tiles=document.querySelectorAll('.topo-fan-tile');
|
||||
if(!tiles.length)return;
|
||||
var fans=document.querySelectorAll('.topo-fan-tile');
|
||||
var psus=document.querySelectorAll('.topo-psu-tile');
|
||||
if(!fans.length&&!psus.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');
|
||||
});
|
||||
if(!m)return;
|
||||
if(m.fans){
|
||||
var by={};m.fans.forEach(function(f){by[f.name]=f.rpm;});
|
||||
fans.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');
|
||||
});
|
||||
}
|
||||
if(m.psus){
|
||||
psus.forEach(function(t){
|
||||
var p=m.psus[parseInt(t.dataset.psu,10)];if(!p||!(p.power_w>0))return;
|
||||
var d=t.querySelector('.topo-psu-detail');if(!d)return;
|
||||
d.textContent=d.textContent.replace(/[\d.]+ W(?: rated)?/, Math.round(p.power_w)+' W');
|
||||
});
|
||||
}
|
||||
}).catch(function(){});
|
||||
}
|
||||
setInterval(tick,5000);tick();
|
||||
|
||||
@@ -90,15 +90,22 @@ func TestTopoPageRendersArbitraryPSUAndFirmwareCountsAsFlexRows(t *testing.T) {
|
||||
path := filepath.Join(dir, "audit.json")
|
||||
|
||||
okStatus := "OK"
|
||||
failStatus := "Critical"
|
||||
watt := 3000
|
||||
volt := 230.0
|
||||
|
||||
var psus []schema.HardwarePowerSupply
|
||||
for i := 0; i < 6; i++ {
|
||||
slot := strconv.Itoa(i)
|
||||
st := okStatus
|
||||
if i == 3 {
|
||||
st = failStatus
|
||||
}
|
||||
psus = append(psus, schema.HardwarePowerSupply{
|
||||
HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus},
|
||||
HardwareComponentStatus: schema.HardwareComponentStatus{Status: &st},
|
||||
Slot: &slot,
|
||||
WattageW: &watt,
|
||||
InputVoltage: &volt,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -132,14 +139,20 @@ func TestTopoPageRendersArbitraryPSUAndFirmwareCountsAsFlexRows(t *testing.T) {
|
||||
if !strings.Contains(body, "BIOS") || !strings.Contains(body, "BMC") {
|
||||
t.Fatalf("topo page missing BIOS/BMC firmware boxes: %s", body)
|
||||
}
|
||||
// All 6 PSUs must be represented, grouped into one stacked card with a
|
||||
// count rather than 6 separate boxes.
|
||||
if !strings.Contains(body, "Power Supplies ×6") {
|
||||
t.Fatalf("topo page missing grouped Power Supplies x6 card: %s", body)
|
||||
// One card per PSU (6), each clickable, each showing voltage + power.
|
||||
if n := strings.Count(body, `class="topo-psu-tile"`); n != 6 {
|
||||
t.Fatalf("expected 6 per-PSU cards, got %d", n)
|
||||
}
|
||||
if strings.Count(body, `onclick="openComponentDetail('psu')"`) != 1 &&
|
||||
strings.Count(body, `onclick="openComponentDetail('psu')"`) != 1 {
|
||||
t.Fatalf("expected exactly one clickable PSU group card, not one per PSU: %s", body)
|
||||
if n := strings.Count(body, `onclick="openComponentDetail('psu')"`) +
|
||||
strings.Count(body, `onclick="openComponentDetail('psu')"`); n != 6 {
|
||||
t.Fatalf("expected one clickable card per PSU (6), got %d", n)
|
||||
}
|
||||
if !strings.Contains(body, "230 V · 3000 W rated") {
|
||||
t.Fatalf("PSU card missing voltage/power line: %s", body)
|
||||
}
|
||||
// The one failed PSU is coloured red on its own (crit token) and labelled.
|
||||
if !strings.Contains(body, "var(--crit-bg)") || !strings.Contains(body, "CRITICAL") {
|
||||
t.Fatalf("failed PSU should render individually as critical: %s", body)
|
||||
}
|
||||
// Firmware/PSU rows must be flex-wrap HTML (arbitrary count, no overlap),
|
||||
// not absolutely-positioned SVG rects sharing fixed x/y coordinates.
|
||||
|
||||
Reference in New Issue
Block a user