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
@@ -3,6 +3,7 @@ package webui
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -700,6 +701,34 @@ func TestRunTaskHonorsCancel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTaskFanNotApplicableIsCancelledNotFailed(t *testing.T) {
|
||||
q := &taskQueue{opts: &HandlerOptions{App: &app.App{}}}
|
||||
tk := &Task{
|
||||
ID: "fan-1", Name: "Fan Ceiling Check", Target: "fan",
|
||||
Status: TaskRunning, CreatedAt: time.Now(),
|
||||
}
|
||||
j := &jobState{}
|
||||
tk.job = j
|
||||
|
||||
orig := runFanCheckPackCtx
|
||||
runFanCheckPackCtx = func(_ *app.App, _ context.Context, _ string, _ platform.FanCheckOptions, _ func(string)) (string, error) {
|
||||
return "", fmt.Errorf("no fan sensors readable: %w", platform.ErrTestNotApplicable)
|
||||
}
|
||||
defer func() { runFanCheckPackCtx = orig }()
|
||||
|
||||
q.runTask(tk, j, context.Background())
|
||||
|
||||
if tk.Status != TaskCancelled {
|
||||
t.Fatalf("status=%q want %q", tk.Status, TaskCancelled)
|
||||
}
|
||||
if j.err != "" {
|
||||
t.Fatalf("job err should be empty for a not-applicable task, got %q", j.err)
|
||||
}
|
||||
if !strings.Contains(tk.ErrMsg, "not applicable") {
|
||||
t.Fatalf("ErrMsg should explain not-applicable, got %q", tk.ErrMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTaskUsesBurnProfileDurationForCPU(t *testing.T) {
|
||||
var gotDuration int
|
||||
q := &taskQueue{
|
||||
|
||||
Reference in New Issue
Block a user