ui: split /hosts/{id} into host page + /runs/{runID} run page
Host page owns host metadata, full runs table with per-row stage strip, in-flight banner, and empty-state CTA. Run page owns pipeline, active step, logs, sub-steps, spec diffs, and hold banner with a breadcrumb back to the host. Dashboard tile reverts to host-only. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"vetting/internal/model"
|
||||
"vetting/internal/store"
|
||||
)
|
||||
|
||||
func runReq(runID int64) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/runs/%d", runID), nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("runID", fmt.Sprintf("%d", runID))
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
// defaultOpenStage returns the data-stage attr of the single
|
||||
// `<details ... open data-stage="...">` rendered by ActiveStep.
|
||||
// Rendered attribute order is fixed (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]
|
||||
}
|
||||
|
||||
// TestRunPage_Pipeline: GET /runs/{id} emits the breadcrumb, run
|
||||
// header, pipeline section, and active-step panels for every canonical
|
||||
// stage. Sanity check for the run-page shell.
|
||||
func TestRunPage_Pipeline(t *testing.T) {
|
||||
ui, hosts, runs := setupPage(t)
|
||||
ctx := context.Background()
|
||||
id, err := hosts.Create(ctx, model.Host{
|
||||
Name: "run-pipeline",
|
||||
MAC: "aa:bb:cc:dd:ee:70",
|
||||
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, "rp", 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.RunPage(rr, runReq(runID))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
wantPipelineID := fmt.Sprintf(`id="pipeline-%d"`, runID)
|
||||
if !strings.Contains(body, wantPipelineID) {
|
||||
t.Fatalf("body missing %s", wantPipelineID)
|
||||
}
|
||||
wantHeader := fmt.Sprintf(`id="run-header-%d"`, runID)
|
||||
if !strings.Contains(body, wantHeader) {
|
||||
t.Fatalf("body missing %s", wantHeader)
|
||||
}
|
||||
// Breadcrumb: Dashboard / host / run #N — three segments.
|
||||
wantCrumb := fmt.Sprintf(`run #%d`, runID)
|
||||
if !strings.Contains(body, wantCrumb) {
|
||||
t.Fatalf("body missing breadcrumb %q", wantCrumb)
|
||||
}
|
||||
if !strings.Contains(body, `href="/hosts/`) {
|
||||
t.Fatalf("body missing breadcrumb host link: %s", body)
|
||||
}
|
||||
// Every stage gets its own active-step panel with a 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 log pane %s", wantPane)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunPage_DefaultStep_Running: while a stage is running, its
|
||||
// <details open> opens by default so the operator's eye lands on the
|
||||
// live work first.
|
||||
func TestRunPage_DefaultStep_Running(t *testing.T) {
|
||||
ui, hosts, runs := setupPage(t)
|
||||
ctx := context.Background()
|
||||
id, _ := hosts.Create(ctx, model.Host{
|
||||
Name: "run-running",
|
||||
MAC: "aa:bb:cc:dd:ee:71",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||
})
|
||||
runID, _ := runs.Create(ctx, id, "rr", false)
|
||||
_ = ui.Stages.Seed(ctx, runID)
|
||||
for _, name := range []string{"Inventory", "SpecValidate"} {
|
||||
_ = ui.Stages.StartByName(ctx, runID, name)
|
||||
_ = ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, "")
|
||||
}
|
||||
_ = ui.Stages.StartByName(ctx, runID, "SMART")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
ui.RunPage(rr, runReq(runID))
|
||||
if got := defaultOpenStage(rr.Body.String()); got != "SMART" {
|
||||
t.Fatalf("default step = %q, want SMART", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunPage_DefaultStep_Failed: no stage running but one failed →
|
||||
// default opens the failed stage so the operator reads the blocker.
|
||||
func TestRunPage_DefaultStep_Failed(t *testing.T) {
|
||||
ui, hosts, runs := setupPage(t)
|
||||
ctx := context.Background()
|
||||
id, _ := hosts.Create(ctx, model.Host{
|
||||
Name: "run-failed",
|
||||
MAC: "aa:bb:cc:dd:ee:72",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||
})
|
||||
runID, _ := runs.Create(ctx, id, "rf", false)
|
||||
_ = ui.Stages.Seed(ctx, runID)
|
||||
for _, name := range []string{"Inventory", "SpecValidate", "SMART"} {
|
||||
_ = ui.Stages.StartByName(ctx, runID, name)
|
||||
_ = ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, "")
|
||||
}
|
||||
_ = ui.Stages.StartByName(ctx, runID, "CPUStress")
|
||||
_ = ui.Stages.CompleteByName(ctx, runID, "CPUStress", model.StageFailed, `{"reason":"thermal"}`)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
ui.RunPage(rr, runReq(runID))
|
||||
if got := defaultOpenStage(rr.Body.String()); got != "CPUStress" {
|
||||
t.Fatalf("default step = %q, want CPUStress", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunPage_DefaultStep_Completed: all stages passed → default lands
|
||||
// on Reporting (where the report link lives).
|
||||
func TestRunPage_DefaultStep_Completed(t *testing.T) {
|
||||
ui, hosts, runs := setupPage(t)
|
||||
ctx := context.Background()
|
||||
id, _ := hosts.Create(ctx, model.Host{
|
||||
Name: "run-done",
|
||||
MAC: "aa:bb:cc:dd:ee:73",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||
})
|
||||
runID, _ := runs.Create(ctx, id, "rd", false)
|
||||
_ = ui.Stages.Seed(ctx, runID)
|
||||
for _, name := range store.DefaultStageOrder {
|
||||
_ = ui.Stages.StartByName(ctx, runID, name)
|
||||
_ = ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, "")
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
ui.RunPage(rr, runReq(runID))
|
||||
if got := defaultOpenStage(rr.Body.String()); got != "Reporting" {
|
||||
t.Fatalf("default step = %q, want Reporting", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunPage_CancelForm: non-terminal run shows a Cancel button that
|
||||
// posts to /hosts/{hostID}/cancel. Terminal runs omit Cancel and
|
||||
// instead offer Start-new-run (posts to /hosts/{hostID}/start).
|
||||
func TestRunPage_CancelForm(t *testing.T) {
|
||||
ui, hosts, runs := setupPage(t)
|
||||
ctx := context.Background()
|
||||
id, _ := hosts.Create(ctx, model.Host{
|
||||
Name: "run-cancel",
|
||||
MAC: "aa:bb:cc:dd:ee:74",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||
})
|
||||
runID, _ := runs.Create(ctx, id, "rc", false)
|
||||
|
||||
// Non-terminal (Queued): Cancel form present.
|
||||
rr := httptest.NewRecorder()
|
||||
ui.RunPage(rr, runReq(runID))
|
||||
body := rr.Body.String()
|
||||
wantCancel := fmt.Sprintf(`/hosts/%d/cancel`, id)
|
||||
if !strings.Contains(body, wantCancel) {
|
||||
t.Fatalf("non-terminal run missing Cancel form %s", wantCancel)
|
||||
}
|
||||
if strings.Contains(body, "Start new run") {
|
||||
t.Fatalf("non-terminal run should not offer Start new run: %s", body)
|
||||
}
|
||||
|
||||
// Now flip to Cancelled (terminal) and re-render.
|
||||
if err := ui.Runs.SetState(ctx, runID, model.StateCancelled); err != nil {
|
||||
t.Fatalf("cancel run: %v", err)
|
||||
}
|
||||
rr = httptest.NewRecorder()
|
||||
ui.RunPage(rr, runReq(runID))
|
||||
body = rr.Body.String()
|
||||
if strings.Contains(body, wantCancel) {
|
||||
t.Fatalf("terminal run should not show Cancel form: %s", body)
|
||||
}
|
||||
wantStart := fmt.Sprintf(`/hosts/%d/start`, id)
|
||||
if !strings.Contains(body, wantStart) {
|
||||
t.Fatalf("terminal run missing Start-new-run form %s", wantStart)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunPage_UnknownRun: /runs/999999 returns 404 rather than rendering
|
||||
// an empty page.
|
||||
func TestRunPage_UnknownRun(t *testing.T) {
|
||||
ui, _, _ := setupPage(t)
|
||||
rr := httptest.NewRecorder()
|
||||
ui.RunPage(rr, runReq(999999))
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunPage_BadID: a non-numeric runID returns 400.
|
||||
func TestRunPage_BadID(t *testing.T) {
|
||||
ui, _, _ := setupPage(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/runs/bogus", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("runID", "bogus")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
rr := httptest.NewRecorder()
|
||||
ui.RunPage(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rr.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user