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">`)
|
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(){});
|
||||||
|
|||||||
@@ -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('psu')"`); n != 6 {
|
strings.Count(body, `onclick="openComponentDetail('psu')"`); 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") {
|
||||||
|
|||||||
Reference in New Issue
Block a user