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:
@@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -32,6 +33,7 @@ func setupAgent(t *testing.T) (*api.Agent, int64, string) {
|
||||
hosts := &store.Hosts{DB: conn}
|
||||
runs := &store.Runs{DB: conn}
|
||||
meas := &store.Measurements{DB: conn}
|
||||
subSteps := &store.SubSteps{DB: conn}
|
||||
|
||||
hostID, err := hosts.Create(context.Background(), model.Host{
|
||||
Name: "t-host",
|
||||
@@ -55,6 +57,7 @@ func setupAgent(t *testing.T) (*api.Agent, int64, string) {
|
||||
Hosts: hosts,
|
||||
Runs: runs,
|
||||
Measurements: meas,
|
||||
SubSteps: subSteps,
|
||||
}, runID, plain
|
||||
}
|
||||
|
||||
@@ -215,3 +218,73 @@ func TestResult_AcceptsMatchingStage(t *testing.T) {
|
||||
t.Fatalf("run state = %q, want CPUStress after SMART pass", after.State)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResult_PersistsSubSteps covers the /result handler's contract for
|
||||
// the new sub_steps table: when the agent includes a sub_steps array in
|
||||
// the POST body, each entry lands in the table with an ordinal equal to
|
||||
// its slice index, state derived from passed/skipped, and timestamps
|
||||
// parsed from RFC3339. The guard must let the call through (matching
|
||||
// stage) and sub-steps are written *after* CompleteStage so a persistence
|
||||
// error doesn't wedge the whole run.
|
||||
func TestResult_PersistsSubSteps(t *testing.T) {
|
||||
a, runID, token := setupAgent(t)
|
||||
a.Runner = &orchestrator.Runner{Runs: a.Runs, Hosts: a.Hosts, Stages: &store.Stages{DB: a.Runs.DB}, EventHub: events.NewHub()}
|
||||
stages := &store.Stages{DB: a.Runs.DB}
|
||||
if err := stages.Seed(context.Background(), runID); err != nil {
|
||||
t.Fatalf("seed stages: %v", err)
|
||||
}
|
||||
if err := a.Runs.SetState(context.Background(), runID, model.StateCPUStress); err != nil {
|
||||
t.Fatalf("set state: %v", err)
|
||||
}
|
||||
|
||||
start := time.Date(2026, 4, 18, 13, 0, 0, 0, time.UTC)
|
||||
end := start.Add(3 * time.Minute)
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"stage": "CPUStress",
|
||||
"passed": true,
|
||||
"sub_steps": []map[string]any{
|
||||
{
|
||||
"name": "CPU pass",
|
||||
"passed": true,
|
||||
"started_at": start.Format(time.RFC3339Nano),
|
||||
"completed_at": end.Format(time.RFC3339Nano),
|
||||
"summary": json.RawMessage(`{"elapsed_secs":180}`),
|
||||
},
|
||||
{
|
||||
"name": "Memory pass",
|
||||
"passed": false,
|
||||
"started_at": end.Format(time.RFC3339Nano),
|
||||
"completed_at": end.Add(2 * time.Minute).Format(time.RFC3339Nano),
|
||||
},
|
||||
},
|
||||
})
|
||||
req := routedRequest(runID, http.MethodPost, "/api/v1/runs/"+strconv.FormatInt(runID, 10)+"/result", body)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
a.Result(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
rows, err := a.SubSteps.ListForRun(context.Background(), runID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListForRun: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("got %d sub-steps, want 2", len(rows))
|
||||
}
|
||||
if rows[0].Ordinal != 0 || rows[0].Name != "CPU pass" || rows[0].State != model.StagePassed {
|
||||
t.Fatalf("row[0] = %+v", rows[0])
|
||||
}
|
||||
if rows[1].Ordinal != 1 || rows[1].Name != "Memory pass" || rows[1].State != model.StageFailed {
|
||||
t.Fatalf("row[1] = %+v", rows[1])
|
||||
}
|
||||
if rows[0].StartedAt == nil || !rows[0].StartedAt.Equal(start) {
|
||||
t.Fatalf("row[0].StartedAt = %v, want %v", rows[0].StartedAt, start)
|
||||
}
|
||||
if rows[0].SummaryJSON != `{"elapsed_secs":180}` {
|
||||
t.Fatalf("row[0].SummaryJSON = %q", rows[0].SummaryJSON)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user