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:
@@ -37,6 +37,7 @@ type Agent struct {
|
||||
Hosts *store.Hosts
|
||||
Runs *store.Runs
|
||||
Stages *store.Stages
|
||||
SubSteps *store.SubSteps
|
||||
Artifacts *store.Artifacts
|
||||
SpecDiffs *store.SpecDiffs
|
||||
Measurements *store.Measurements
|
||||
@@ -386,12 +387,30 @@ func (a *Agent) Log(w http.ResponseWriter, r *http.Request) {
|
||||
// DefaultStageOrder); Passed drives StageCompleted vs StageFailed.
|
||||
// Inventory is optional and only set when kind == "Inventory" — the
|
||||
// orchestrator persists it as an artifact and feeds it to spec.Diff.
|
||||
//
|
||||
// SubSteps is agent-authored granular rows (CPU/Memory pass, per-disk
|
||||
// SMART, per-device GPU, …). Empty for stages with no natural
|
||||
// breakdown. Persisted after the mismatch guard fires; per-row SSE is
|
||||
// emitted at the same time so the detail pane can surface them without
|
||||
// a full page reload.
|
||||
type StageResult struct {
|
||||
Stage string `json:"stage"`
|
||||
Passed bool `json:"passed"`
|
||||
Summary json.RawMessage `json:"summary,omitempty"`
|
||||
Inventory *spec.Inventory `json:"inventory,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Stage string `json:"stage"`
|
||||
Passed bool `json:"passed"`
|
||||
Summary json.RawMessage `json:"summary,omitempty"`
|
||||
Inventory *spec.Inventory `json:"inventory,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
SubSteps []SubStepResultLine `json:"sub_steps,omitempty"`
|
||||
}
|
||||
|
||||
// SubStepResultLine is one entry in StageResult.SubSteps. Ordinal is
|
||||
// assigned from slice index server-side; the agent doesn't set it.
|
||||
type SubStepResultLine struct {
|
||||
Name string `json:"name"`
|
||||
Passed bool `json:"passed"`
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
Summary json.RawMessage `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// Result receives a stage's outcome. Flow:
|
||||
@@ -470,6 +489,12 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Agent-authored sub-steps: persist in slice order (ordinal = index)
|
||||
// and fan out a per-row SSE event each so the detail pane shows them
|
||||
// without a reload. Best-effort — a persistence error is logged but
|
||||
// doesn't fail the whole /result.
|
||||
a.persistSubSteps(r.Context(), runID, body.Stage, body.SubSteps)
|
||||
|
||||
// Inventory-specific: persist artifact + compute spec diff.
|
||||
if body.Stage == "Inventory" && body.Inventory != nil {
|
||||
if err := a.persistInventory(r, run, body.Inventory); err != nil {
|
||||
@@ -531,6 +556,65 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "next_state": string(next)})
|
||||
}
|
||||
|
||||
// persistSubSteps writes each reported sub-step as a row keyed by
|
||||
// (runID, stage, ordinal) where ordinal is the slice index, then emits
|
||||
// a per-row SSE event so an open detail page updates without a reload.
|
||||
// Silently no-ops when SubSteps is unwired (tests that don't supply a
|
||||
// store) or the slice is empty.
|
||||
func (a *Agent) persistSubSteps(ctx context.Context, runID int64, stage string, lines []SubStepResultLine) {
|
||||
if a.SubSteps == nil || len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
for i, line := range lines {
|
||||
state := model.StagePassed
|
||||
switch {
|
||||
case line.Skipped:
|
||||
state = model.StageSkipped
|
||||
case !line.Passed:
|
||||
state = model.StageFailed
|
||||
}
|
||||
started := parseResultTime(line.StartedAt)
|
||||
completed := parseResultTime(line.CompletedAt)
|
||||
summaryJSON := ""
|
||||
if len(line.Summary) > 0 {
|
||||
summaryJSON = string(line.Summary)
|
||||
}
|
||||
ss := model.SubStep{
|
||||
RunID: runID,
|
||||
StageName: stage,
|
||||
Ordinal: i,
|
||||
Name: line.Name,
|
||||
State: state,
|
||||
StartedAt: started,
|
||||
CompletedAt: completed,
|
||||
SummaryJSON: summaryJSON,
|
||||
}
|
||||
if err := a.SubSteps.Upsert(ctx, ss); err != nil {
|
||||
log.Printf("substep upsert run=%d stage=%s ord=%d: %v", runID, stage, i, err)
|
||||
continue
|
||||
}
|
||||
if a.Runner != nil {
|
||||
a.Runner.PublishSubStepUpdate(ctx, ss)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseResultTime tolerates RFC3339 / RFC3339Nano and returns nil for
|
||||
// empty or unparseable values so a missing timestamp doesn't block the
|
||||
// persist path.
|
||||
func parseResultTime(s string) *time.Time {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||
return &t
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return &t
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) persistInventory(r *http.Request, run *model.Run, inv *spec.Inventory) error {
|
||||
dir := filepath.Join(a.ArtifactsDir, fmt.Sprintf("run-%d", run.ID))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,17 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"vetting/internal/api"
|
||||
"vetting/internal/db"
|
||||
"vetting/internal/events"
|
||||
"vetting/internal/logs"
|
||||
"vetting/internal/model"
|
||||
"vetting/internal/orchestrator"
|
||||
"vetting/internal/store"
|
||||
@@ -21,7 +24,8 @@ import (
|
||||
|
||||
func setupDetail(t *testing.T) (*api.UI, *store.Hosts, *store.Runs) {
|
||||
t.Helper()
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "vetting.db"))
|
||||
tmp := t.TempDir()
|
||||
conn, err := db.Open(filepath.Join(tmp, "vetting.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
@@ -29,18 +33,26 @@ func setupDetail(t *testing.T) (*api.UI, *store.Hosts, *store.Runs) {
|
||||
hosts := &store.Hosts{DB: conn}
|
||||
runs := &store.Runs{DB: conn}
|
||||
stages := &store.Stages{DB: conn}
|
||||
subSteps := &store.SubSteps{DB: conn}
|
||||
diffs := &store.SpecDiffs{DB: conn}
|
||||
arts := &store.Artifacts{DB: conn}
|
||||
hub := events.NewHub()
|
||||
logsHub, err := logs.NewHub(filepath.Join(tmp, "logs"), hub)
|
||||
if err != nil {
|
||||
t.Fatalf("logs hub: %v", err)
|
||||
}
|
||||
t.Cleanup(logsHub.Close)
|
||||
runner := &orchestrator.Runner{Runs: runs, Hosts: hosts, Stages: stages, EventHub: hub}
|
||||
tiles := &api.TileEnricher{Runs: runs, Artifacts: arts, SpecDiffs: diffs}
|
||||
ui := &api.UI{
|
||||
Hosts: hosts,
|
||||
Runs: runs,
|
||||
Stages: stages,
|
||||
SubSteps: subSteps,
|
||||
SpecDiffs: diffs,
|
||||
Artifacts: arts,
|
||||
EventHub: hub,
|
||||
Logs: logsHub,
|
||||
Runner: runner,
|
||||
Tiles: tiles,
|
||||
}
|
||||
@@ -54,6 +66,16 @@ func detailReq(id int64) *http.Request {
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
// detailReqWithQuery is detailReq with an optional ?run= query string.
|
||||
// Used by TestHostDetail_RunQueryParam so we can drive the selected-run
|
||||
// branch without routing through the real router.
|
||||
func detailReqWithQuery(id int64, rawQuery string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/hosts/%d?%s", id, rawQuery), nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", fmt.Sprintf("%d", id))
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
func TestHostDetail_OK(t *testing.T) {
|
||||
ui, hosts, runs := setupDetail(t)
|
||||
ctx := context.Background()
|
||||
@@ -71,6 +93,9 @@ func TestHostDetail_OK(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("create run: %v", err)
|
||||
}
|
||||
if err := ui.Stages.Seed(ctx, runID); err != nil {
|
||||
t.Fatalf("seed stages: %v", err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
ui.HostDetail(rr, detailReq(id))
|
||||
@@ -85,7 +110,8 @@ func TestHostDetail_OK(t *testing.T) {
|
||||
if !strings.Contains(body, wantPipelineID) {
|
||||
t.Fatalf("body missing %s", wantPipelineID)
|
||||
}
|
||||
wantLogID := fmt.Sprintf(`id="log-%d"`, runID)
|
||||
// Each stage owns its own log pane; assert one of them is present.
|
||||
wantLogID := fmt.Sprintf(`id="log-%d-Inventory"`, runID)
|
||||
if !strings.Contains(body, wantLogID) {
|
||||
t.Fatalf("body missing %s", wantLogID)
|
||||
}
|
||||
@@ -121,14 +147,16 @@ func TestHostDetail_NeverRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostDetail_LogTabsRendered: when a run exists, the detail page
|
||||
// emits the log-tabs scaffold with one radio per stage + an "All" tab
|
||||
// checked by default. CSS sibling selectors drive visibility — no JS.
|
||||
func TestHostDetail_LogTabsRendered(t *testing.T) {
|
||||
// TestHostDetail_ActiveStepsRendered: every canonical stage gets its own
|
||||
// <details data-stage="..."> panel with a matching log pane id, replacing
|
||||
// the old flat log-tab scaffold. Also confirms the sub-step SSE swap
|
||||
// target exists when sub-steps are seeded for a stage (so Phase 1's
|
||||
// substep-* event path has a DOM home).
|
||||
func TestHostDetail_ActiveStepsRendered(t *testing.T) {
|
||||
ui, hosts, runs := setupDetail(t)
|
||||
ctx := context.Background()
|
||||
id, err := hosts.Create(ctx, model.Host{
|
||||
Name: "tabs-host",
|
||||
Name: "steps-host",
|
||||
MAC: "aa:bb:cc:dd:ee:40",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
@@ -141,6 +169,19 @@ func TestHostDetail_LogTabsRendered(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("create run: %v", err)
|
||||
}
|
||||
if err := ui.Stages.Seed(ctx, runID); err != nil {
|
||||
t.Fatalf("seed stages: %v", err)
|
||||
}
|
||||
// Seed one CPUStress sub-step so the SubStepRow swap target lands.
|
||||
if err := ui.SubSteps.Upsert(ctx, model.SubStep{
|
||||
RunID: runID,
|
||||
StageName: "CPUStress",
|
||||
Ordinal: 0,
|
||||
Name: "CPU pass",
|
||||
State: model.StagePending,
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert sub-step: %v", err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
ui.HostDetail(rr, detailReq(id))
|
||||
@@ -149,23 +190,246 @@ func TestHostDetail_LogTabsRendered(t *testing.T) {
|
||||
}
|
||||
body := rr.Body.String()
|
||||
|
||||
// All tab: the default-checked radio, plus its pane.
|
||||
wantAllID := fmt.Sprintf(`id="log-tab-%d-all"`, runID)
|
||||
if !strings.Contains(body, wantAllID) {
|
||||
t.Fatalf("body missing All tab radio %s", wantAllID)
|
||||
}
|
||||
// Per-stage tabs: every entry in DefaultStageOrder must have its own
|
||||
// radio + pane so tabs switch purely via sibling CSS.
|
||||
// Every stage in DefaultStageOrder owns a collapsible panel + log pane.
|
||||
for _, s := range store.DefaultStageOrder {
|
||||
wantRadio := fmt.Sprintf(`id="log-tab-%d-%s"`, runID, s)
|
||||
if !strings.Contains(body, wantRadio) {
|
||||
t.Fatalf("body missing stage tab radio %s", wantRadio)
|
||||
wantPanel := fmt.Sprintf(`data-stage="%s"`, s)
|
||||
if !strings.Contains(body, wantPanel) {
|
||||
t.Fatalf("body missing active-step panel %s", wantPanel)
|
||||
}
|
||||
wantPane := fmt.Sprintf(`id="log-%d-%s"`, runID, s)
|
||||
if !strings.Contains(body, wantPane) {
|
||||
t.Fatalf("body missing stage pane %s", wantPane)
|
||||
t.Fatalf("body missing stage log pane %s", wantPane)
|
||||
}
|
||||
}
|
||||
// Sub-step row for CPUStress/0 is present and SSE-bound.
|
||||
wantSub := fmt.Sprintf(`id="substep-%d-CPUStress-0"`, runID)
|
||||
if !strings.Contains(body, wantSub) {
|
||||
t.Fatalf("body missing sub-step row %s", wantSub)
|
||||
}
|
||||
wantSubSwap := fmt.Sprintf(`sse-swap="substep-%d-CPUStress-0"`, runID)
|
||||
if !strings.Contains(body, wantSubSwap) {
|
||||
t.Fatalf("body missing sub-step sse-swap %s", wantSubSwap)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultOpenStage returns the value of data-stage on the single
|
||||
// `<details ... open data-stage="...">` emitted by ActiveStep. Returns
|
||||
// "" if no stage is currently open. The rendered attribute order is
|
||||
// fixed by active_step.templ (class, then open?, then data-stage), so
|
||||
// a tight substring match is safe.
|
||||
func defaultOpenStage(body string) string {
|
||||
re := regexp.MustCompile(`open data-stage="([^"]+)"`)
|
||||
m := re.FindStringSubmatch(body)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
func TestHostDetail_DefaultStep_Running(t *testing.T) {
|
||||
ui, hosts, runs := setupDetail(t)
|
||||
ctx := context.Background()
|
||||
id, err := hosts.Create(ctx, model.Host{
|
||||
Name: "default-running",
|
||||
MAC: "aa:bb:cc:dd:ee:50",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create host: %v", err)
|
||||
}
|
||||
runID, err := runs.Create(ctx, id, "t-running", false)
|
||||
if err != nil {
|
||||
t.Fatalf("create run: %v", err)
|
||||
}
|
||||
if err := ui.Stages.Seed(ctx, runID); err != nil {
|
||||
t.Fatalf("seed stages: %v", err)
|
||||
}
|
||||
// Earlier stages passed; SMART is running.
|
||||
for _, name := range []string{"Inventory", "SpecValidate"} {
|
||||
if err := ui.Stages.StartByName(ctx, runID, name); err != nil {
|
||||
t.Fatalf("start %s: %v", name, err)
|
||||
}
|
||||
if err := ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, ""); err != nil {
|
||||
t.Fatalf("complete %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := ui.Stages.StartByName(ctx, runID, "SMART"); err != nil {
|
||||
t.Fatalf("start SMART: %v", err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
ui.HostDetail(rr, detailReq(id))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := defaultOpenStage(rr.Body.String()); got != "SMART" {
|
||||
t.Fatalf("default step = %q, want SMART", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostDetail_DefaultStep_Failed(t *testing.T) {
|
||||
ui, hosts, runs := setupDetail(t)
|
||||
ctx := context.Background()
|
||||
id, err := hosts.Create(ctx, model.Host{
|
||||
Name: "default-failed",
|
||||
MAC: "aa:bb:cc:dd:ee:51",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create host: %v", err)
|
||||
}
|
||||
runID, err := runs.Create(ctx, id, "t-failed", false)
|
||||
if err != nil {
|
||||
t.Fatalf("create run: %v", err)
|
||||
}
|
||||
if err := ui.Stages.Seed(ctx, runID); err != nil {
|
||||
t.Fatalf("seed stages: %v", err)
|
||||
}
|
||||
// Inventory + SpecValidate + SMART passed; CPUStress failed; nothing
|
||||
// running. Default must land on CPUStress.
|
||||
for _, name := range []string{"Inventory", "SpecValidate", "SMART"} {
|
||||
if err := ui.Stages.StartByName(ctx, runID, name); err != nil {
|
||||
t.Fatalf("start %s: %v", name, err)
|
||||
}
|
||||
if err := ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, ""); err != nil {
|
||||
t.Fatalf("complete %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := ui.Stages.StartByName(ctx, runID, "CPUStress"); err != nil {
|
||||
t.Fatalf("start CPUStress: %v", err)
|
||||
}
|
||||
if err := ui.Stages.CompleteByName(ctx, runID, "CPUStress", model.StageFailed, `{"reason":"thermal"}`); err != nil {
|
||||
t.Fatalf("complete CPUStress: %v", err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
ui.HostDetail(rr, detailReq(id))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rr.Code)
|
||||
}
|
||||
if got := defaultOpenStage(rr.Body.String()); got != "CPUStress" {
|
||||
t.Fatalf("default step = %q, want CPUStress", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostDetail_DefaultStep_Completed(t *testing.T) {
|
||||
ui, hosts, runs := setupDetail(t)
|
||||
ctx := context.Background()
|
||||
id, err := hosts.Create(ctx, model.Host{
|
||||
Name: "default-done",
|
||||
MAC: "aa:bb:cc:dd:ee:52",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create host: %v", err)
|
||||
}
|
||||
runID, err := runs.Create(ctx, id, "t-done", false)
|
||||
if err != nil {
|
||||
t.Fatalf("create run: %v", err)
|
||||
}
|
||||
if err := ui.Stages.Seed(ctx, runID); err != nil {
|
||||
t.Fatalf("seed stages: %v", err)
|
||||
}
|
||||
// All stages passed → default lands on Reporting.
|
||||
for _, name := range store.DefaultStageOrder {
|
||||
if err := ui.Stages.StartByName(ctx, runID, name); err != nil {
|
||||
t.Fatalf("start %s: %v", name, err)
|
||||
}
|
||||
if err := ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, ""); err != nil {
|
||||
t.Fatalf("complete %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
ui.HostDetail(rr, detailReq(id))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rr.Code)
|
||||
}
|
||||
if got := defaultOpenStage(rr.Body.String()); got != "Reporting" {
|
||||
t.Fatalf("default step = %q, want Reporting", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostDetail_RunQueryParam: ?run=N selects a specific past run
|
||||
// instead of the latest. The history sidebar's links rely on this.
|
||||
func TestHostDetail_RunQueryParam(t *testing.T) {
|
||||
ui, hosts, runs := setupDetail(t)
|
||||
ctx := context.Background()
|
||||
id, err := hosts.Create(ctx, model.Host{
|
||||
Name: "query-run",
|
||||
MAC: "aa:bb:cc:dd:ee:53",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create host: %v", err)
|
||||
}
|
||||
// Older run: failed at CPUStress. Newer run: fully passed.
|
||||
oldRun, err := runs.Create(ctx, id, "old", false)
|
||||
if err != nil {
|
||||
t.Fatalf("create old run: %v", err)
|
||||
}
|
||||
if err := ui.Stages.Seed(ctx, oldRun); err != nil {
|
||||
t.Fatalf("seed old: %v", err)
|
||||
}
|
||||
for _, name := range []string{"Inventory", "SpecValidate", "SMART"} {
|
||||
_ = ui.Stages.StartByName(ctx, oldRun, name)
|
||||
_ = ui.Stages.CompleteByName(ctx, oldRun, name, model.StagePassed, "")
|
||||
}
|
||||
_ = ui.Stages.StartByName(ctx, oldRun, "CPUStress")
|
||||
_ = ui.Stages.CompleteByName(ctx, oldRun, "CPUStress", model.StageFailed, "")
|
||||
|
||||
// Newer run lands after a tiny gap so Runs.LatestForHost picks it.
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
newRun, err := runs.Create(ctx, id, "new", false)
|
||||
if err != nil {
|
||||
t.Fatalf("create new run: %v", err)
|
||||
}
|
||||
if err := ui.Stages.Seed(ctx, newRun); err != nil {
|
||||
t.Fatalf("seed new: %v", err)
|
||||
}
|
||||
for _, name := range store.DefaultStageOrder {
|
||||
_ = ui.Stages.StartByName(ctx, newRun, name)
|
||||
_ = ui.Stages.CompleteByName(ctx, newRun, name, model.StagePassed, "")
|
||||
}
|
||||
|
||||
// Sanity: with no ?run=, default is Reporting (latest run is green).
|
||||
rr := httptest.NewRecorder()
|
||||
ui.HostDetail(rr, detailReq(id))
|
||||
if got := defaultOpenStage(rr.Body.String()); got != "Reporting" {
|
||||
t.Fatalf("latest default = %q, want Reporting", got)
|
||||
}
|
||||
|
||||
// With ?run={old}, we view the failed run → default is CPUStress and
|
||||
// the pipeline section references the old run's ID.
|
||||
rr = httptest.NewRecorder()
|
||||
ui.HostDetail(rr, detailReqWithQuery(id, fmt.Sprintf("run=%d", oldRun)))
|
||||
body := rr.Body.String()
|
||||
if got := defaultOpenStage(body); got != "CPUStress" {
|
||||
t.Fatalf("?run=old default = %q, want CPUStress", got)
|
||||
}
|
||||
wantPipelineID := fmt.Sprintf(`id="pipeline-%d"`, oldRun)
|
||||
if !strings.Contains(body, wantPipelineID) {
|
||||
t.Fatalf("?run=old body missing %s", wantPipelineID)
|
||||
}
|
||||
|
||||
// A ?run= value that belongs to no host at all falls back to latest
|
||||
// silently (stale bookmark should never 4xx).
|
||||
rr = httptest.NewRecorder()
|
||||
ui.HostDetail(rr, detailReqWithQuery(id, "run=9999"))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("bogus run fallback status = %d", rr.Code)
|
||||
}
|
||||
if got := defaultOpenStage(rr.Body.String()); got != "Reporting" {
|
||||
t.Fatalf("bogus run default = %q, want Reporting (fall back to latest)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostDetail_UnknownID(t *testing.T) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
// place that renders a tile shows the same data.
|
||||
type TileEnricher struct {
|
||||
Runs *store.Runs
|
||||
Stages *store.Stages
|
||||
Artifacts *store.Artifacts
|
||||
SpecDiffs *store.SpecDiffs
|
||||
}
|
||||
@@ -53,6 +54,16 @@ func (e *TileEnricher) Build(ctx context.Context, host model.Host, latest *model
|
||||
log.Printf("tile: list artifacts run %d: %v", latest.ID, err)
|
||||
}
|
||||
}
|
||||
// Stage row per canonical stage drives the dashboard tile's mini
|
||||
// run-view strip. Fail-soft: a DB hiccup renders the tile without
|
||||
// dots rather than breaking the whole dashboard.
|
||||
if e.Stages != nil {
|
||||
if stages, err := e.Stages.ListForRun(ctx, latest.ID); err == nil {
|
||||
t.Stages = stages
|
||||
} else {
|
||||
log.Printf("tile: list stages run %d: %v", latest.ID, err)
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
|
||||
+72
-12
@@ -28,6 +28,7 @@ type UI struct {
|
||||
Hosts *store.Hosts
|
||||
Runs *store.Runs
|
||||
Stages *store.Stages
|
||||
SubSteps *store.SubSteps
|
||||
SpecDiffs *store.SpecDiffs
|
||||
Artifacts *store.Artifacts
|
||||
EventHub *events.Hub
|
||||
@@ -118,7 +119,16 @@ func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad host id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
data, err := u.LoadHostDetailData(r.Context(), id)
|
||||
// Optional ?run=N: select a specific past run instead of the latest.
|
||||
// Rejected runs (bad parse, wrong host) fall back to latest silently
|
||||
// so a stale bookmark doesn't 404.
|
||||
var selectedRunID int64
|
||||
if q := r.URL.Query().Get("run"); q != "" {
|
||||
if parsed, err := strconv.ParseInt(q, 10, 64); err == nil {
|
||||
selectedRunID = parsed
|
||||
}
|
||||
}
|
||||
data, err := u.LoadHostDetailData(r.Context(), id, selectedRunID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
@@ -139,7 +149,12 @@ func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
|
||||
// diffs, replay, and tile enrichment are fail-soft (empty on error) —
|
||||
// mirrors the original inline behaviour so a transient DB hiccup on one
|
||||
// relation doesn't blank the whole page.
|
||||
func (u *UI) LoadHostDetailData(ctx context.Context, hostID int64) (templates.HostDetailData, error) {
|
||||
//
|
||||
// selectedRunID == 0 means "use the latest run". A positive value picks
|
||||
// a specific past run for the hosts/{id}?run=N history-sidebar navigation;
|
||||
// if that run doesn't exist or belongs to another host we fall back to
|
||||
// the latest so a stale URL doesn't error out.
|
||||
func (u *UI) LoadHostDetailData(ctx context.Context, hostID int64, selectedRunID int64) (templates.HostDetailData, error) {
|
||||
host, err := u.Hosts.Get(ctx, hostID)
|
||||
if err != nil {
|
||||
return templates.HostDetailData{}, err
|
||||
@@ -148,29 +163,74 @@ func (u *UI) LoadHostDetailData(ctx context.Context, hostID int64) (templates.Ho
|
||||
if err != nil {
|
||||
return templates.HostDetailData{}, err
|
||||
}
|
||||
// Resolve the viewed run: selectedRunID wins when it matches this
|
||||
// host; otherwise fall back to latest. A run that belongs to a
|
||||
// different host is silently ignored — no operator action should be
|
||||
// able to render another host's run under this page.
|
||||
viewed := latest
|
||||
if selectedRunID > 0 && u.Runs != nil {
|
||||
if r, err := u.Runs.Get(ctx, selectedRunID); err == nil && r != nil && r.HostID == hostID {
|
||||
viewed = r
|
||||
}
|
||||
}
|
||||
var stages []model.Stage
|
||||
var diffs []model.SpecDiff
|
||||
if latest != nil {
|
||||
var subSteps []model.SubStep
|
||||
if viewed != nil {
|
||||
if u.Stages != nil {
|
||||
stages, _ = u.Stages.ListForRun(ctx, latest.ID)
|
||||
stages, _ = u.Stages.ListForRun(ctx, viewed.ID)
|
||||
}
|
||||
if u.SpecDiffs != nil {
|
||||
diffs, _ = u.SpecDiffs.ListForRun(ctx, latest.ID)
|
||||
diffs, _ = u.SpecDiffs.ListForRun(ctx, viewed.ID)
|
||||
}
|
||||
if u.SubSteps != nil {
|
||||
subSteps, _ = u.SubSteps.ListForRun(ctx, viewed.ID)
|
||||
}
|
||||
}
|
||||
t := u.Tiles.Build(ctx, *host, latest)
|
||||
// Sidebar: last 20 runs for this host, newest first. Fail-soft so a
|
||||
// transient DB error doesn't blank the whole page.
|
||||
var history []model.Run
|
||||
if u.Runs != nil {
|
||||
history, _ = u.Runs.ListForHost(ctx, hostID, 20)
|
||||
}
|
||||
t := u.Tiles.Build(ctx, *host, viewed)
|
||||
replay := ""
|
||||
if latest != nil && u.Logs != nil {
|
||||
replay = u.Logs.Replay(latest.ID)
|
||||
replayByStage := map[string]string{}
|
||||
if viewed != nil && u.Logs != nil {
|
||||
replay = u.Logs.Replay(viewed.ID)
|
||||
replayByStage = u.Logs.ReplayByStage(viewed.ID)
|
||||
}
|
||||
return templates.HostDetailData{
|
||||
Tile: t,
|
||||
Stages: stages,
|
||||
SpecDiffs: diffs,
|
||||
LogReplay: replay,
|
||||
Tile: t,
|
||||
Stages: stages,
|
||||
SpecDiffs: diffs,
|
||||
SubSteps: subSteps,
|
||||
History: history,
|
||||
DefaultStepStage: pickDefaultStep(stages),
|
||||
LogReplay: replay,
|
||||
LogReplayByStage: replayByStage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// pickDefaultStep chooses which stage the detail page opens expanded by
|
||||
// default. Rule: running → first-failed → Reporting. The operator is
|
||||
// almost always most interested in the thing currently happening (or
|
||||
// the thing that just failed); Reporting is the sensible terminal fallback
|
||||
// because it's where the report link lives.
|
||||
func pickDefaultStep(stages []model.Stage) string {
|
||||
for _, s := range stages {
|
||||
if s.State == model.StageRunning {
|
||||
return s.Name
|
||||
}
|
||||
}
|
||||
for _, s := range stages {
|
||||
if s.State == model.StageFailed {
|
||||
return s.Name
|
||||
}
|
||||
}
|
||||
return "Reporting"
|
||||
}
|
||||
|
||||
// StartRun creates a new Run for the host, issues an agent token, and
|
||||
// transitions Registered→Queued. The dispatcher goroutine picks it up
|
||||
// on its next tick; the happy path is heartbeat-driven (the reporter's
|
||||
|
||||
Reference in New Issue
Block a user