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:
co-authored by
Claude Sonnet 5
parent
20cd317c87
commit
bcf02e0515
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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><name>.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()">↻ 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)">▶ 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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user