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": [ {