diff --git a/audit/internal/webui/page_topo_diagram.go b/audit/internal/webui/page_topo_diagram.go
index 6c77d57..048cdcd 100644
--- a/audit/internal/webui/page_topo_diagram.go
+++ b/audit/internal/webui/page_topo_diagram.go
@@ -628,19 +628,30 @@ func renderTopoPSURow(psus []schema.HardwarePowerSupply) string {
b.WriteString(`
`)
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, `
`+
- `
%s
`,
- i, fill, stroke, text, html.EscapeString(label))
- if detail != "" {
- fmt.Fprintf(&b, `
%s
`, 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, `
`,
+ i, rating, voltAttr, stroke, text)
+ fmt.Fprintf(&b, `
`, fillH, stroke)
+ fmt.Fprintf(&b, `
%s
`, html.EscapeString(label))
+ fmt.Fprintf(&b, `
%s
`, html.EscapeString(detail))
if statusWord != "" {
fmt.Fprintf(&b, `
%s
`, html.EscapeString(strings.ToUpper(statusWord)))
}
- b.WriteString(`
`)
+ b.WriteString(`
`)
}
b.WriteString(`
`)
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(){});
diff --git a/audit/internal/webui/page_topo_test.go b/audit/internal/webui/page_topo_test.go
index 64963e0..185d5bf 100644
--- a/audit/internal/webui/page_topo_test.go
+++ b/audit/internal/webui/page_topo_test.go
@@ -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") {