fix(webui): repair audit actions and CPU burn flow - v3.15
This commit is contained in:
@@ -684,7 +684,11 @@ func resolveSATCommand(cmd []string) ([]string, error) {
|
|||||||
case "rvs":
|
case "rvs":
|
||||||
return resolveRVSCommand(cmd[1:]...)
|
return resolveRVSCommand(cmd[1:]...)
|
||||||
}
|
}
|
||||||
return cmd, nil
|
path, err := satLookPath(cmd[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s not found in PATH: %w", cmd[0], err)
|
||||||
|
}
|
||||||
|
return append([]string{path}, cmd[1:]...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveRVSCommand(args ...string) ([]string, error) {
|
func resolveRVSCommand(args ...string) ([]string, error) {
|
||||||
|
|||||||
@@ -256,6 +256,44 @@ func TestResolveROCmSMICommandFromPATH(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveSATCommandUsesLookPathForGenericTools(t *testing.T) {
|
||||||
|
oldLookPath := satLookPath
|
||||||
|
satLookPath = func(file string) (string, error) {
|
||||||
|
if file == "stress-ng" {
|
||||||
|
return "/usr/bin/stress-ng", nil
|
||||||
|
}
|
||||||
|
return "", exec.ErrNotFound
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { satLookPath = oldLookPath })
|
||||||
|
|
||||||
|
cmd, err := resolveSATCommand([]string{"stress-ng", "--cpu", "0"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveSATCommand error: %v", err)
|
||||||
|
}
|
||||||
|
if len(cmd) != 3 {
|
||||||
|
t.Fatalf("cmd len=%d want 3 (%v)", len(cmd), cmd)
|
||||||
|
}
|
||||||
|
if cmd[0] != "/usr/bin/stress-ng" {
|
||||||
|
t.Fatalf("cmd[0]=%q want /usr/bin/stress-ng", cmd[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSATCommandFailsForMissingGenericTool(t *testing.T) {
|
||||||
|
oldLookPath := satLookPath
|
||||||
|
satLookPath = func(file string) (string, error) {
|
||||||
|
return "", exec.ErrNotFound
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { satLookPath = oldLookPath })
|
||||||
|
|
||||||
|
_, err := resolveSATCommand([]string{"stress-ng", "--cpu", "0"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "stress-ng not found in PATH") {
|
||||||
|
t.Fatalf("error=%q", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveROCmSMICommandFallsBackToROCmTree(t *testing.T) {
|
func TestResolveROCmSMICommandFallsBackToROCmTree(t *testing.T) {
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
execPath := filepath.Join(tmp, "opt", "rocm", "bin", "rocm-smi")
|
execPath := filepath.Join(tmp, "opt", "rocm", "bin", "rocm-smi")
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -179,8 +181,11 @@ func (h *handler) handleAPISATRun(target string) http.HandlerFunc {
|
|||||||
Profile string `json:"profile"`
|
Profile string `json:"profile"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
}
|
}
|
||||||
if r.ContentLength > 0 {
|
if r.Body != nil {
|
||||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
name := taskDisplayName(target, body.Profile, body.Loader)
|
name := taskDisplayName(target, body.Profile, body.Loader)
|
||||||
@@ -925,8 +930,31 @@ func parseXrandrOutput(out string) []displayInfo {
|
|||||||
return infos
|
return infos
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func xrandrCommand(args ...string) *exec.Cmd {
|
||||||
|
cmd := exec.Command("xrandr", args...)
|
||||||
|
env := append([]string{}, os.Environ()...)
|
||||||
|
hasDisplay := false
|
||||||
|
hasXAuthority := false
|
||||||
|
for _, kv := range env {
|
||||||
|
if strings.HasPrefix(kv, "DISPLAY=") && strings.TrimPrefix(kv, "DISPLAY=") != "" {
|
||||||
|
hasDisplay = true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(kv, "XAUTHORITY=") && strings.TrimPrefix(kv, "XAUTHORITY=") != "" {
|
||||||
|
hasXAuthority = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasDisplay {
|
||||||
|
env = append(env, "DISPLAY=:0")
|
||||||
|
}
|
||||||
|
if !hasXAuthority {
|
||||||
|
env = append(env, "XAUTHORITY=/home/bee/.Xauthority")
|
||||||
|
}
|
||||||
|
cmd.Env = env
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
func (h *handler) handleAPIDisplayResolutions(w http.ResponseWriter, _ *http.Request) {
|
func (h *handler) handleAPIDisplayResolutions(w http.ResponseWriter, _ *http.Request) {
|
||||||
out, err := exec.Command("xrandr").Output()
|
out, err := xrandrCommand().Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "xrandr: "+err.Error())
|
writeError(w, http.StatusInternalServerError, "xrandr: "+err.Error())
|
||||||
return
|
return
|
||||||
@@ -953,7 +981,7 @@ func (h *handler) handleAPIDisplaySet(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid output name")
|
writeError(w, http.StatusBadRequest, "invalid output name")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if out, err := exec.Command("xrandr", "--output", req.Output, "--mode", req.Mode).CombinedOutput(); err != nil {
|
if out, err := xrandrCommand("--output", req.Output, "--mode", req.Mode).CombinedOutput(); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "xrandr: "+strings.TrimSpace(string(out)))
|
writeError(w, http.StatusInternalServerError, "xrandr: "+strings.TrimSpace(string(out)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
64
audit/internal/webui/api_test.go
Normal file
64
audit/internal/webui/api_test.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"bee/audit/internal/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestXrandrCommandAddsDefaultX11Env(t *testing.T) {
|
||||||
|
t.Setenv("DISPLAY", "")
|
||||||
|
t.Setenv("XAUTHORITY", "")
|
||||||
|
|
||||||
|
cmd := xrandrCommand("--query")
|
||||||
|
|
||||||
|
var hasDisplay bool
|
||||||
|
var hasXAuthority bool
|
||||||
|
for _, kv := range cmd.Env {
|
||||||
|
if kv == "DISPLAY=:0" {
|
||||||
|
hasDisplay = true
|
||||||
|
}
|
||||||
|
if kv == "XAUTHORITY=/home/bee/.Xauthority" {
|
||||||
|
hasXAuthority = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasDisplay {
|
||||||
|
t.Fatalf("DISPLAY not injected: %v", cmd.Env)
|
||||||
|
}
|
||||||
|
if !hasXAuthority {
|
||||||
|
t.Fatalf("XAUTHORITY not injected: %v", cmd.Env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAPISATRunDecodesBodyWithoutContentLength(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/sat/cpu/run", strings.NewReader(`{"profile":"smoke"}`))
|
||||||
|
req.ContentLength = -1
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.handleAPISATRun("cpu").ServeHTTP(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))
|
||||||
|
}
|
||||||
|
if got := globalQueue.tasks[0].params.BurnProfile; got != "smoke" {
|
||||||
|
t.Fatalf("burn profile=%q want smoke", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -289,7 +289,7 @@ func renderAudit() string {
|
|||||||
func renderHardwareSummaryCard(opts HandlerOptions) string {
|
func renderHardwareSummaryCard(opts HandlerOptions) string {
|
||||||
data, err := loadSnapshot(opts.AuditPath)
|
data, err := loadSnapshot(opts.AuditPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return `<div class="card"><div class="card-head">Hardware Summary</div><div class="card-body"><span class="badge badge-unknown">No audit data</span></div></div>`
|
return `<div class="card"><div class="card-head">Hardware Summary</div><div class="card-body"><button class="btn btn-primary" onclick="auditModalRun()">▶ Run Audit</button></div></div>`
|
||||||
}
|
}
|
||||||
// Parse just enough fields for the summary banner
|
// Parse just enough fields for the summary banner
|
||||||
var snap struct {
|
var snap struct {
|
||||||
|
|||||||
@@ -136,6 +136,33 @@ func TestRootRendersDashboard(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRootShowsRunAuditButtonWhenSnapshotMissing(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
exportDir := filepath.Join(dir, "export")
|
||||||
|
if err := os.MkdirAll(exportDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewHandler(HandlerOptions{
|
||||||
|
Title: "Bee Hardware Audit",
|
||||||
|
AuditPath: filepath.Join(dir, "missing-audit.json"),
|
||||||
|
ExportDir: exportDir,
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, `Run Audit`) {
|
||||||
|
t.Fatalf("dashboard missing run audit button: %s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `No audit data`) {
|
||||||
|
t.Fatalf("dashboard still shows empty audit badge: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuditPageRendersViewerFrameAndActions(t *testing.T) {
|
func TestAuditPageRendersViewerFrameAndActions(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "audit.json")
|
path := filepath.Join(dir, "audit.json")
|
||||||
|
|||||||
@@ -468,6 +468,7 @@ func (q *taskQueue) runTask(t *Task, j *jobState, ctx context.Context) {
|
|||||||
if dur <= 0 {
|
if dur <= 0 {
|
||||||
dur = 60
|
dur = 60
|
||||||
}
|
}
|
||||||
|
j.append(fmt.Sprintf("CPU stress duration: %ds", dur))
|
||||||
archive, err = runCPUAcceptancePackCtx(a, ctx, "", dur, j.append)
|
archive, err = runCPUAcceptancePackCtx(a, ctx, "", dur, j.append)
|
||||||
case "amd":
|
case "amd":
|
||||||
archive, err = runAMDAcceptancePackCtx(a, ctx, "", j.append)
|
archive, err = runAMDAcceptancePackCtx(a, ctx, "", j.append)
|
||||||
|
|||||||
@@ -171,3 +171,34 @@ func TestRunTaskHonorsCancel(t *testing.T) {
|
|||||||
t.Fatal("runTask did not return after cancel")
|
t.Fatal("runTask did not return after cancel")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunTaskUsesBurnProfileDurationForCPU(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var gotDuration int
|
||||||
|
q := &taskQueue{
|
||||||
|
opts: &HandlerOptions{App: &app.App{}},
|
||||||
|
}
|
||||||
|
tk := &Task{
|
||||||
|
ID: "cpu-burn-1",
|
||||||
|
Name: "CPU Burn-in",
|
||||||
|
Target: "cpu",
|
||||||
|
Status: TaskRunning,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
params: taskParams{BurnProfile: "smoke"},
|
||||||
|
}
|
||||||
|
j := &jobState{}
|
||||||
|
|
||||||
|
orig := runCPUAcceptancePackCtx
|
||||||
|
runCPUAcceptancePackCtx = func(_ *app.App, _ context.Context, _ string, durationSec int, _ func(string)) (string, error) {
|
||||||
|
gotDuration = durationSec
|
||||||
|
return "/tmp/cpu-burn.tar.gz", nil
|
||||||
|
}
|
||||||
|
defer func() { runCPUAcceptancePackCtx = orig }()
|
||||||
|
|
||||||
|
q.runTask(tk, j, context.Background())
|
||||||
|
|
||||||
|
if gotDuration != 5*60 {
|
||||||
|
t.Fatalf("duration=%d want %d", gotDuration, 5*60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user