webui: add "6. Scenario" page — run scriptable test scenarios from removable media through the normal task queue

Exposes the scenario engine (platform.System.RunScenario, added earlier)
in the web UI instead of only the `bee run` CLI: a new nav item lists every
scenarios/*.json found on mounted removable media (GET /api/scenario/list)
and runs one with a click (POST /api/scenario/run), enqueued as a normal
Task with target "scenario" — progress/logs live in Tasks like any other
SAT pack, no separate live-output UI needed.

- app.go: satRunner gains RunScenario, exportManager gains
  ListScenarioFilesOnRemovableMedia/ReadScenarioFromRemovableMedia — both
  already implemented on platform.System, just newly exposed through App.
- webui/tasks.go: taskParams.ScenarioName; runTask's "scenario" case reads
  the file from removable media, parses it, and runs it.
- webui/page_scenario.go: the page itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-07-28 18:58:11 +03:00
co-authored by Claude Sonnet 5
parent 20cd317c87
commit bcf02e0515
13 changed files with 318 additions and 1 deletions
+3
View File
@@ -73,6 +73,8 @@ type serviceManager interface {
type exportManager interface { type exportManager interface {
ListRemovableTargets() ([]platform.RemovableTarget, error) ListRemovableTargets() ([]platform.RemovableTarget, error)
ExportFileToTarget(src string, target platform.RemovableTarget) (string, error) ExportFileToTarget(src string, target platform.RemovableTarget) (string, error)
ListScenarioFilesOnRemovableMedia() ([]platform.ScenarioFileOnRemovableMedia, error)
ReadScenarioFromRemovableMedia(name string) ([]byte, error)
} }
type toolManager interface { type toolManager interface {
@@ -154,6 +156,7 @@ type satRunner interface {
RunFanStressTest(ctx context.Context, baseDir string, opts platform.FanStressOptions) (string, error) RunFanStressTest(ctx context.Context, baseDir string, opts platform.FanStressOptions) (string, error)
RunPlatformStress(ctx context.Context, baseDir string, opts platform.PlatformStressOptions, logFunc func(string)) (string, error) RunPlatformStress(ctx context.Context, baseDir string, opts platform.PlatformStressOptions, logFunc func(string)) (string, error)
RunNCCLTests(ctx context.Context, baseDir string, gpuIndices []int, logFunc func(string)) (string, error) RunNCCLTests(ctx context.Context, baseDir string, gpuIndices []int, logFunc func(string)) (string, error)
RunScenario(ctx context.Context, baseDir string, spec platform.ScenarioSpec, logFunc func(string)) (string, error)
} }
type runtimeChecker interface { type runtimeChecker interface {
+14
View File
@@ -14,6 +14,20 @@ func (a *App) ListRemovableTargets() ([]platform.RemovableTarget, error) {
return a.exports.ListRemovableTargets() return a.exports.ListRemovableTargets()
} }
// ListScenarioFilesOnRemovableMedia lists scenarios/*.json found on any
// mounted removable target (e.g. the blackbox USB stick) — see
// platform.System.ListScenarioFilesOnRemovableMedia.
func (a *App) ListScenarioFilesOnRemovableMedia() ([]platform.ScenarioFileOnRemovableMedia, error) {
return a.exports.ListScenarioFilesOnRemovableMedia()
}
// ReadScenarioFromRemovableMedia reads scenarios/<name>.json from whichever
// mounted removable target has it — see
// platform.System.ReadScenarioFromRemovableMedia.
func (a *App) ReadScenarioFromRemovableMedia(name string) ([]byte, error) {
return a.exports.ReadScenarioFromRemovableMedia(name)
}
func (a *App) ExportLatestAudit(target platform.RemovableTarget) (string, error) { func (a *App) ExportLatestAudit(target platform.RemovableTarget) (string, error) {
if _, err := os.Stat(DefaultAuditJSONPath); err != nil { if _, err := os.Stat(DefaultAuditJSONPath); err != nil {
return "", err return "", err
+11
View File
@@ -17,6 +17,17 @@ func (a *App) RunNvidiaAcceptancePack(baseDir string, logFunc func(string)) (str
return a.sat.RunNvidiaAcceptancePack(baseDir, logFunc) return a.sat.RunNvidiaAcceptancePack(baseDir, logFunc)
} }
// RunScenario runs a user-authored ScenarioSpec (see
// platform.ParseScenarioJSON) — the webui Scenario page and `bee run` CLI
// command both go through this so a scenario run gets the same SAT base
// directory default as every other pack.
func (a *App) RunScenario(ctx context.Context, baseDir string, spec platform.ScenarioSpec, logFunc func(string)) (string, error) {
if strings.TrimSpace(baseDir) == "" {
baseDir = DefaultSATBaseDir
}
return a.sat.RunScenario(ctx, baseDir, spec, logFunc)
}
func (a *App) RunNvidiaAcceptancePackResult(baseDir string) (ActionResult, error) { func (a *App) RunNvidiaAcceptancePackResult(baseDir string) (ActionResult, error) {
path, err := a.RunNvidiaAcceptancePack(baseDir, nil) path, err := a.RunNvidiaAcceptancePack(baseDir, nil)
body := "Archive written." body := "Archive written."
+12
View File
@@ -91,6 +91,14 @@ func (f fakeExports) ExportFileToTarget(src string, target platform.RemovableTar
return "", nil return "", nil
} }
func (f fakeExports) ListScenarioFilesOnRemovableMedia() ([]platform.ScenarioFileOnRemovableMedia, error) {
return nil, nil
}
func (f fakeExports) ReadScenarioFromRemovableMedia(name string) ([]byte, error) {
return nil, nil
}
type fakeRuntime struct { type fakeRuntime struct {
collectFn func(string) (schema.RuntimeHealth, error) collectFn func(string) (schema.RuntimeHealth, error)
dumpFn func(string) error dumpFn func(string) error
@@ -332,6 +340,10 @@ func (f fakeSAT) RunNCCLTests(_ context.Context, baseDir string, gpuIndices []in
return "", nil return "", nil
} }
func (f fakeSAT) RunScenario(_ context.Context, baseDir string, _ platform.ScenarioSpec, _ func(string)) (string, error) {
return "", nil
}
func TestRunNCCLTestsPassesSelectedGPUs(t *testing.T) { func TestRunNCCLTestsPassesSelectedGPUs(t *testing.T) {
t.Parallel() t.Parallel()
+44
View File
@@ -203,3 +203,47 @@ func (s *System) ReadScenarioFromRemovableMedia(name string) ([]byte, error) {
} }
return nil, fmt.Errorf("scenarios/%s.json not found on any removable media", name) return nil, fmt.Errorf("scenarios/%s.json not found on any removable media", name)
} }
// ScenarioFileOnRemovableMedia is one scenarios/*.json file found on a
// mounted removable target, as returned by ListScenarioFilesOnRemovableMedia.
type ScenarioFileOnRemovableMedia struct {
Name string // filename without ".json" — what ReadScenarioFromRemovableMedia/bee run expects
Device string // which removable target it was found on
}
// ListScenarioFilesOnRemovableMedia mounts each removable target in turn
// (unmounting again afterward if it mounted it itself) and collects every
// *.json file under a top-level scenarios/ directory. Used by the webui's
// Scenario page to show what's available to run without requiring the
// operator to already know a file's exact name.
func (s *System) ListScenarioFilesOnRemovableMedia() ([]ScenarioFileOnRemovableMedia, error) {
targets, err := s.ListRemovableTargets()
if err != nil {
return nil, err
}
var out []ScenarioFileOnRemovableMedia
for _, target := range targets {
mountpoint, mountedHere, mountErr := mountRemovableTargetReadOnly(target)
if mountErr != nil {
continue
}
entries, readErr := os.ReadDir(filepath.Join(mountpoint, "scenarios"))
if readErr == nil {
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
out = append(out, ScenarioFileOnRemovableMedia{
Name: strings.TrimSuffix(e.Name(), ".json"),
Device: target.Device,
})
}
}
if mountedHere {
_, _ = exportExecCommand("umount", mountpoint).CombinedOutput()
_ = os.Remove(mountpoint)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
+56 -1
View File
@@ -131,7 +131,7 @@ func defaultTaskPriority(target string, params taskParams) int {
return taskPriorityAudit return taskPriorityAudit
case "nvidia-bench-perf", "nvidia-bench-power", "nvidia-bench-autotune": case "nvidia-bench-perf", "nvidia-bench-power", "nvidia-bench-autotune":
return taskPriorityBenchmark return taskPriorityBenchmark
case "nvidia-stress", "amd-stress", "memory-stress", "sat-stress", "platform-stress", "nvidia-compute": case "nvidia-stress", "amd-stress", "memory-stress", "sat-stress", "platform-stress", "nvidia-compute", "scenario":
return taskPriorityBurn return taskPriorityBurn
case "nvidia", "nvidia-targeted-stress", "nvidia-targeted-power", "nvidia-pulse", case "nvidia", "nvidia-targeted-stress", "nvidia-targeted-power", "nvidia-pulse",
"nvidia-interconnect", "nvidia-bandwidth", "memory", "storage", "cpu", "nvidia-interconnect", "nvidia-bandwidth", "memory", "storage", "cpu",
@@ -575,6 +575,61 @@ func (h *handler) handleAPISATRun(target string) http.HandlerFunc {
} }
} }
// ── Scenario ─────────────────────────────────────────────────────────────────
func (h *handler) handleAPIScenarioList(w http.ResponseWriter, _ *http.Request) {
if h.opts.App == nil {
writeError(w, http.StatusServiceUnavailable, "app not configured")
return
}
files, err := h.opts.App.ListScenarioFilesOnRemovableMedia()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
type scenarioFile struct {
Name string `json:"name"`
Device string `json:"device"`
}
out := make([]scenarioFile, 0, len(files))
for _, f := range files {
out = append(out, scenarioFile{Name: f.Name, Device: f.Device})
}
writeJSON(w, out)
}
func (h *handler) handleAPIScenarioRun(w http.ResponseWriter, r *http.Request) {
if h.opts.App == nil {
writeError(w, http.StatusServiceUnavailable, "app not configured")
return
}
var body struct {
Name string `json:"name"`
}
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
}
name := strings.TrimSpace(body.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "scenario name is required")
return
}
t := &Task{
ID: newJobID("scenario"),
Name: "Scenario: " + name,
Target: "scenario",
Priority: defaultTaskPriority("scenario", taskParams{}),
Status: TaskPending,
CreatedAt: time.Now(),
}
t.params.ScenarioName = name
globalQueue.enqueue(t)
writeJSON(w, map[string]string{"task_id": t.ID, "job_id": t.ID})
}
func (h *handler) handleAPIBenchmarkNvidiaRunKind(target string) http.HandlerFunc { func (h *handler) handleAPIBenchmarkNvidiaRunKind(target string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if h.opts.App == nil { if h.opts.App == nil {
+49
View File
@@ -46,6 +46,55 @@ func TestHandleAPISATRunDecodesBodyWithoutContentLength(t *testing.T) {
} }
} }
func TestHandleAPIScenarioRunRequiresName(t *testing.T) {
h := &handler{opts: HandlerOptions{App: &app.App{}}}
req := httptest.NewRequest("POST", "/api/scenario/run", strings.NewReader(`{}`))
rec := httptest.NewRecorder()
h.handleAPIScenarioRun(rec, req)
if rec.Code != 400 {
t.Fatalf("status=%d want 400, body=%s", rec.Code, rec.Body.String())
}
}
func TestHandleAPIScenarioRunEnqueuesTask(t *testing.T) {
globalQueue.mu.Lock()
originalTasks := globalQueue.tasks
globalQueue.tasks = nil
globalQueue.mu.Unlock()
t.Cleanup(func() {
globalQueue.mu.Lock()
globalQueue.tasks = originalTasks
globalQueue.mu.Unlock()
})
h := &handler{opts: HandlerOptions{App: &app.App{}}}
req := httptest.NewRequest("POST", "/api/scenario/run", strings.NewReader(`{"name":"power-watch"}`))
rec := httptest.NewRecorder()
h.handleAPIScenarioRun(rec, req)
if rec.Code != 200 {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
globalQueue.mu.Lock()
defer globalQueue.mu.Unlock()
if len(globalQueue.tasks) != 1 {
t.Fatalf("tasks=%d want 1", len(globalQueue.tasks))
}
task := globalQueue.tasks[0]
if task.Target != "scenario" {
t.Fatalf("target=%q want scenario", task.Target)
}
if task.params.ScenarioName != "power-watch" {
t.Fatalf("scenario name=%q want power-watch", task.params.ScenarioName)
}
if task.Priority != taskPriorityBurn {
t.Fatalf("priority=%d want %d", task.Priority, taskPriorityBurn)
}
}
func TestHandleAPIBlackboxStatusReturnsDisabledWhenStateMissing(t *testing.T) { func TestHandleAPIBlackboxStatusReturnsDisabledWhenStateMissing(t *testing.T) {
h := &handler{opts: HandlerOptions{ExportDir: t.TempDir()}} h := &handler{opts: HandlerOptions{ExportDir: t.TempDir()}}
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
+1
View File
@@ -108,6 +108,7 @@ func layoutNav(active string, buildLabel string) string {
{id: "load", label: "3. Load", href: "/load"}, {id: "load", label: "3. Load", href: "/load"},
{id: "burn", label: "4. Burn", href: "/burn"}, {id: "burn", label: "4. Burn", href: "/burn"},
{id: "benchmark", label: "5. Benchmark", href: "/benchmark"}, {id: "benchmark", label: "5. Benchmark", href: "/benchmark"},
{id: "scenario", label: "6. Scenario", href: "/scenario"},
{sep: true}, {sep: true},
{id: "tasks", label: "Tasks", href: "/tasks"}, {id: "tasks", label: "Tasks", href: "/tasks"},
{id: "tools", label: "Tools", href: "/tools"}, {id: "tools", label: "Tools", href: "/tools"},
+85
View File
@@ -0,0 +1,85 @@
package webui
// renderScenario renders the "6. Scenario" page: scenarios/*.json files
// found on any mounted removable media (e.g. the blackbox USB stick),
// each runnable with one click. A run is enqueued as a normal Task
// (target "scenario") — progress/logs live in Tasks like every other SAT
// pack, no separate live-output UI needed here.
//
// See audit/internal/platform/scenario.go for the scenario file format and
// scenarios/README.md for the drop-a-file-on-the-USB-stick workflow this
// page is built around.
func renderScenario(opts HandlerOptions) string {
return `<p style="color:var(--muted);font-size:13px;margin-bottom:16px">
Scripted, ad-hoc test scenarios — commands plus background samplers (IPMI
sensors, nvidia-smi, ...), described in a small JSON file instead of a
hardcoded test. Drop a <code>&lt;name&gt;.json</code> file under
<code>scenarios/</code> on a removable drive (e.g. the same USB stick
already plugged in for blackbox) and it shows up below — no rebuild
needed. Runs are enqueued like any other task; watch progress in
<a href="/tasks">Tasks</a>.
</p>
<div class="card">
<div class="card-head">
<span>Scenarios found on removable media</span>
<button class="btn btn-sm btn-secondary" type="button" onclick="scenarioRefresh()">&#8635; Refresh</button>
</div>
<div class="card-body">
<div id="scenario-list"><p style="color:var(--muted);font-size:13px">Loading...</p></div>
</div>
</div>
<script>
function scenarioRefresh() {
const list = document.getElementById('scenario-list');
list.innerHTML = '<p style="color:var(--muted);font-size:13px">Loading...</p>';
fetch('/api/scenario/list')
.then(r => r.json())
.then(files => {
if (!files || !files.length) {
list.innerHTML = '<p style="color:var(--muted);font-size:13px">No scenarios/*.json found on any mounted removable media.</p>';
return;
}
let html = '<table class="table"><thead><tr><th>Name</th><th>Found on</th><th></th></tr></thead><tbody>';
for (const f of files) {
html += '<tr><td>' + escapeHTML(f.name) + '</td><td style="color:var(--muted);font-size:12px">' + escapeHTML(f.device) + '</td>'
+ '<td><button class="btn btn-sm btn-primary" onclick="scenarioRun(' + JSON.stringify(f.name) + ', this)">&#9654; Run</button></td></tr>';
}
html += '</tbody></table>';
list.innerHTML = html;
})
.catch(err => {
list.innerHTML = '<p style="color:var(--crit-fg)">Failed to list scenarios: ' + escapeHTML(String(err)) + '</p>';
});
}
function escapeHTML(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
function scenarioRun(name, btn) {
btn.disabled = true;
btn.textContent = 'Starting...';
fetch('/api/scenario/run', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name})
})
.then(r => {
if (!r.ok) return r.text().then(t => { throw new Error(t || r.statusText); });
return r.json();
})
.then(() => { window.location.href = '/tasks'; })
.catch(err => {
btn.disabled = false;
btn.textContent = 'Run';
alert('Failed to start scenario: ' + err.message);
});
}
scenarioRefresh();
</script>`
}
@@ -0,0 +1,20 @@
package webui
import (
"strings"
"testing"
)
func TestRenderPageScenarioRoutes(t *testing.T) {
body := renderPage("scenario", HandlerOptions{})
if !strings.Contains(body, "scenario-list") {
t.Fatalf("scenario page body missing scenario-list container:\n%s", body)
}
}
func TestLayoutNavIncludesScenario(t *testing.T) {
nav := layoutNav("scenario", "dev")
if !strings.Contains(nav, `href="/scenario"`) || !strings.Contains(nav, "6. Scenario") {
t.Fatalf("nav missing Scenario item:\n%s", nav)
}
}
+4
View File
@@ -42,6 +42,10 @@ func renderPage(page string, opts HandlerOptions) string {
pageID = "benchmark" pageID = "benchmark"
title = "5. Benchmark" title = "5. Benchmark"
body = renderBenchmark(opts) body = renderBenchmark(opts)
case "scenario":
pageID = "scenario"
title = "6. Scenario"
body = renderScenario(opts)
case "tools": case "tools":
pageID = "tools" pageID = "tools"
title = "Tools" title = "Tools"
+2
View File
@@ -278,6 +278,8 @@ func NewHandler(opts HandlerOptions) http.Handler {
mux.HandleFunc("POST /api/bee-bench/nvidia/perf/run", h.handleAPIBenchmarkNvidiaRunKind("nvidia-bench-perf")) mux.HandleFunc("POST /api/bee-bench/nvidia/perf/run", h.handleAPIBenchmarkNvidiaRunKind("nvidia-bench-perf"))
mux.HandleFunc("POST /api/bee-bench/nvidia/power/run", h.handleAPIBenchmarkNvidiaRunKind("nvidia-bench-power")) mux.HandleFunc("POST /api/bee-bench/nvidia/power/run", h.handleAPIBenchmarkNvidiaRunKind("nvidia-bench-power"))
mux.HandleFunc("POST /api/bee-bench/nvidia/autotune/run", h.handleAPIBenchmarkAutotuneRun()) mux.HandleFunc("POST /api/bee-bench/nvidia/autotune/run", h.handleAPIBenchmarkAutotuneRun())
mux.HandleFunc("GET /api/scenario/list", h.handleAPIScenarioList)
mux.HandleFunc("POST /api/scenario/run", h.handleAPIScenarioRun)
mux.HandleFunc("GET /api/bee-bench/nvidia/autotune/status", h.handleAPIBenchmarkAutotuneStatus) mux.HandleFunc("GET /api/bee-bench/nvidia/autotune/status", h.handleAPIBenchmarkAutotuneStatus)
mux.HandleFunc("GET /api/benchmark/results", h.handleAPIBenchmarkResults) mux.HandleFunc("GET /api/benchmark/results", h.handleAPIBenchmarkResults)
+17
View File
@@ -148,6 +148,7 @@ type taskParams struct {
RAIDDevices []string `json:"raid_devices,omitempty"` RAIDDevices []string `json:"raid_devices,omitempty"`
RAIDArrayName string `json:"raid_array_name,omitempty"` RAIDArrayName string `json:"raid_array_name,omitempty"`
RAIDSlot string `json:"raid_slot,omitempty"` RAIDSlot string `json:"raid_slot,omitempty"`
ScenarioName string `json:"scenario_name,omitempty"`
} }
type persistedTask struct { type persistedTask struct {
@@ -988,6 +989,22 @@ func (q *taskQueue) runTask(t *Task, j *jobState, ctx context.Context) {
break break
} }
archive, err = a.RunNCCLTests(ctx, "", t.params.GPUIndices, j.append) archive, err = a.RunNCCLTests(ctx, "", t.params.GPUIndices, j.append)
case "scenario":
if a == nil {
err = fmt.Errorf("app not configured")
break
}
var data []byte
data, err = a.ReadScenarioFromRemovableMedia(t.params.ScenarioName)
if err != nil {
break
}
var spec platform.ScenarioSpec
spec, err = platform.ParseScenarioJSON(data)
if err != nil {
break
}
archive, err = a.RunScenario(ctx, "", spec, j.append)
case "nvidia-stress": case "nvidia-stress":
if a == nil { if a == nil {
err = fmt.Errorf("app not configured") err = fmt.Errorf("app not configured")