platform/webui: add scenario description, show it in the Scenario page's list

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 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-07-28 19:28:07 +03:00
co-authored by Claude Sonnet 5
parent 5a780e96b4
commit d108df7fe9
7 changed files with 61 additions and 17 deletions
+25 -1
View File
@@ -1,6 +1,7 @@
package platform
import (
"encoding/json"
"fmt"
"os"
"os/exec"
@@ -208,9 +209,29 @@ func (s *System) ReadScenarioFromRemovableMedia(name string) ([]byte, error) {
// mounted removable target, as returned by ListScenarioFilesOnRemovableMedia.
type ScenarioFileOnRemovableMedia struct {
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
// (unmounting again afterward if it mounted it itself) and collects every
// *.json file under a top-level scenarios/ directory. Used by the webui's
@@ -227,7 +248,8 @@ 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") {
@@ -235,6 +257,7 @@ func (s *System) ListScenarioFilesOnRemovableMedia() ([]ScenarioFileOnRemovableM
}
out = append(out, ScenarioFileOnRemovableMedia{
Name: strings.TrimSuffix(e.Name(), ".json"),
Description: scenarioDescription(filepath.Join(scenariosDir, e.Name())),
Device: target.Device,
})
}
@@ -281,6 +304,7 @@ func (s *System) ListLocalScenarioFiles() ([]ScenarioFileOnRemovableMedia, error
}
out = append(out, ScenarioFileOnRemovableMedia{
Name: strings.TrimSuffix(e.Name(), ".json"),
Description: scenarioDescription(filepath.Join(LocalScenariosDir, e.Name())),
Device: "local (shipped with image)",
})
}
+8 -1
View File
@@ -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)
}
}
+7
View File
@@ -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,
@@ -58,6 +59,12 @@ type ScenarioJob struct {
// }
type ScenarioSpec struct {
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"`
}
+2 -1
View File
@@ -589,11 +589,12 @@ func (h *handler) handleAPIScenarioList(w http.ResponseWriter, _ *http.Request)
}
type scenarioFile struct {
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)
}
+6 -3
View File
@@ -44,10 +44,13 @@ function scenarioRefresh() {
list.innerHTML = '<p style="color:var(--muted);font-size:13px">No scenarios found — none shipped with this image and none under scenarios/ on mounted removable media.</p>';
return;
}
let html = '<table class="table"><thead><tr><th>Name</th><th>Found on</th><th></th></tr></thead><tbody>';
let html = '<table class="table"><thead><tr><th>Name</th><th>Description</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>';
const desc = f.description ? escapeHTML(f.description) : '<span style="color:var(--muted)">—</span>';
html += '<tr><td style="white-space:nowrap">' + escapeHTML(f.name) + '</td>'
+ '<td style="font-size:12px;max-width:360px;color:var(--muted)">' + desc + '</td>'
+ '<td style="color:var(--muted);font-size:12px;white-space:nowrap">' + escapeHTML(f.device) + '</td>'
+ '<td style="white-space:nowrap"><button class="btn btn-primary" onclick="scenarioRun(' + JSON.stringify(f.name) + ', this)">&#9654; Run</button></td></tr>';
}
html += '</tbody></table>';
list.innerHTML = html;
@@ -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": [
{
@@ -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": [
{