diff --git a/audit/internal/webui/page_topo_diagram.go b/audit/internal/webui/page_topo_diagram.go
index 96eae8f..6c77d57 100644
--- a/audit/internal/webui/page_topo_diagram.go
+++ b/audit/internal/webui/page_topo_diagram.go
@@ -592,33 +592,16 @@ func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string
}
b.WriteString(renderTopoFlexRow("Firmware", firmwareItems))
- if len(hw.PowerSupplies) > 0 {
- var tally topoStatusTally
- watt := 0
- for _, psu := range hw.PowerSupplies {
- tally.add(classifyTopoSeverity(psu.Status))
- if psu.WattageW != nil {
- watt = *psu.WattageW
- }
- }
- fill, stroke, text := topoSeverityColors(tally.worst())
- sublabel := ""
- if watt > 0 {
- sublabel = fmt.Sprintf("%dW each", watt)
- }
- b.WriteString(renderTopoFlexRow("Power Supplies", []topoCardInfo{{
- label: "Power Supplies", sublabel: sublabel, count: len(hw.PowerSupplies),
- statusLine: tally.line(),
- fillVar: fill, strokeVar: stroke, textVar: text,
- detailType: "psu",
- }}))
+ hasPSU := len(hw.PowerSupplies) > 0
+ if hasPSU {
+ b.WriteString(renderTopoPSURow(hw.PowerSupplies))
}
- // Cooling fans — one small clickable square per fan (no PCIe/CPU affinity,
- // arbitrary count, so a wrapping flex row like PSUs rather than SVG boxes).
- // Square SIZE encodes the fan's ceiling RPM (its class); the coloured FILL
- // rising from the bottom encodes live duty cycle (current / ceiling).
- if fans := dedupeFansByName(hw.Sensors); len(fans) > 0 {
+ // Cooling fans — one small clickable square per fan. Square SIZE encodes
+ // the fan's ceiling RPM (its class); the coloured FILL rising from the
+ // bottom encodes live duty cycle (current / ceiling).
+ fans := dedupeFansByName(hw.Sensors)
+ if len(fans) > 0 {
current := map[string]float64{}
for _, f := range fans {
if f.RPM != nil {
@@ -628,9 +611,75 @@ func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string
b.WriteString(renderTopoFanRow(fans, platform.ResolveFanMaxRPM(current), platform.ObservedFanMaxRPM()))
}
+ if hasPSU || len(fans) > 0 {
+ b.WriteString(topoLiveScript())
+ }
+
return topoCard("Topology", b.String())
}
+// renderTopoPSURow renders the POWER SUPPLIES row: one card per PSU, coloured
+// by that PSU's own status (a failed unit goes red on its own), showing input
+// voltage and draw. Cards click through to the shared PSU detail modal and
+// carry data-psu so topoLiveScript can refresh the wattage in place.
+func renderTopoPSURow(psus []schema.HardwarePowerSupply) string {
+ var b strings.Builder
+ b.WriteString(topoRowHeading("Power Supplies"))
+ b.WriteString(`
`)
+ for i, p := range psus {
+ sev := classifyTopoSeverity(p.Status)
+ fill, stroke, text := topoSeverityColors(sev)
+
+ label := fmt.Sprintf("PSU %d", i)
+ if p.Slot != nil && strings.TrimSpace(*p.Slot) != "" {
+ label = strings.TrimSpace(*p.Slot)
+ }
+
+ 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)
+ }
+ detail := strings.Join(parts, " · ")
+
+ statusWord := ""
+ if sev >= 2 {
+ statusWord = topoSeverityStatus(p.Status)
+ }
+
+ fmt.Fprintf(&b, `
`+
+ `
%s
`,
+ i, fill, stroke, text, html.EscapeString(label))
+ if detail != "" {
+ fmt.Fprintf(&b, `
%s
`, html.EscapeString(detail))
+ }
+ if statusWord != "" {
+ fmt.Fprintf(&b, `
%s
`, html.EscapeString(strings.ToUpper(statusWord)))
+ }
+ 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 {
+ switch {
+ case p.OutputPowerW != nil && *p.OutputPowerW > 0:
+ return fmt.Sprintf("%.0f W", *p.OutputPowerW)
+ 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)
+ default:
+ return ""
+ }
+}
+
// renderTopoFanRow renders the COOLING row. ceilByName (from
// platform.ResolveFanMaxRPM) has a value for every fan and drives tile size.
// observedByName (from platform.ObservedFanMaxRPM) holds only ceilings that
@@ -710,37 +759,46 @@ func renderTopoFanRow(fans []schema.HardwareFanSensor, ceilByName, observedByNam
side, side, stroke, text, fillBar, glyph)
}
b.WriteString(``)
- b.WriteString(topoFanLiveScript())
return b.String()
}
-// topoFanLiveScript polls the already-collected live-metrics snapshot
+// topoLiveScript polls the already-collected live-metrics snapshot
// (/api/metrics/latest — served from memory, no BMC call) every 5s and
-// updates each fan tile's spin rate, duty fill and tooltip in place. 5s is
-// the metrics collector's own sampling period, so polling faster would only
-// re-read identical numbers; the endpoint is a mutex read + small JSON, so
-// this is cheap even with many viewers.
-func topoFanLiveScript() string {
+// refreshes the fan tiles (spin rate, duty fill, tooltip) and PSU tiles
+// (wattage) in place. 5s is the metrics collector's own sampling period, so
+// polling faster only re-reads identical numbers; the endpoint is a mutex
+// read + small JSON, so this stays cheap with many viewers.
+func topoLiveScript() string {
return `