From d108df7fe9520ecf19530319d421bb05bbe65e65 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Tue, 28 Jul 2026 19:20:31 +0300 Subject: [PATCH] platform/webui: add scenario description, show it in the Scenario page's list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScenarioSpec gains an optional "description" field. Listing (both ListLocalScenarioFiles and ListScenarioFilesOnRemovableMedia, via the new scenarioDescription helper) reads it out of each file without requiring full ParseScenarioJSON validation to succeed, so a listing never hides a scenario over an unrelated validation issue. The webui Scenario page now renders Name/Description/Found-on/Run instead of just Name/Found-on — a bare filename rarely tells anyone but the author what a scenario actually does. Co-Authored-By: Claude Sonnet 5 --- audit/internal/platform/export.go | 38 +++++++++++++++---- audit/internal/platform/export_test.go | 9 ++++- audit/internal/platform/scenario.go | 13 +++++-- audit/internal/webui/api.go | 7 ++-- audit/internal/webui/page_scenario.go | 9 +++-- .../nvbandwidth-all-gpu-power-watch.json | 1 + .../nvbandwidth-all-gpu-power-watch.json | 1 + 7 files changed, 61 insertions(+), 17 deletions(-) diff --git a/audit/internal/platform/export.go b/audit/internal/platform/export.go index f253ce8..b9d1a41 100644 --- a/audit/internal/platform/export.go +++ b/audit/internal/platform/export.go @@ -1,6 +1,7 @@ package platform import ( + "encoding/json" "fmt" "os" "os/exec" @@ -207,8 +208,28 @@ func (s *System) ReadScenarioFromRemovableMedia(name string) ([]byte, error) { // 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 + Name string // filename without ".json" — what ReadScenarioFromRemovableMedia/bee run expects + Description string // from the scenario's own "description" field, if it parses; "" otherwise + Device string // which removable target it was found on +} + +// scenarioDescription reads a scenario file's "description" field without +// requiring the whole file to pass ParseScenarioJSON's stricter validation +// — a listing shouldn't hide (or crash on) a scenario just because it, say, +// hasn't gotten a "type" filled in on every job yet. Returns "" if the file +// can't be read or parsed at all. +func scenarioDescription(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + var partial struct { + Description string `json:"description"` + } + if err := json.Unmarshal(data, &partial); err != nil { + return "" + } + return strings.TrimSpace(partial.Description) } // ListScenarioFilesOnRemovableMedia mounts each removable target in turn @@ -227,15 +248,17 @@ func (s *System) ListScenarioFilesOnRemovableMedia() ([]ScenarioFileOnRemovableM if mountErr != nil { continue } - entries, readErr := os.ReadDir(filepath.Join(mountpoint, "scenarios")) + scenariosDir := filepath.Join(mountpoint, "scenarios") + entries, readErr := os.ReadDir(scenariosDir) 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, + Name: strings.TrimSuffix(e.Name(), ".json"), + Description: scenarioDescription(filepath.Join(scenariosDir, e.Name())), + Device: target.Device, }) } } @@ -280,8 +303,9 @@ func (s *System) ListLocalScenarioFiles() ([]ScenarioFileOnRemovableMedia, error continue } out = append(out, ScenarioFileOnRemovableMedia{ - Name: strings.TrimSuffix(e.Name(), ".json"), - Device: "local (shipped with image)", + Name: strings.TrimSuffix(e.Name(), ".json"), + Description: scenarioDescription(filepath.Join(LocalScenariosDir, e.Name())), + Device: "local (shipped with image)", }) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) diff --git a/audit/internal/platform/export_test.go b/audit/internal/platform/export_test.go index 2eb9239..6e81ef7 100644 --- a/audit/internal/platform/export_test.go +++ b/audit/internal/platform/export_test.go @@ -234,7 +234,8 @@ func TestReadScenarioFallsBackToRemovableMediaWhenNotLocal(t *testing.T) { func TestListAvailableScenariosMergesLocalAndRemovable(t *testing.T) { localDir := withLocalScenariosDir(t) - if err := os.WriteFile(filepath.Join(localDir, "shipped.json"), []byte(`{}`), 0644); err != nil { + shipped := []byte(`{"name":"shipped","description":"reproduces the reboot"}`) + if err := os.WriteFile(filepath.Join(localDir, "shipped.json"), shipped, 0644); err != nil { t.Fatalf("write local scenario: %v", err) } @@ -265,7 +266,13 @@ func TestListAvailableScenariosMergesLocalAndRemovable(t *testing.T) { if got[0].Name != "shipped" || got[0].Device != "local (shipped with image)" { t.Fatalf("got[0]=%+v want local shipped entry first", got[0]) } + if got[0].Description != "reproduces the reboot" { + t.Fatalf("got[0].Description=%q want %q", got[0].Description, "reproduces the reboot") + } if got[1].Name != "from-usb" { t.Fatalf("got[1]=%+v want from-usb", got[1]) } + if got[1].Description != "" { + t.Fatalf("got[1].Description=%q want empty (file has no description field)", got[1].Description) + } } diff --git a/audit/internal/platform/scenario.go b/audit/internal/platform/scenario.go index 9b3a8bd..e535d1f 100644 --- a/audit/internal/platform/scenario.go +++ b/audit/internal/platform/scenario.go @@ -45,6 +45,7 @@ type ScenarioJob struct { // // { // "name": "nvbandwidth-all-gpu-power-watch", +// "description": "Full nvbandwidth across all GPUs at once — the failure mode never reproduces on a single socket alone — while sampling IPMI sensors and GPU power/temp for a power-delivery correlation.", // "timeout_sec": 1800, // "jobs": [ // {"name": "ipmi-sensors", "type": "sampler", "interval_sec": 2, @@ -57,9 +58,15 @@ type ScenarioJob struct { // ] // } type ScenarioSpec struct { - Name string `json:"name"` - TimeoutSec int `json:"timeout_sec,omitempty"` - Jobs []ScenarioJob `json:"jobs"` + Name string `json:"name"` + // Description is a short, human-readable explanation of what the + // scenario does and why — shown in the webui's scenario list (and + // available to any other UI) alongside the name, since a bare + // filename/name rarely conveys enough for someone other than the + // author to decide whether to run it. + Description string `json:"description,omitempty"` + TimeoutSec int `json:"timeout_sec,omitempty"` + Jobs []ScenarioJob `json:"jobs"` } // ParseScenarioJSON parses and validates a scenario file's contents. diff --git a/audit/internal/webui/api.go b/audit/internal/webui/api.go index e9a5dea..fccbaf6 100644 --- a/audit/internal/webui/api.go +++ b/audit/internal/webui/api.go @@ -588,12 +588,13 @@ func (h *handler) handleAPIScenarioList(w http.ResponseWriter, _ *http.Request) return } type scenarioFile struct { - Name string `json:"name"` - Device string `json:"device"` + Name string `json:"name"` + Description string `json:"description"` + Device string `json:"device"` } out := make([]scenarioFile, 0, len(files)) for _, f := range files { - out = append(out, scenarioFile{Name: f.Name, Device: f.Device}) + out = append(out, scenarioFile{Name: f.Name, Description: f.Description, Device: f.Device}) } writeJSON(w, out) } diff --git a/audit/internal/webui/page_scenario.go b/audit/internal/webui/page_scenario.go index e80cd98..693a95f 100644 --- a/audit/internal/webui/page_scenario.go +++ b/audit/internal/webui/page_scenario.go @@ -44,10 +44,13 @@ function scenarioRefresh() { list.innerHTML = '

