feat(sat): fan ceiling check + topology fan tiles

Repurpose the previously-unwired RunFanStressTest into RunFanCheck, a
Load-tier SAT test that drives stressapptest (CPU+memory) and, when a GPU
is present, a GPU burn to 100% simultaneously, then watches every fan
until none has climbed for ~60s. The observed peak RPM per fan is the
"ceiling"; it is persisted through the existing fan-observation store.

MSI G4201 / AMI MegaRAC exposes no host-side fan force (every OEM IPMI
command returns 0xc1; Redfish Thermal is GET-only), so load-driven ramp
is the closest safe equivalent. See
bible-local/decisions/2026-09-04-fan-ceiling-check.md.

- platform.ResolveFanMaxRPM: per-fan max with fallback (persisted peak ->
  peer peak -> current RPM), resolved in platform, not the view.
- platform.ErrTestNotApplicable: no load source or no fan sensors ->
  task lands as cancelled ("not applicable"), never failed, so an
  engineer never sees a false red. executeTaskWithOptions maps the
  sentinel; finalizeTaskForResult honours a pre-set TaskCancelled.
- Verdict FAIL only for a fan at 0 RPM / IPMI cr-nr under load.
- /topo: one small spinning square per fan, sized by RPM / resolved max,
  clickable through to a new "fan" component-detail type; per-fan status
  recorded to the component-status DB from the fan SAT summary.
