f79fe0f0db
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>
456 lines
14 KiB
Go
456 lines
14 KiB
Go
package api_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"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"
|
|
)
|
|
|
|
func setupDetail(t *testing.T) (*api.UI, *store.Hosts, *store.Runs) {
|
|
t.Helper()
|
|
tmp := t.TempDir()
|
|
conn, err := db.Open(filepath.Join(tmp, "vetting.db"))
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
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,
|
|
}
|
|
return ui, hosts, runs
|
|
}
|
|
|
|
func detailReq(id int64) *http.Request {
|
|
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/hosts/%d", id), nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", fmt.Sprintf("%d", id))
|
|
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()
|
|
id, err := hosts.Create(ctx, model.Host{
|
|
Name: "detail-host",
|
|
MAC: "aa:bb:cc:dd:ee:30",
|
|
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, "deadbeef", 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)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
ui.HostDetail(rr, detailReq(id))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
|
|
}
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, "detail-host") {
|
|
t.Fatalf("body missing host name: %s", body)
|
|
}
|
|
wantPipelineID := fmt.Sprintf(`id="pipeline-%d"`, runID)
|
|
if !strings.Contains(body, wantPipelineID) {
|
|
t.Fatalf("body missing %s", wantPipelineID)
|
|
}
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
func TestHostDetail_NeverRun(t *testing.T) {
|
|
ui, hosts, _ := setupDetail(t)
|
|
id, err := hosts.Create(context.Background(), model.Host{
|
|
Name: "never-run",
|
|
MAC: "aa:bb:cc:dd:ee:31",
|
|
WoLBroadcastIP: "10.0.0.255",
|
|
WoLPort: 9,
|
|
ExpectedSpecYAML: "memory:\n total_gib: 8\n",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create host: %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())
|
|
}
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, "Start vetting") {
|
|
t.Fatalf("never-run page missing Start vetting: %s", body)
|
|
}
|
|
// Ghost pipeline: all nodes rendered but none are running/failed/passed.
|
|
if strings.Contains(body, "stage-dot-running") || strings.Contains(body, "stage-dot-failed") {
|
|
t.Fatalf("ghost pipeline should have no running/failed dots: %s", body)
|
|
}
|
|
if !strings.Contains(body, "stage-dot-pending") {
|
|
t.Fatalf("expected pending stage dots in ghost pipeline: %s", body)
|
|
}
|
|
}
|
|
|
|
// 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: "steps-host",
|
|
MAC: "aa:bb:cc:dd:ee:40",
|
|
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, "cafef00d", 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)
|
|
}
|
|
// 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))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rr.Code)
|
|
}
|
|
body := rr.Body.String()
|
|
|
|
// Every stage in DefaultStageOrder owns a collapsible panel + log pane.
|
|
for _, s := range store.DefaultStageOrder {
|
|
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 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) {
|
|
ui, _, _ := setupDetail(t)
|
|
rr := httptest.NewRecorder()
|
|
ui.HostDetail(rr, detailReq(9999))
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestHostDetail_BadID(t *testing.T) {
|
|
ui, _, _ := setupDetail(t)
|
|
req := httptest.NewRequest(http.MethodGet, "/hosts/not-a-number", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", "not-a-number")
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
rr := httptest.NewRecorder()
|
|
ui.HostDetail(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", rr.Code)
|
|
}
|
|
}
|