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 {
ListRemovableTargets() ([]platform.RemovableTarget, error)
ExportFileToTarget(src string, target platform.RemovableTarget) (string, error)
ListScenarioFilesOnRemovableMedia() ([]platform.ScenarioFileOnRemovableMedia, error)
ReadScenarioFromRemovableMedia(name string) ([]byte, error)
}
type toolManager interface {
@@ -154,6 +156,7 @@ type satRunner interface {
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)
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 {
+14
View File
@@ -14,6 +14,20 @@ func (a *App) ListRemovableTargets() ([]platform.RemovableTarget, error) {
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) {
if _, err := os.Stat(DefaultAuditJSONPath); err != nil {
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)
}
// 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) {
path, err := a.RunNvidiaAcceptancePack(baseDir, nil)
body := "Archive written."
+12
View File
@@ -91,6 +91,14 @@ func (f fakeExports) ExportFileToTarget(src string, target platform.RemovableTar
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 {
collectFn func(string) (schema.RuntimeHealth, error)
dumpFn func(string) error
@@ -332,6 +340,10 @@ func (f fakeSAT) RunNCCLTests(_ context.Context, baseDir string, gpuIndices []in
return "", nil
}
func (f fakeSAT) RunScenario(_ context.Context, baseDir string, _ platform.ScenarioSpec, _ func(string)) (string, error) {
return "", nil
}
func TestRunNCCLTestsPassesSelectedGPUs(t *testing.T) {
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)
}
// 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
case "nvidia-bench-perf", "nvidia-bench-power", "nvidia-bench-autotune":
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
case "nvidia", "nvidia-targeted-stress", "nvidia-targeted-power", "nvidia-pulse",
"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 {
return func(w http.ResponseWriter, r *http.Request) {
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) {
h := &handler{opts: HandlerOptions{ExportDir: t.TempDir()}}
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: "burn", label: "4. Burn", href: "/burn"},
{id: "benchmark", label: "5. Benchmark", href: "/benchmark"},
{id: "scenario", label: "6. Scenario", href: "/scenario"},
{sep: true},
{id: "tasks", label: "Tasks", href: "/tasks"},
{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"
title = "5. Benchmark"
body = renderBenchmark(opts)
case "scenario":
pageID = "scenario"
title = "6. Scenario"
body = renderScenario(opts)
case "tools":
pageID = "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/power/run", h.handleAPIBenchmarkNvidiaRunKind("nvidia-bench-power"))
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/benchmark/results", h.handleAPIBenchmarkResults)
+17
View File
@@ -148,6 +148,7 @@ type taskParams struct {
RAIDDevices []string `json:"raid_devices,omitempty"`
RAIDArrayName string `json:"raid_array_name,omitempty"`
RAIDSlot string `json:"raid_slot,omitempty"`
ScenarioName string `json:"scenario_name,omitempty"`
}
type persistedTask struct {
@@ -988,6 +989,22 @@ func (q *taskQueue) runTask(t *Task, j *jobState, ctx context.Context) {
break
}
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":
if a == nil {
err = fmt.Errorf("app not configured")