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:
co-authored by
Claude Sonnet 5
parent
a4853a0a4f
commit
59271fa674
@@ -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();
|
||||||
|
|||||||
@@ -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('psu')"`) != 1 &&
|
if n := strings.Count(body, `onclick="openComponentDetail('psu')"`) +
|
||||||
strings.Count(body, `onclick="openComponentDetail('psu')"`) != 1 {
|
strings.Count(body, `onclick="openComponentDetail('psu')"`); 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user