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:
@@ -1,455 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"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 setupPage(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 hostReq(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))
|
||||
}
|
||||
|
||||
// TestHostPage_RunsTableRendered: GET /hosts/{id} renders one <tr> per
|
||||
// run with the compact stage-strip in each row. This is the operator's
|
||||
// "what has happened here" view.
|
||||
func TestHostPage_RunsTableRendered(t *testing.T) {
|
||||
ui, hosts, runs := setupPage(t)
|
||||
ctx := context.Background()
|
||||
id, err := hosts.Create(ctx, model.Host{
|
||||
Name: "runs-table",
|
||||
MAC: "aa:bb:cc:dd:ee:60",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create host: %v", err)
|
||||
}
|
||||
// Two runs: older completed, newer still in flight.
|
||||
oldID, err := runs.Create(ctx, id, "old", false)
|
||||
if err != nil {
|
||||
t.Fatalf("create old: %v", err)
|
||||
}
|
||||
if err := ui.Stages.Seed(ctx, oldID); err != nil {
|
||||
t.Fatalf("seed old: %v", err)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
newID, err := runs.Create(ctx, id, "new", false)
|
||||
if err != nil {
|
||||
t.Fatalf("create new: %v", err)
|
||||
}
|
||||
if err := ui.Stages.Seed(ctx, newID); err != nil {
|
||||
t.Fatalf("seed new: %v", err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
ui.HostPage(rr, hostReq(id))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, runID := range []int64{oldID, newID} {
|
||||
wantRow := fmt.Sprintf(`id="runrow-%d"`, runID)
|
||||
if !strings.Contains(body, wantRow) {
|
||||
t.Fatalf("body missing %s:\n%s", wantRow, body)
|
||||
}
|
||||
wantLink := fmt.Sprintf(`href="/runs/%d"`, runID)
|
||||
if !strings.Contains(body, wantLink) {
|
||||
t.Fatalf("body missing run link %s", wantLink)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, `class="stage-strip"`) {
|
||||
t.Fatalf("body missing stage-strip: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `class="runs-table"`) {
|
||||
t.Fatalf("body missing runs-table: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostPage_EmptyState: a never-run host renders the empty-state
|
||||
// banner + a big Start vetting button instead of the runs table.
|
||||
func TestHostPage_EmptyState(t *testing.T) {
|
||||
ui, hosts, _ := setupPage(t)
|
||||
id, err := hosts.Create(context.Background(), model.Host{
|
||||
Name: "empty-host",
|
||||
MAC: "aa:bb:cc:dd:ee:61",
|
||||
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.HostPage(rr, hostReq(id))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `class="host-empty-state"`) {
|
||||
t.Fatalf("body missing empty-state: %s", body)
|
||||
}
|
||||
if strings.Contains(body, `class="runs-table"`) {
|
||||
t.Fatalf("empty-state should not emit runs-table: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "Start vetting") {
|
||||
t.Fatalf("empty-state should offer Start vetting: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostPage_InFlightBanner: a live (non-terminal) run renders the
|
||||
// sticky in-flight banner pointing at /runs/{N}; the matching runs-table
|
||||
// row gets the .runs-row-live highlight class.
|
||||
func TestHostPage_InFlightBanner(t *testing.T) {
|
||||
ui, hosts, runs := setupPage(t)
|
||||
ctx := context.Background()
|
||||
id, err := hosts.Create(ctx, model.Host{
|
||||
Name: "live-host",
|
||||
MAC: "aa:bb:cc:dd:ee:62",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 8\n",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create host: %v", err)
|
||||
}
|
||||
runID, err := runs.Create(ctx, id, "live", false)
|
||||
if err != nil {
|
||||
t.Fatalf("create run: %v", err)
|
||||
}
|
||||
// Queued is non-terminal — enough to trigger the banner.
|
||||
rr := httptest.NewRecorder()
|
||||
ui.HostPage(rr, hostReq(id))
|
||||
body := rr.Body.String()
|
||||
wantOpen := fmt.Sprintf(`href="/runs/%d"`, runID)
|
||||
if !strings.Contains(body, wantOpen) {
|
||||
t.Fatalf("body missing in-flight link %s", wantOpen)
|
||||
}
|
||||
if !strings.Contains(body, "in progress") {
|
||||
t.Fatalf("body missing in-flight banner text: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `runs-row-live`) {
|
||||
t.Fatalf("body missing runs-row-live highlight: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostPage_UnknownID: a host that doesn't exist returns 404 — a
|
||||
// stale bookmark or typo shouldn't render anything.
|
||||
func TestHostPage_UnknownID(t *testing.T) {
|
||||
ui, _, _ := setupPage(t)
|
||||
rr := httptest.NewRecorder()
|
||||
ui.HostPage(rr, hostReq(9999))
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostPage_BadID: a non-numeric path segment returns 400, not a
|
||||
// mysterious 500.
|
||||
func TestHostPage_BadID(t *testing.T) {
|
||||
ui, _, _ := setupPage(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.HostPage(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rr.Code)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ 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
|
||||
}
|
||||
@@ -54,16 +53,6 @@ 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
|
||||
}
|
||||
|
||||
|
||||
+120
-73
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -107,28 +108,19 @@ func (u *UI) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
_ = templates.Dashboard(tiles).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
// HostDetail renders the per-host page: breadcrumb, summary, pipeline
|
||||
// timeline, hold card, action row, spec diffs, log pane, meta. Same
|
||||
// enrichment path as Dashboard for tile data; additionally reads stage
|
||||
// rows + spec diffs for the latest run to populate the timeline and
|
||||
// diff list.
|
||||
func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
|
||||
// HostPage renders /hosts/{id}: summary + actions + in-flight banner +
|
||||
// runs table. Run-level detail (pipeline, logs, sub-steps, spec diffs,
|
||||
// hold banner) lives on /runs/{runID}. The split keeps host-scoped and
|
||||
// run-scoped work on distinct URLs so permalinks don't wander onto
|
||||
// whichever run happens to be active.
|
||||
func (u *UI) HostPage(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "bad host id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// 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)
|
||||
data, err := u.LoadHostPageData(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
@@ -137,78 +129,129 @@ func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = templates.HostDetail(data).Render(r.Context(), w)
|
||||
_ = templates.HostPage(data).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
// LoadHostDetailData assembles the HostDetailData payload for hostID —
|
||||
// the same bundle the initial GET renders. Also used by the orchestrator's
|
||||
// PublishHostDetail path so the live SSE fragments render from identical
|
||||
// inputs as the initial page, avoiding drift between reload-rendered and
|
||||
// pushed HTML. Returns store.ErrNotFound if the host doesn't exist; all
|
||||
// other store errors are surfaced to the caller. Sub-queries for stages,
|
||||
// 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.
|
||||
//
|
||||
// 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) {
|
||||
// LoadHostPageData assembles the HostPageData payload for hostID — host
|
||||
// metadata, the full newest-first runs list, the currently non-terminal
|
||||
// run (if any) for the in-flight banner, and a per-run stages map so
|
||||
// the runs table can paint its compact stage-strips without re-querying
|
||||
// inside the template. Returns store.ErrNotFound when the host doesn't
|
||||
// exist; other store errors are surfaced. Stage lookups are fail-soft:
|
||||
// a transient DB error on one run's stages yields an empty strip for
|
||||
// that row rather than blanking the whole page.
|
||||
func (u *UI) LoadHostPageData(ctx context.Context, hostID int64) (templates.HostPageData, error) {
|
||||
host, err := u.Hosts.Get(ctx, hostID)
|
||||
if err != nil {
|
||||
return templates.HostDetailData{}, err
|
||||
return templates.HostPageData{}, err
|
||||
}
|
||||
latest, err := u.Runs.LatestForHost(ctx, hostID)
|
||||
if err != nil {
|
||||
return templates.HostDetailData{}, err
|
||||
var runs []model.Run
|
||||
if u.Runs != nil {
|
||||
runs, _ = u.Runs.ListForHostAll(ctx, hostID)
|
||||
}
|
||||
// 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 active *model.Run
|
||||
for i := range runs {
|
||||
if !runs[i].State.IsTerminal() {
|
||||
active = &runs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
runStages := make(map[int64][]model.Stage, len(runs))
|
||||
if u.Stages != nil {
|
||||
for _, r := range runs {
|
||||
if stages, err := u.Stages.ListForRun(ctx, r.ID); err == nil {
|
||||
runStages[r.ID] = stages
|
||||
}
|
||||
}
|
||||
}
|
||||
return templates.HostPageData{
|
||||
Host: *host,
|
||||
LastSeenAt: host.LastSeenAt,
|
||||
Runs: runs,
|
||||
ActiveRun: active,
|
||||
RunStages: runStages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RunPage renders /runs/{runID}: breadcrumb, run header, hold banner,
|
||||
// pipeline, per-stage active-step panels, and spec diffs. Host metadata
|
||||
// is resolved from run.HostID for the breadcrumb and for action POST
|
||||
// targets (cancel/override still live under /hosts/{hostID}/...).
|
||||
func (u *UI) RunPage(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "runID")
|
||||
runID, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "bad run id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
data, err := u.LoadRunPageData(r.Context(), runID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = templates.RunPage(data).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
// LoadRunPageData assembles the RunPageData payload for runID. Resolves
|
||||
// the owning host, then reads stages, sub-steps, spec diffs, and log
|
||||
// replay. Returns store.ErrNotFound when the run or host is gone. The
|
||||
// orchestrator's PublishRunPage path uses the same loader so SSE fragments
|
||||
// render from identical inputs as the initial GET.
|
||||
func (u *UI) LoadRunPageData(ctx context.Context, runID int64) (templates.RunPageData, error) {
|
||||
if u.Runs == nil {
|
||||
return templates.RunPageData{}, store.ErrNotFound
|
||||
}
|
||||
run, err := u.Runs.Get(ctx, runID)
|
||||
if err != nil {
|
||||
return templates.RunPageData{}, err
|
||||
}
|
||||
if run == nil {
|
||||
return templates.RunPageData{}, store.ErrNotFound
|
||||
}
|
||||
host, err := u.Hosts.Get(ctx, run.HostID)
|
||||
if err != nil {
|
||||
return templates.RunPageData{}, err
|
||||
}
|
||||
var stages []model.Stage
|
||||
var diffs []model.SpecDiff
|
||||
var subSteps []model.SubStep
|
||||
if viewed != nil {
|
||||
if u.Stages != nil {
|
||||
stages, _ = u.Stages.ListForRun(ctx, viewed.ID)
|
||||
}
|
||||
if u.SpecDiffs != nil {
|
||||
diffs, _ = u.SpecDiffs.ListForRun(ctx, viewed.ID)
|
||||
}
|
||||
if u.SubSteps != nil {
|
||||
subSteps, _ = u.SubSteps.ListForRun(ctx, viewed.ID)
|
||||
}
|
||||
var diffs []model.SpecDiff
|
||||
if u.Stages != nil {
|
||||
stages, _ = u.Stages.ListForRun(ctx, runID)
|
||||
}
|
||||
// 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)
|
||||
if u.SubSteps != nil {
|
||||
subSteps, _ = u.SubSteps.ListForRun(ctx, runID)
|
||||
}
|
||||
if u.SpecDiffs != nil {
|
||||
diffs, _ = u.SpecDiffs.ListForRun(ctx, runID)
|
||||
}
|
||||
t := u.Tiles.Build(ctx, *host, viewed)
|
||||
replay := ""
|
||||
replayByStage := map[string]string{}
|
||||
if viewed != nil && u.Logs != nil {
|
||||
replay = u.Logs.Replay(viewed.ID)
|
||||
replayByStage = u.Logs.ReplayByStage(viewed.ID)
|
||||
if u.Logs != nil {
|
||||
replayByStage = u.Logs.ReplayByStage(runID)
|
||||
}
|
||||
return templates.HostDetailData{
|
||||
Tile: t,
|
||||
// Critical-diff count + hold-key path reuse the tile enricher so the
|
||||
// run header shows the same numbers the dashboard tile + runs-table
|
||||
// row show. Fail-soft if tiles isn't wired (test setups can skip it).
|
||||
critical := 0
|
||||
holdKeyPath := ""
|
||||
if u.Tiles != nil {
|
||||
t := u.Tiles.Build(ctx, *host, run)
|
||||
critical = t.SpecDiffCritical
|
||||
holdKeyPath = t.HoldKeyPath
|
||||
}
|
||||
return templates.RunPageData{
|
||||
Host: *host,
|
||||
Run: *run,
|
||||
Stages: stages,
|
||||
SpecDiffs: diffs,
|
||||
SubSteps: subSteps,
|
||||
History: history,
|
||||
SpecDiffs: diffs,
|
||||
DefaultStepStage: pickDefaultStep(stages),
|
||||
LogReplay: replay,
|
||||
LogReplayByStage: replayByStage,
|
||||
HoldKeyPath: holdKeyPath,
|
||||
SpecDiffCritical: critical,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -285,7 +328,9 @@ func (u *UI) StartRun(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
log.Printf("ui: created run %d for host %d (state=Queued)", runID, hostID)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
// Send the operator straight to the new run — the button they clicked
|
||||
// was "Start vetting", the thing they want next is to watch it.
|
||||
http.Redirect(w, r, fmt.Sprintf("/runs/%d", runID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UI) NewHostForm(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -542,7 +587,9 @@ func (u *UI) OverrideWipeStorage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "override: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
// Operator was on /runs/{latest.ID} when they clicked — land them
|
||||
// back there so they can see the override take effect.
|
||||
http.Redirect(w, r, fmt.Sprintf("/runs/%d", latest.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// CancelRun halts an in-flight run. Transitions the run to
|
||||
@@ -571,7 +618,7 @@ func (u *UI) CancelRun(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
log.Printf("ui: cancelled run %d for host %d", latest.ID, hostID)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
http.Redirect(w, r, fmt.Sprintf("/runs/%d", latest.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UI) DeleteHost(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user