- Wiring: /api/sat/fan/run route, "fan" task target, Load-page card,
  stress-mode Run All.

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 10:35:34 +03:00
co-authored by Claude Sonnet 5
parent e4f7519ef3
commit bb2a501a28
22 changed files with 798 additions and 222 deletions
+139 -2
View File
@@ -614,9 +614,138 @@ func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string
}}))
}
// Cooling fans — one small square per fan (no PCIe/CPU affinity, arbitrary
// count, so a wrapping flex row like PSUs rather than SVG boxes). Each
// square is sized by rpm / observed-max-rpm and carries a fan glyph that
// spins via CSS — faster when the fan is spinning faster.
if fans := dedupeFansByName(hw.Sensors); len(fans) > 0 {
current := map[string]float64{}
for _, f := range fans {
if f.RPM != nil {
current[strings.TrimSpace(f.Name)] = float64(*f.RPM)
}
}
b.WriteString(renderTopoFanRow(fans, platform.ResolveFanMaxRPM(current)))
}
return topoCard("Topology", b.String())
}
// renderTopoFanRow renders the COOLING row: one clickable square per fan,
// side length scaled by rpm/maxRPM and a fan glyph whose spin rate tracks the
// same ratio. maxByName comes from platform.ResolveFanMaxRPM — it already has
// an entry for every fan (persisted peak, else peer peak, else current RPM),
// so no fallback logic lives here.
func renderTopoFanRow(fans []schema.HardwareFanSensor, maxByName map[string]float64) string {
const (
fanTileMin = 30 // px, a stalled / slowest fan
fanTileMax = 58 // px, a fan at its ceiling
)
var tally topoStatusTally
for _, f := range fans {
tally.add(classifyTopoSeverity(f.Status))
}
var b strings.Builder
b.WriteString(topoRowHeading("Cooling"))
b.WriteString(topoFanSpinStyle())
b.WriteString(`<div style="display:flex;flex-wrap:wrap;gap:6px;align-items:flex-end">`)
for _, f := range fans {
name := strings.TrimSpace(f.Name)
fill, stroke, text := topoSeverityColors(classifyTopoSeverity(f.Status))
denom := maxByName[name]
ratio := 0.0
title := name
if f.RPM != nil {
if denom > 0 {
ratio = float64(*f.RPM) / denom
}
if ratio < 0 {
ratio = 0
}
if ratio > 1 {
ratio = 1
}
if denom > float64(*f.RPM) {
title = fmt.Sprintf("%s · %d RPM (max %d)", name, *f.RPM, int(denom))
} else {
title = fmt.Sprintf("%s · %d RPM", name, *f.RPM)
}
} else {
title = name + " · no reading"
}
side := fanTileMin + int(float64(fanTileMax-fanTileMin)*ratio+0.5)
// Spin period: 2.6s at rest down to 0.5s at the observed peak. A fan
// with no reading doesn't spin.
spin := ""
if f.RPM != nil && *f.RPM > 0 {
period := 2.6 - 2.1*ratio
spin = fmt.Sprintf(`<svg class="topo-fan-spin" style="animation-duration:%.2fs" width="%d" height="%d" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">`,
period, side*7/16, side*7/16) + topoFanGlyphPaths() + `</svg>`
} else {
spin = fmt.Sprintf(`<svg width="%d" height="%d" viewBox="0 0 24 24" fill="currentColor" style="opacity:.4" aria-hidden="true">`,
side*7/16, side*7/16) + topoFanGlyphPaths() + `</svg>`
}
fmt.Fprintf(&b, `<div title="%s" onclick="openComponentDetail('fan')" `+
`style="width:%dpx;height:%dpx;display:flex;align-items:center;justify-content:center;`+
`border-radius:5px;background:%s;border:1px solid %s;color:%s;cursor:pointer">%s</div>`,
html.EscapeString(title), side, side, fill, stroke, text, spin)
}
b.WriteString(`</div>`)
fmt.Fprintf(&b, `<div style="font-size:11px;color:var(--muted);margin-top:6px">%d fans · %s · tile size ∝ RPM / observed max</div>`,
len(fans), html.EscapeString(tally.line()))
return b.String()
}
// topoFanSpinStyle emits the keyframes + base class for the spinning fan
// glyph once per row. A repeated identical <style> is harmless.
func topoFanSpinStyle() string {
return `<style>@keyframes topoFanSpin{to{transform:rotate(360deg)}}` +
`.topo-fan-spin{transform-box:fill-box;transform-origin:center;` +
`animation-name:topoFanSpin;animation-timing-function:linear;animation-iteration-count:infinite}` +
`@media (prefers-reduced-motion:reduce){.topo-fan-spin{animation:none}}</style>`
}
// topoFanGlyphPaths is the fan-blade drawing shared by every fan square,
// designed on a 24×24 viewBox.
func topoFanGlyphPaths() string {
return `<ellipse cx="12" cy="6.5" rx="3.1" ry="5.2"/>` +
`<ellipse cx="12" cy="6.5" rx="3.1" ry="5.2" transform="rotate(120 12 12)"/>` +
`<ellipse cx="12" cy="6.5" rx="3.1" ry="5.2" transform="rotate(240 12 12)"/>` +
`<circle cx="12" cy="12" r="2.3"/>`
}
// dedupeFansByName returns the fan sensors from a snapshot with duplicate
// names collapsed to their first occurrence, matching the ingest contract's
// "(sensor_type, name) — first wins" rule and skipping unnamed sensors.
func dedupeFansByName(sensors *schema.HardwareSensors) []schema.HardwareFanSensor {
if sensors == nil {
return nil
}
seen := map[string]bool{}
var out []schema.HardwareFanSensor
for _, f := range sensors.Fans {
name := strings.TrimSpace(f.Name)
if name == "" || seen[name] {
continue
}
seen[name] = true
out = append(out, f)
}
return out
}
// topoRowHeading renders the small uppercase section label shared by the
// flex rows below the SVG diagram (Firmware / Power Supplies / Cooling / ...).
func topoRowHeading(title string) string {
return fmt.Sprintf(`<div style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin:16px 0 6px">%s</div>`,
html.EscapeString(title))
}
// renderTopoFlexRow renders a labeled, wrapping row of component cards.
// Returns "" if items is empty (e.g. no PSU data in this audit).
func renderTopoFlexRow(title string, items []topoCardInfo) string {
@@ -624,8 +753,7 @@ func renderTopoFlexRow(title string, items []topoCardInfo) string {
return ""
}
var b strings.Builder
fmt.Fprintf(&b, `<div style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin:16px 0 6px">%s</div>`,
html.EscapeString(title))
b.WriteString(topoRowHeading(title))
b.WriteString(`<div style="display:flex;flex-wrap:wrap;gap:10px">`)
for _, item := range items {
onclick := ""
@@ -1064,6 +1192,15 @@ func inventoryFallbackRecords(compType string, opts HandlerOptions) []app.Compon
}
records = append(records, app.ComponentStatusRecord{ComponentKey: key, Status: topoSeverityStatus(p.Status)})
}
case "fan":
for i, f := range dedupeFansByName(hw.Sensors) {
name := strings.TrimSpace(f.Name)
key := fmt.Sprintf("fan:%d", i)
if name != "" {
key = "fan:" + name
}
records = append(records, app.ComponentStatusRecord{ComponentKey: key, Status: topoSeverityStatus(f.Status)})
}
case "gpu", "nic", "raid":
for i, dev := range hw.PCIeDevices {
if pcieDeviceKind(dev) != compType {