feat(webui): break the topology PSU row into one card per supply

Each PSU is its own card, coloured by its own status — a failed unit
goes red on its own instead of dragging a single grouped card down —
and shows input voltage + draw (measured output/input, else nameplate
rating). Cards click through to the PSU detail modal and carry data-psu
so the shared topoLiveScript refreshes their wattage from
/api/metrics/latest on the same 5s poll as the fan tiles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VHG21rgTUiR1G3qFHTVmN
This commit is contained in:
Mikhail Chusavitin
2026-09-04 12:32:56 +03:00
co-authored by Claude Sonnet 5
parent a4853a0a4f
commit 59271fa674
2 changed files with 125 additions and 54 deletions
+94 -36
View File
@@ -592,33 +592,16 @@ func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string
} }
b.WriteString(renderTopoFlexRow("Firmware", firmwareItems)) b.WriteString(renderTopoFlexRow("Firmware", firmwareItems))
if len(hw.PowerSupplies) > 0 { hasPSU := len(hw.PowerSupplies) > 0
var tally topoStatusTally if hasPSU {
watt := 0 b.WriteString(renderTopoPSURow(hw.PowerSupplies))
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",
}}))
} }
// Cooling fans — one small clickable square per fan (no PCIe/CPU affinity, // Cooling fans — one small clickable square per fan. Square SIZE encodes
// arbitrary count, so a wrapping flex row like PSUs rather than SVG boxes). // the fan's ceiling RPM (its class); the coloured FILL rising from the
// Square SIZE encodes the fan's ceiling RPM (its class); the coloured FILL // bottom encodes live duty cycle (current / ceiling).
// rising from the bottom encodes live duty cycle (current / ceiling). fans := dedupeFansByName(hw.Sensors)
if fans := dedupeFansByName(hw.Sensors); len(fans) > 0 { if len(fans) > 0 {
current := map[string]float64{} current := map[string]float64{}
for _, f := range fans { for _, f := range fans {
if f.RPM != nil { 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())) b.WriteString(renderTopoFanRow(fans, platform.ResolveFanMaxRPM(current), platform.ObservedFanMaxRPM()))
} }
if hasPSU || len(fans) > 0 {
b.WriteString(topoLiveScript())
}
return topoCard("Topology", b.String()) 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(`<div style="display:flex;flex-wrap:wrap;gap:8px">`)
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, `<div class="topo-psu-tile" data-psu="%d" onclick="openComponentDetail('psu')" `+
`style="cursor:pointer;min-width:96px;padding:8px 11px;border-radius:6px;background:%s;border:1px solid %s;color:%s">`+
`<div style="font-size:13px;font-weight:700">%s</div>`,
i, fill, stroke, text, html.EscapeString(label))
if detail != "" {
fmt.Fprintf(&b, `<div class="topo-psu-detail" style="font-size:11px;opacity:.85;margin-top:2px">%s</div>`, html.EscapeString(detail))
}
if 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>`)
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 // renderTopoFanRow renders the COOLING row. ceilByName (from
// platform.ResolveFanMaxRPM) has a value for every fan and drives tile size. // platform.ResolveFanMaxRPM) has a value for every fan and drives tile size.
// observedByName (from platform.ObservedFanMaxRPM) holds only ceilings that // observedByName (from platform.ObservedFanMaxRPM) holds only ceilings that
@@ -710,28 +759,29 @@ func renderTopoFanRow(fans []schema.HardwareFanSensor, ceilByName, observedByNam
side, side, stroke, text, fillBar, glyph) side, side, stroke, text, fillBar, glyph)
} }
b.WriteString(`</div>`) b.WriteString(`</div>`)
b.WriteString(topoFanLiveScript())
return b.String() 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 // (/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 // refreshes the fan tiles (spin rate, duty fill, tooltip) and PSU tiles
// the metrics collector's own sampling period, so polling faster would only // (wattage) in place. 5s is the metrics collector's own sampling period, so
// re-read identical numbers; the endpoint is a mutex read + small JSON, so // polling faster only re-reads identical numbers; the endpoint is a mutex
// this is cheap even with many viewers. // read + small JSON, so this stays cheap with many viewers.
func topoFanLiveScript() string { func topoLiveScript() string {
return `<script>(function(){ return `<script>(function(){
var tiles=document.querySelectorAll('.topo-fan-tile'); var fans=document.querySelectorAll('.topo-fan-tile');
if(!tiles.length)return; var psus=document.querySelectorAll('.topo-psu-tile');
if(!fans.length&&!psus.length)return;
function period(rpm){var lo=1000,hi=13000,slow=2.2,fast=0.35; function period(rpm){var lo=1000,hi=13000,slow=2.2,fast=0.35;
if(rpm<=lo)return slow;if(rpm>=hi)return fast; if(rpm<=lo)return slow;if(rpm>=hi)return fast;
return slow+(rpm-lo)/(hi-lo)*(fast-slow);} return slow+(rpm-lo)/(hi-lo)*(fast-slow);}
function tick(){ function tick(){
fetch('/api/metrics/latest',{cache:'no-store'}).then(function(r){return r.json();}).then(function(m){ fetch('/api/metrics/latest',{cache:'no-store'}).then(function(r){return r.json();}).then(function(m){
if(!m||!m.fans)return; if(!m)return;
if(m.fans){
var by={};m.fans.forEach(function(f){by[f.name]=f.rpm;}); var by={};m.fans.forEach(function(f){by[f.name]=f.rpm;});
tiles.forEach(function(t){ fans.forEach(function(t){
var rpm=by[t.dataset.fan];if(rpm==null)return; var rpm=by[t.dataset.fan];if(rpm==null)return;
var ceil=parseFloat(t.dataset.ceil)||0; var ceil=parseFloat(t.dataset.ceil)||0;
var svg=t.querySelector('.topo-fan-spin'); var svg=t.querySelector('.topo-fan-spin');
@@ -741,6 +791,14 @@ func topoFanLiveScript() string {
if(meas){var fill=t.querySelector('.topo-fan-fill');if(fill)fill.style.height=duty.toFixed(0)+'%';} if(meas){var fill=t.querySelector('.topo-fan-fill');if(fill)fill.style.height=duty.toFixed(0)+'%';}
t.title=t.dataset.fan+' · '+Math.round(rpm)+' RPM'+(meas?' · '+Math.round(duty)+'% duty (ceiling '+ceil+')':' · ceiling not measured — run Fan Ceiling Check'); t.title=t.dataset.fan+' · '+Math.round(rpm)+' RPM'+(meas?' · '+Math.round(duty)+'% duty (ceiling '+ceil+')':' · ceiling not measured — run Fan Ceiling Check');
}); });
}
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');
});
}
}).catch(function(){}); }).catch(function(){});
} }
setInterval(tick,5000);tick(); setInterval(tick,5000);tick();
+21 -8
View File
@@ -90,15 +90,22 @@ func TestTopoPageRendersArbitraryPSUAndFirmwareCountsAsFlexRows(t *testing.T) {
path := filepath.Join(dir, "audit.json") path := filepath.Join(dir, "audit.json")
okStatus := "OK" okStatus := "OK"
failStatus := "Critical"
watt := 3000 watt := 3000
volt := 230.0
var psus []schema.HardwarePowerSupply var psus []schema.HardwarePowerSupply
for i := 0; i < 6; i++ { for i := 0; i < 6; i++ {
slot := strconv.Itoa(i) slot := strconv.Itoa(i)
st := okStatus
if i == 3 {
st = failStatus
}
psus = append(psus, schema.HardwarePowerSupply{ psus = append(psus, schema.HardwarePowerSupply{
HardwareComponentStatus: schema.HardwareComponentStatus{Status: &okStatus}, HardwareComponentStatus: schema.HardwareComponentStatus{Status: &st},
Slot: &slot, Slot: &slot,
WattageW: &watt, WattageW: &watt,
InputVoltage: &volt,
}) })
} }
@@ -132,14 +139,20 @@ func TestTopoPageRendersArbitraryPSUAndFirmwareCountsAsFlexRows(t *testing.T) {
if !strings.Contains(body, "BIOS") || !strings.Contains(body, "BMC") { if !strings.Contains(body, "BIOS") || !strings.Contains(body, "BMC") {
t.Fatalf("topo page missing BIOS/BMC firmware boxes: %s", body) t.Fatalf("topo page missing BIOS/BMC firmware boxes: %s", body)
} }
// All 6 PSUs must be represented, grouped into one stacked card with a // One card per PSU (6), each clickable, each showing voltage + power.
// count rather than 6 separate boxes. if n := strings.Count(body, `class="topo-psu-tile"`); n != 6 {
if !strings.Contains(body, "Power Supplies ×6") { t.Fatalf("expected 6 per-PSU cards, got %d", n)
t.Fatalf("topo page missing grouped Power Supplies x6 card: %s", body)
} }
if strings.Count(body, `onclick="openComponentDetail(&#39;psu&#39;)"`) != 1 && if n := strings.Count(body, `onclick="openComponentDetail('psu')"`) +
strings.Count(body, `onclick="openComponentDetail('psu')"`) != 1 { strings.Count(body, `onclick="openComponentDetail(&#39;psu&#39;)"`); n != 6 {
t.Fatalf("expected exactly one clickable PSU group card, not one per PSU: %s", body) 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)
}
// 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") {
t.Fatalf("failed PSU should render individually as critical: %s", body)
} }
// Firmware/PSU rows must be flex-wrap HTML (arbitrary count, no overlap), // Firmware/PSU rows must be flex-wrap HTML (arbitrary count, no overlap),
// not absolutely-positioned SVG rects sharing fixed x/y coordinates. // not absolutely-positioned SVG rects sharing fixed x/y coordinates.