ui: GitHub-Actions-style detail page, sub-steps, mini-tile run-view
Reshapes the detail page into a run-view: hybrid horizontal pipeline
+ expanded active-step pane with sub-steps, a per-step log pane with
line-numbered permalinks and client-side search, and a runs-history
sidebar that navigates via ?run=N. Default step is server-picked
(running → failed → Reporting) so the operator lands on the thing
that's moving.
Adds a sub_steps table + SSE topic (substep-{run}-{stage}-{ordinal})
so per-disk and per-pass work (SMART, CPUStress CPU/RAM, Storage,
GPU) is visible in the UI instead of buried in stage summary JSON.
Agent emits sub-step reports from existing per-iteration loops.
Dashboard tiles become a mini run-view with a 9-dot step strip so
the operator reads run health across the whole grid at a glance.
Register page gets the same card shell + button styling.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+57
-23
@@ -3,6 +3,7 @@ package tests
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -52,6 +53,7 @@ func CPUStress(ctx context.Context, d Deps) Outcome {
|
||||
|
||||
cores := runtime.NumCPU()
|
||||
extras := map[string]any{"cores": cores}
|
||||
var subs []SubStepReport
|
||||
|
||||
// Pass 1: CPU
|
||||
cpu := runStressPass(ctx, d, "CPU", cpuPassDuration, []string{
|
||||
@@ -62,12 +64,14 @@ func CPUStress(ctx context.Context, d Deps) Outcome {
|
||||
"--verify",
|
||||
})
|
||||
extras["cpu_pass"] = cpu
|
||||
subs = append(subs, subStepFromPass("CPU pass", cpu))
|
||||
if !cpu.Passed {
|
||||
return Outcome{
|
||||
Passed: false,
|
||||
Message: "CPU pass failed: " + cpu.Err,
|
||||
Summary: fmt.Sprintf("CPU pass failed after %ds", cpu.ElapsedSecs),
|
||||
Extras: extras,
|
||||
Passed: false,
|
||||
Message: "CPU pass failed: " + cpu.Err,
|
||||
Summary: fmt.Sprintf("CPU pass failed after %ds", cpu.ElapsedSecs),
|
||||
Extras: extras,
|
||||
SubSteps: subs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,10 +81,11 @@ func CPUStress(ctx context.Context, d Deps) Outcome {
|
||||
if err != nil {
|
||||
d.Error("CPUStress: read MemAvailable: " + err.Error())
|
||||
return Outcome{
|
||||
Passed: false,
|
||||
Message: "read MemAvailable: " + err.Error(),
|
||||
Summary: "failed (meminfo unreadable)",
|
||||
Extras: extras,
|
||||
Passed: false,
|
||||
Message: "read MemAvailable: " + err.Error(),
|
||||
Summary: "failed (meminfo unreadable)",
|
||||
Extras: extras,
|
||||
SubSteps: subs,
|
||||
}
|
||||
}
|
||||
cap := avail - memHeadroomBytes
|
||||
@@ -92,10 +97,11 @@ func CPUStress(ctx context.Context, d Deps) Outcome {
|
||||
avail, memFloorBytes, memHeadroomBytes)
|
||||
d.Error("CPUStress: " + msg)
|
||||
return Outcome{
|
||||
Passed: false,
|
||||
Message: msg,
|
||||
Summary: "failed (insufficient free RAM for memory pass)",
|
||||
Extras: extras,
|
||||
Passed: false,
|
||||
Message: msg,
|
||||
Summary: "failed (insufficient free RAM for memory pass)",
|
||||
Extras: extras,
|
||||
SubSteps: subs,
|
||||
}
|
||||
}
|
||||
mem := runStressPass(ctx, d, "memory", memPassDuration, []string{
|
||||
@@ -107,12 +113,14 @@ func CPUStress(ctx context.Context, d Deps) Outcome {
|
||||
"--verify",
|
||||
})
|
||||
extras["mem_pass"] = mem
|
||||
subs = append(subs, subStepFromPass(fmt.Sprintf("Memory pass (cap %s)", humanBytes(cap)), mem))
|
||||
if !mem.Passed {
|
||||
return Outcome{
|
||||
Passed: false,
|
||||
Message: "memory pass failed: " + mem.Err,
|
||||
Summary: fmt.Sprintf("memory pass failed after %ds", mem.ElapsedSecs),
|
||||
Extras: extras,
|
||||
Passed: false,
|
||||
Message: "memory pass failed: " + mem.Err,
|
||||
Summary: fmt.Sprintf("memory pass failed after %ds", mem.ElapsedSecs),
|
||||
Extras: extras,
|
||||
SubSteps: subs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +128,26 @@ func CPUStress(ctx context.Context, d Deps) Outcome {
|
||||
Passed: true,
|
||||
Summary: fmt.Sprintf("CPU+RAM PASSED (%d cores, %s cap)",
|
||||
cores, humanBytes(cap)),
|
||||
Extras: extras,
|
||||
Extras: extras,
|
||||
SubSteps: subs,
|
||||
}
|
||||
}
|
||||
|
||||
// subStepFromPass projects a stressPass into a SubStepReport — shared by
|
||||
// both passes and by the mid-stage early-return paths so the UI always
|
||||
// sees exactly one row per pass, even on failure.
|
||||
func subStepFromPass(name string, p stressPass) SubStepReport {
|
||||
summary, _ := json.Marshal(map[string]any{
|
||||
"elapsed_secs": p.ElapsedSecs,
|
||||
"target_secs": p.TargetSecs,
|
||||
"err": p.Err,
|
||||
})
|
||||
return SubStepReport{
|
||||
Name: name,
|
||||
Passed: p.Passed,
|
||||
StartedAt: p.StartedAt,
|
||||
CompletedAt: p.CompletedAt,
|
||||
SummaryJSON: summary,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,12 +167,16 @@ const (
|
||||
|
||||
// stressPass is the per-pass result embedded in CPUStress's Extras.
|
||||
// Passed==true and Elapsed close to target is the only happy path.
|
||||
// StartedAt/CompletedAt are not serialized (the summary already has
|
||||
// ElapsedSecs) but are used by the caller to emit SubStepReport rows.
|
||||
type stressPass struct {
|
||||
Passed bool `json:"passed"`
|
||||
Err string `json:"err,omitempty"`
|
||||
ElapsedSecs int `json:"elapsed_secs"`
|
||||
TargetSecs int `json:"target_secs"`
|
||||
OutputTail string `json:"output_tail,omitempty"`
|
||||
Passed bool `json:"passed"`
|
||||
Err string `json:"err,omitempty"`
|
||||
ElapsedSecs int `json:"elapsed_secs"`
|
||||
TargetSecs int `json:"target_secs"`
|
||||
OutputTail string `json:"output_tail,omitempty"`
|
||||
StartedAt time.Time `json:"-"`
|
||||
CompletedAt time.Time `json:"-"`
|
||||
}
|
||||
|
||||
// runStressPass invokes stress-ng and validates both exit code and
|
||||
@@ -159,12 +190,15 @@ func runStressPass(ctx context.Context, d Deps, label string, target time.Durati
|
||||
cmd := exec.CommandContext(runCtx, "stress-ng", args...)
|
||||
start := time.Now()
|
||||
out, err := cmd.CombinedOutput()
|
||||
elapsed := time.Since(start)
|
||||
end := time.Now()
|
||||
elapsed := end.Sub(start)
|
||||
|
||||
res := stressPass{
|
||||
ElapsedSecs: int(elapsed.Round(time.Second).Seconds()),
|
||||
TargetSecs: int(target.Round(time.Second).Seconds()),
|
||||
OutputTail: tailLines(string(out), 20),
|
||||
StartedAt: start,
|
||||
CompletedAt: end,
|
||||
}
|
||||
if err != nil {
|
||||
res.Err = err.Error()
|
||||
|
||||
Reference in New Issue
Block a user