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:
Mikhail Chusavitin
2026-07-28 18:58:11 +03:00
co-authored by Claude Sonnet 5
parent 20cd317c87
commit bcf02e0515
13 changed files with 318 additions and 1 deletions
+44
View File
@@ -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
}