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:
co-authored by
Claude Sonnet 5
parent
e4f7519ef3
commit
bb2a501a28
@@ -152,6 +152,71 @@ func TestTopoPageRendersArbitraryPSUAndFirmwareCountsAsFlexRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopoPageRendersCoolingFansAsFlexRow(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit.json")
|
||||
|
||||
ok, warn := "OK", "Warning"
|
||||
rpm := func(v int) *int { return &v }
|
||||
|
||||
ingest := schema.HardwareIngestRequest{
|
||||
CollectedAt: "2026-03-15T00:00:00Z",
|
||||
Hardware: schema.HardwareSnapshot{
|
||||
Sensors: &schema.HardwareSensors{
|
||||
Fans: []schema.HardwareFanSensor{
|
||||
{Name: "FAN1", RPM: rpm(4200), Status: &ok},
|
||||
{Name: "FAN2", RPM: rpm(15000), Status: &warn},
|
||||
{Name: "FAN2", RPM: rpm(15000), Status: &warn}, // dup name, first wins
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(ingest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := NewHandler(HandlerOptions{AuditPath: path})
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/topo", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
|
||||
// One clickable square per fan (2 after dedup by name), each with a
|
||||
// spinning glyph, under a COOLING heading.
|
||||
if !strings.Contains(body, "Cooling") {
|
||||
t.Fatalf("topo page missing Cooling heading: %s", body)
|
||||
}
|
||||
if n := strings.Count(body, `onclick="openComponentDetail('fan')"`) +
|
||||
strings.Count(body, `onclick="openComponentDetail('fan')"`); n != 2 {
|
||||
t.Fatalf("expected one clickable square per fan (2), got %d: %s", n, body)
|
||||
}
|
||||
if n := strings.Count(body, `class="topo-fan-spin"`); n != 2 {
|
||||
t.Fatalf("expected 2 spinning fan glyphs, got %d: %s", n, body)
|
||||
}
|
||||
if !strings.Contains(body, "FAN1 · 4200 RPM") || !strings.Contains(body, "FAN2 · 15000 RPM") {
|
||||
t.Fatalf("topo page missing per-fan RPM tooltips: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "2 fans") {
|
||||
t.Fatalf("topo page missing fan count caption: %s", body)
|
||||
}
|
||||
|
||||
// Component-detail fallback endpoint must resolve the "fan" type.
|
||||
rec2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/api/components/fan", nil))
|
||||
if rec2.Code != http.StatusOK {
|
||||
t.Fatalf("/api/components/fan status=%d", rec2.Code)
|
||||
}
|
||||
if b := rec2.Body.String(); !strings.Contains(b, "FAN1") || !strings.Contains(b, "FAN2") {
|
||||
t.Fatalf("fan component detail missing fan names: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopoPageRendersStorageDisksGroupedByType(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit.json")
|
||||
|
||||
Reference in New Issue
Block a user