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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -79,9 +80,11 @@ func finalizeTaskForResult(t *Task, errMsg string, cancelled bool) {
|
||||
now := time.Now()
|
||||
t.DoneAt = &now
|
||||
switch {
|
||||
case cancelled:
|
||||
case cancelled || t.Status == TaskCancelled:
|
||||
t.Status = TaskCancelled
|
||||
t.ErrMsg = "aborted"
|
||||
if strings.TrimSpace(t.ErrMsg) == "" {
|
||||
t.ErrMsg = "aborted"
|
||||
}
|
||||
case strings.TrimSpace(errMsg) != "":
|
||||
t.Status = TaskFailed
|
||||
t.ErrMsg = errMsg
|
||||
@@ -377,6 +380,12 @@ func executeTaskWithOptions(opts *HandlerOptions, t *Task, j *jobState, ctx cont
|
||||
runOpts := resolvePlatformStressPreset(t.params.BurnProfile)
|
||||
runOpts.Components = t.params.PlatformComponents
|
||||
archive, err = a.RunPlatformStress(ctx, "", runOpts, j.append)
|
||||
case "fan":
|
||||
if a == nil {
|
||||
err = fmt.Errorf("app not configured")
|
||||
break
|
||||
}
|
||||
archive, err = runFanCheckPackCtx(a, ctx, "", platform.FanCheckOptions{GPUIndices: t.params.GPUIndices}, j.append)
|
||||
case "audit":
|
||||
if a == nil {
|
||||
err = fmt.Errorf("app not configured")
|
||||
@@ -476,10 +485,19 @@ func executeTaskWithOptions(opts *HandlerOptions, t *Task, j *jobState, ctx cont
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
switch {
|
||||
case ctx.Err() != nil:
|
||||
j.append("Aborted.")
|
||||
j.finish("aborted")
|
||||
} else {
|
||||
case errors.Is(err, platform.ErrTestNotApplicable):
|
||||
// The host offered no way to run this test — not a hardware
|
||||
// fault. Land the task as cancelled ("not applicable") with a
|
||||
// detailed log, so an engineer never sees a false failure.
|
||||
j.append("NOT APPLICABLE: " + err.Error())
|
||||
t.Status = TaskCancelled
|
||||
t.ErrMsg = "not applicable — " + err.Error()
|
||||
j.finish("")
|
||||
default:
|
||||
j.append("ERROR: " + err.Error())
|
||||
j.finish(err.Error())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user