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 `