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:
co-authored by
Claude Sonnet 5
parent
59271fa674
commit
e713504fc9
@@ -628,19 +628,30 @@ func renderTopoPSURow(psus []schema.HardwarePowerSupply) string {
|
||||
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)
|
||||
_, stroke, text := topoSeverityColors(sev)
|
||||
|
||||
label := fmt.Sprintf("PSU %d", i)
|
||||
if p.Slot != nil && 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
|
||||
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)
|
||||
switch {
|
||||
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, " · ")
|
||||
|
||||
@@ -649,34 +660,55 @@ func renderTopoPSURow(psus []schema.HardwarePowerSupply) string {
|
||||
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))
|
||||
// Load fill (draw / rating) rises from the bottom of the card, the
|
||||
// same idea as the fan duty-cycle fill. Only drawn when the nameplate
|
||||
// rating is known — a raw wattage with nothing to scale it against is
|
||||
// not a load figure.
|
||||
loadPct := -1.0
|
||||
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 != "" {
|
||||
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>`)
|
||||
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 {
|
||||
// psuDrawW returns the PSU's current power draw (measured output preferred,
|
||||
// else measured input).
|
||||
func psuDrawW(p schema.HardwarePowerSupply) (float64, bool) {
|
||||
switch {
|
||||
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:
|
||||
return fmt.Sprintf("%.0f W", *p.InputPowerW)
|
||||
case p.WattageW != nil && *p.WattageW > 0:
|
||||
return fmt.Sprintf("%d W rated", *p.WattageW)
|
||||
return *p.InputPowerW, true
|
||||
default:
|
||||
return ""
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,9 +826,15 @@ func topoLiveScript() string {
|
||||
}
|
||||
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');
|
||||
var p=m.psus[parseInt(t.dataset.psu,10)];if(!p)return;
|
||||
var w=p.power_w||0,max=parseFloat(t.dataset.psuMax)||0,v=t.dataset.psuV;
|
||||
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(){});
|
||||
|
||||
@@ -93,6 +93,7 @@ func TestTopoPageRendersArbitraryPSUAndFirmwareCountsAsFlexRows(t *testing.T) {
|
||||
failStatus := "Critical"
|
||||
watt := 3000
|
||||
volt := 230.0
|
||||
draw := 1500.0
|
||||
|
||||
var psus []schema.HardwarePowerSupply
|
||||
for i := 0; i < 6; i++ {
|
||||
@@ -101,11 +102,13 @@ func TestTopoPageRendersArbitraryPSUAndFirmwareCountsAsFlexRows(t *testing.T) {
|
||||
if i == 3 {
|
||||
st = failStatus
|
||||
}
|
||||
d := draw
|
||||
psus = append(psus, schema.HardwarePowerSupply{
|
||||
HardwareComponentStatus: schema.HardwareComponentStatus{Status: &st},
|
||||
Slot: &slot,
|
||||
WattageW: &watt,
|
||||
InputVoltage: &volt,
|
||||
InputPowerW: &d,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -147,8 +150,12 @@ func TestTopoPageRendersArbitraryPSUAndFirmwareCountsAsFlexRows(t *testing.T) {
|
||||
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)
|
||||
if !strings.Contains(body, "230 V · 1500 / 3000 W · 50% load") {
|
||||
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.
|
||||
if !strings.Contains(body, "var(--crit-bg)") || !strings.Contains(body, "CRITICAL") {
|
||||
|
||||
Reference in New Issue
Block a user