From bcf02e0515281b6b230fc449b978161158f4c8d8 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Tue, 28 Jul 2026 18:58:11 +0300 Subject: [PATCH] =?UTF-8?q?webui:=20add=20"6.=20Scenario"=20page=20?= =?UTF-8?q?=E2=80=94=20run=20scriptable=20test=20scenarios=20from=20remova?= =?UTF-8?q?ble=20media=20through=20the=20normal=20task=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- audit/internal/app/app.go | 3 + audit/internal/app/app_install.go | 14 ++++ audit/internal/app/app_packs.go | 11 +++ audit/internal/app/app_test.go | 12 +++ audit/internal/platform/export.go | 44 +++++++++++ audit/internal/webui/api.go | 57 ++++++++++++++- audit/internal/webui/api_test.go | 49 +++++++++++++ audit/internal/webui/layout.go | 1 + audit/internal/webui/page_scenario.go | 85 ++++++++++++++++++++++ audit/internal/webui/page_scenario_test.go | 20 +++++ audit/internal/webui/pages.go | 4 + audit/internal/webui/server.go | 2 + audit/internal/webui/tasks.go | 17 +++++ 13 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 audit/internal/webui/page_scenario.go create mode 100644 audit/internal/webui/page_scenario_test.go diff --git a/audit/internal/app/app.go b/audit/internal/app/app.go index 0315090..67a42c8 100644 --- a/audit/internal/app/app.go +++ b/audit/internal/app/app.go @@ -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 { diff --git a/audit/internal/app/app_install.go b/audit/internal/app/app_install.go index c5783e2..c8e450b 100644 --- a/audit/internal/app/app_install.go +++ b/audit/internal/app/app_install.go @@ -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/.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 diff --git a/audit/internal/app/app_packs.go b/audit/internal/app/app_packs.go index 0886df5..c9e5b85 100644 --- a/audit/internal/app/app_packs.go +++ b/audit/internal/app/app_packs.go @@ -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." diff --git a/audit/internal/app/app_test.go b/audit/internal/app/app_test.go index 57c10b2..f0b08e8 100644 --- a/audit/internal/app/app_test.go +++ b/audit/internal/app/app_test.go @@ -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() diff --git a/audit/internal/platform/export.go b/audit/internal/platform/export.go index efb131f..9f35f3f 100644 --- a/audit/internal/platform/export.go +++ b/audit/internal/platform/export.go @@ -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 +} diff --git a/audit/internal/webui/api.go b/audit/internal/webui/api.go index 1a789e0..aea141c 100644 --- a/audit/internal/webui/api.go +++ b/audit/internal/webui/api.go @@ -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 { diff --git a/audit/internal/webui/api_test.go b/audit/internal/webui/api_test.go index 82f6c06..73c2606 100644 --- a/audit/internal/webui/api_test.go +++ b/audit/internal/webui/api_test.go @@ -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() diff --git a/audit/internal/webui/layout.go b/audit/internal/webui/layout.go index ddb5714..5db779f 100644 --- a/audit/internal/webui/layout.go +++ b/audit/internal/webui/layout.go @@ -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"}, diff --git a/audit/internal/webui/page_scenario.go b/audit/internal/webui/page_scenario.go new file mode 100644 index 0000000..399a2fc --- /dev/null +++ b/audit/internal/webui/page_scenario.go @@ -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 `

+ 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 <name>.json file under + scenarios/ 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 + Tasks. +

+ +
+
+ Scenarios found on removable media + +
+
+

Loading...

+
+
+ +` +} diff --git a/audit/internal/webui/page_scenario_test.go b/audit/internal/webui/page_scenario_test.go new file mode 100644 index 0000000..f69f6bf --- /dev/null +++ b/audit/internal/webui/page_scenario_test.go @@ -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) + } +} diff --git a/audit/internal/webui/pages.go b/audit/internal/webui/pages.go index aa9f113..d3f07da 100644 --- a/audit/internal/webui/pages.go +++ b/audit/internal/webui/pages.go @@ -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" diff --git a/audit/internal/webui/server.go b/audit/internal/webui/server.go index f0c8d17..b8e71b2 100644 --- a/audit/internal/webui/server.go +++ b/audit/internal/webui/server.go @@ -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) diff --git a/audit/internal/webui/tasks.go b/audit/internal/webui/tasks.go index 93a3adc..a17fd99 100644 --- a/audit/internal/webui/tasks.go +++ b/audit/internal/webui/tasks.go @@ -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")