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") {