feat(webui): show PSU load as a fill on the topology PSU card

Same idea as the fan duty-cycle fill: a bar rising from the bottom of
each PSU card for draw / nameplate rating, live-updated on the shared
5s /api/metrics/latest poll. Drawn only when the rating (wattage_w) is
known — a raw wattage with nothing to scale against is not a load
figure, so BMCs that report only input power (e.g. this MSI stand) keep
showing the plain watts. Detail line becomes "<v> V · <draw> / <rating> W
· <n>% load" when both are present.

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:37:59 +03:00
co-authored by Claude Sonnet 5
parent 59271fa674
commit e713504fc9
2 changed files with 68 additions and 23 deletions
+59 -21
View File
@@ -628,19 +628,30 @@ func renderTopoPSURow(psus []schema.HardwarePowerSupply) string {
b.WriteString(`<div style="display:flex;flex-wrap:wrap;gap:8px">`) b.WriteString(`<div style="display:flex;flex-wrap:wrap;gap:8px">`)
for i, p := range psus { for i, p := range psus {
sev := classifyTopoSeverity(p.Status) sev := classifyTopoSeverity(p.Status)
fill, stroke, text := topoSeverityColors(sev) _, stroke, text := topoSeverityColors(sev)
label := fmt.Sprintf("PSU %d", i) label := fmt.Sprintf("PSU %d", i)
if p.Slot != nil && strings.TrimSpace(*p.Slot) != "" { if p.Slot != nil && strings.TrimSpace(*p.Slot) != "" {
label = strings.TrimSpace(*p.Slot) label = strings.TrimSpace(*p.Slot)
} }
draw, haveDraw := psuDrawW(p)
rating := 0
if p.WattageW != nil && *p.WattageW > 0 {
rating = *p.WattageW
}
var parts []string var parts []string
if p.InputVoltage != nil && *p.InputVoltage > 0 { if p.InputVoltage != nil && *p.InputVoltage > 0 {
parts = append(parts, fmt.Sprintf("%.0f V", *p.InputVoltage)) parts = append(parts, fmt.Sprintf("%.0f V", *p.InputVoltage))
} }
if w := psuWatts(p); w != "" { switch {
parts = append(parts, w) case haveDraw && rating > 0:
parts = append(parts, fmt.Sprintf("%.0f / %d W · %.0f%% load", draw, rating, draw/float64(rating)*100))
case haveDraw:
parts = append(parts, fmt.Sprintf("%.0f W", draw))
case rating > 0:
parts = append(parts, fmt.Sprintf("%d W rated", rating))
} }
detail := strings.Join(parts, " · ") detail := strings.Join(parts, " · ")
@@ -649,34 +660,55 @@ func renderTopoPSURow(psus []schema.HardwarePowerSupply) string {
statusWord = topoSeverityStatus(p.Status) statusWord = topoSeverityStatus(p.Status)
} }
fmt.Fprintf(&b, `<div class="topo-psu-tile" data-psu="%d" onclick="openComponentDetail('psu')" `+ // Load fill (draw / rating) rises from the bottom of the card, the
`style="cursor:pointer;min-width:96px;padding:8px 11px;border-radius:6px;background:%s;border:1px solid %s;color:%s">`+ // same idea as the fan duty-cycle fill. Only drawn when the nameplate
`<div style="font-size:13px;font-weight:700">%s</div>`, // rating is known — a raw wattage with nothing to scale it against is
i, fill, stroke, text, html.EscapeString(label)) // not a load figure.
if detail != "" { loadPct := -1.0
fmt.Fprintf(&b, `<div class="topo-psu-detail" style="font-size:11px;opacity:.85;margin-top:2px">%s</div>`, html.EscapeString(detail)) if haveDraw && rating > 0 {
loadPct = draw / float64(rating) * 100
if loadPct < 0 {
loadPct = 0
} }
if loadPct > 100 {
loadPct = 100
}
}
fillH := 0.0
if loadPct >= 0 {
fillH = loadPct
}
voltAttr := ""
if p.InputVoltage != nil && *p.InputVoltage > 0 {
voltAttr = fmt.Sprintf("%.0f", *p.InputVoltage)
}
fmt.Fprintf(&b, `<div class="topo-psu-tile" data-psu="%d" data-psu-max="%d" data-psu-v="%s" onclick="openComponentDetail('psu')" `+
`style="position:relative;overflow:hidden;cursor:pointer;min-width:104px;padding:8px 11px;border-radius:6px;background:var(--surface-2);border:1px solid %s;color:%s">`,
i, rating, voltAttr, stroke, text)
fmt.Fprintf(&b, `<div class="topo-psu-fill" style="position:absolute;left:0;right:0;bottom:0;height:%.0f%%;background:%s;opacity:.5;transition:height .8s linear"></div>`, fillH, stroke)
fmt.Fprintf(&b, `<div style="position:relative"><div style="font-size:13px;font-weight:700">%s</div>`, html.EscapeString(label))
fmt.Fprintf(&b, `<div class="topo-psu-detail" style="font-size:11px;opacity:.9;margin-top:2px">%s</div>`, html.EscapeString(detail))
if statusWord != "" { if statusWord != "" {
fmt.Fprintf(&b, `<div style="font-size:10px;font-weight:700;margin-top:3px">%s</div>`, html.EscapeString(strings.ToUpper(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></div>`)
} }
b.WriteString(`</div>`) b.WriteString(`</div>`)
return b.String() return b.String()
} }
// psuWatts picks the most meaningful power figure for a PSU card: measured // psuDrawW returns the PSU's current power draw (measured output preferred,
// output, else measured input, else the nameplate rating. // else measured input).
func psuWatts(p schema.HardwarePowerSupply) string { func psuDrawW(p schema.HardwarePowerSupply) (float64, bool) {
switch { switch {
case p.OutputPowerW != nil && *p.OutputPowerW > 0: case p.OutputPowerW != nil && *p.OutputPowerW > 0:
return fmt.Sprintf("%.0f W", *p.OutputPowerW) return *p.OutputPowerW, true
case p.InputPowerW != nil && *p.InputPowerW > 0: case p.InputPowerW != nil && *p.InputPowerW > 0:
return fmt.Sprintf("%.0f W", *p.InputPowerW) return *p.InputPowerW, true
case p.WattageW != nil && *p.WattageW > 0:
return fmt.Sprintf("%d W rated", *p.WattageW)
default: default:
return "" return 0, false
} }
} }
@@ -794,9 +826,15 @@ func topoLiveScript() string {
} }
if(m.psus){ if(m.psus){
psus.forEach(function(t){ psus.forEach(function(t){
var p=m.psus[parseInt(t.dataset.psu,10)];if(!p||!(p.power_w>0))return; var p=m.psus[parseInt(t.dataset.psu,10)];if(!p)return;
var d=t.querySelector('.topo-psu-detail');if(!d)return; var w=p.power_w||0,max=parseFloat(t.dataset.psuMax)||0,v=t.dataset.psuV;
d.textContent=d.textContent.replace(/[\d.]+ W(?: rated)?/, Math.round(p.power_w)+' W'); var parts=[];
if(v)parts.push(v+' V');
if(w>0&&max>0)parts.push(Math.round(w)+' / '+max+' W · '+Math.round(w/max*100)+'% load');
else if(w>0)parts.push(Math.round(w)+' W');
else if(max>0)parts.push(max+' W rated');
var d=t.querySelector('.topo-psu-detail');if(d&&parts.length)d.textContent=parts.join(' · ');
if(w>0&&max>0){var f=t.querySelector('.topo-psu-fill');if(f)f.style.height=Math.max(0,Math.min(100,w/max*100)).toFixed(0)+'%';}
}); });
} }
}).catch(function(){}); }).catch(function(){});
+9 -2
View File
@@ -93,6 +93,7 @@ func TestTopoPageRendersArbitraryPSUAndFirmwareCountsAsFlexRows(t *testing.T) {
failStatus := "Critical" failStatus := "Critical"
watt := 3000 watt := 3000
volt := 230.0 volt := 230.0
draw := 1500.0
var psus []schema.HardwarePowerSupply var psus []schema.HardwarePowerSupply
for i := 0; i < 6; i++ { for i := 0; i < 6; i++ {
@@ -101,11 +102,13 @@ func TestTopoPageRendersArbitraryPSUAndFirmwareCountsAsFlexRows(t *testing.T) {
if i == 3 { if i == 3 {
st = failStatus st = failStatus
} }
d := draw
psus = append(psus, schema.HardwarePowerSupply{ psus = append(psus, schema.HardwarePowerSupply{
HardwareComponentStatus: schema.HardwareComponentStatus{Status: &st}, HardwareComponentStatus: schema.HardwareComponentStatus{Status: &st},
Slot: &slot, Slot: &slot,
WattageW: &watt, WattageW: &watt,
InputVoltage: &volt, InputVoltage: &volt,
InputPowerW: &d,
}) })
} }
@@ -147,8 +150,12 @@ func TestTopoPageRendersArbitraryPSUAndFirmwareCountsAsFlexRows(t *testing.T) {
strings.Count(body, `onclick="openComponentDetail(&#39;psu&#39;)"`); n != 6 { strings.Count(body, `onclick="openComponentDetail(&#39;psu&#39;)"`); n != 6 {
t.Fatalf("expected one clickable card per PSU (6), got %d", n) t.Fatalf("expected one clickable card per PSU (6), got %d", n)
} }
if !strings.Contains(body, "230 V · 3000 W rated") { if !strings.Contains(body, "230 V · 1500 / 3000 W · 50% load") {
t.Fatalf("PSU card missing voltage/power line: %s", body) t.Fatalf("PSU card missing voltage / draw / load line: %s", body)
}
// Load fill (draw/rating) is drawn, same idea as the fan duty fill.
if !strings.Contains(body, `class="topo-psu-fill"`) || !strings.Contains(body, "height:50%") {
t.Fatalf("PSU card missing load fill: %s", body)
} }
// The one failed PSU is coloured red on its own (crit token) and labelled. // 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") { if !strings.Contains(body, "var(--crit-bg)") || !strings.Contains(body, "CRITICAL") {