No scenarios found — none shipped with this image and none under scenarios/ on mounted removable media.

'; return; } - let html = ''; + let html = '
NameFound on
'; for (const f of files) { - html += '' - + ''; + const desc = f.description ? escapeHTML(f.description) : ''; + html += '' + + '' + + '' + + ''; } html += '
NameDescriptionFound on
' + escapeHTML(f.name) + '' + escapeHTML(f.device) + '
' + escapeHTML(f.name) + '' + desc + '' + escapeHTML(f.device) + '
'; list.innerHTML = html; diff --git a/iso/overlay/usr/share/bee/scenarios/nvbandwidth-all-gpu-power-watch.json b/iso/overlay/usr/share/bee/scenarios/nvbandwidth-all-gpu-power-watch.json index 0633307..f359e96 100644 --- a/iso/overlay/usr/share/bee/scenarios/nvbandwidth-all-gpu-power-watch.json +++ b/iso/overlay/usr/share/bee/scenarios/nvbandwidth-all-gpu-power-watch.json @@ -1,5 +1,6 @@ { "name": "nvbandwidth-all-gpu-power-watch", + "description": "Full nvbandwidth across all GPUs at once (the crash never reproduces on a single socket alone), with IPMI sensors and GPU power/temp sampled every 2s to check for a power-delivery correlation.", "timeout_sec": 1800, "jobs": [ { diff --git a/scenarios/nvbandwidth-all-gpu-power-watch.json b/scenarios/nvbandwidth-all-gpu-power-watch.json index 0633307..f359e96 100644 --- a/scenarios/nvbandwidth-all-gpu-power-watch.json +++ b/scenarios/nvbandwidth-all-gpu-power-watch.json @@ -1,5 +1,6 @@ { "name": "nvbandwidth-all-gpu-power-watch", + "description": "Full nvbandwidth across all GPUs at once (the crash never reproduces on a single socket alone), with IPMI sensors and GPU power/temp sampled every 2s to check for a power-delivery correlation.", "timeout_sec": 1800, "jobs": [ {