From 19608bef1bcac4fa901680e1e1b8fd428d2f3c9f Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 18 Apr 2026 20:37:57 -0400 Subject: [PATCH] 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 --- cmd/vetting/main.go | 50 +- internal/api/host_detail_test.go | 455 ------- internal/api/host_page_test.go | 216 ++++ internal/api/run_page_test.go | 243 ++++ internal/api/tile.go | 11 - internal/api/ui_handlers.go | 193 +-- internal/httpserver/router.go | 3 +- internal/orchestrator/runner.go | 127 +- internal/orchestrator/runner_test.go | 52 +- internal/store/runs.go | 13 +- internal/web/static/app.css | 213 ++-- internal/web/templates/dashboard.templ | 6 +- internal/web/templates/dashboard_templ.go | 6 +- internal/web/templates/host_detail.templ | 423 ------ internal/web/templates/host_detail_templ.go | 1274 ------------------- internal/web/templates/host_detail_test.go | 117 -- internal/web/templates/host_page.templ | 365 ++++++ internal/web/templates/host_page_templ.go | 976 ++++++++++++++ internal/web/templates/host_tile.templ | 35 +- internal/web/templates/host_tile_templ.go | 226 +--- internal/web/templates/host_tile_test.go | 92 +- internal/web/templates/run_detail.templ | 188 +++ internal/web/templates/run_detail_templ.go | 716 +++++++++++ 23 files changed, 3173 insertions(+), 2827 deletions(-) delete mode 100644 internal/api/host_detail_test.go create mode 100644 internal/api/host_page_test.go create mode 100644 internal/api/run_page_test.go delete mode 100644 internal/web/templates/host_detail.templ delete mode 100644 internal/web/templates/host_detail_templ.go delete mode 100644 internal/web/templates/host_detail_test.go create mode 100644 internal/web/templates/host_page.templ create mode 100644 internal/web/templates/host_page_templ.go create mode 100644 internal/web/templates/run_detail.templ create mode 100644 internal/web/templates/run_detail_templ.go diff --git a/cmd/vetting/main.go b/cmd/vetting/main.go index 8a36159..7a0df9b 100644 --- a/cmd/vetting/main.go +++ b/cmd/vetting/main.go @@ -78,7 +78,6 @@ func main() { tiles := &api.TileEnricher{ Runs: runStore, - Stages: stageStore, Artifacts: artifactStore, SpecDiffs: specDiffStore, } @@ -113,28 +112,41 @@ func main() { PublicURL: cfg.Server.PublicURL, } - // Inject the host-detail fragment renderer. The closure reuses - // LoadHostDetailData so the SSE-pushed HTML matches an identical - // reload-rendered page byte-for-byte, then hands each region to - // its Render*String helper. - orchestrator.HostDetailRenderer = func(ctx context.Context, hostID int64) (orchestrator.HostDetailFragments, bool) { - // Orchestrator-side publishes always reference the latest run — - // SSE topics are keyed by runID, so a stale ?run=N bookmark - // doesn't affect what the server pushes. - d, err := ui.LoadHostDetailData(ctx, hostID, 0) + // Inject the host-page + run-page fragment renderers. Each reuses + // the matching LoadHostPageData / LoadRunPageData so SSE-pushed HTML + // matches an initial page load byte-for-byte, then hands each region + // to its Render*String helper. + orchestrator.HostPageRenderer = func(ctx context.Context, hostID int64) (orchestrator.HostPageFragments, bool) { + d, err := ui.LoadHostPageData(ctx, hostID) if err != nil { - return orchestrator.HostDetailFragments{}, false + return orchestrator.HostPageFragments{}, false } - f := orchestrator.HostDetailFragments{ - Summary: templates.RenderDetailSummaryString(d), - Actions: templates.RenderDetailActionsString(d), - SpecDiffs: templates.RenderDetailSpecDiffsString(d), - Hold: templates.RenderDetailHoldString(d), + rows := make(map[int64]string, len(d.Runs)) + for _, r := range d.Runs { + rows[r.ID] = templates.RenderRunRowString(templates.RunRowData{ + Run: r, + Stages: d.RunStages[r.ID], + Live: d.ActiveRun != nil && d.ActiveRun.ID == r.ID, + }) } - if d.Tile.Latest != nil { - f.LatestRunID = d.Tile.Latest.ID + return orchestrator.HostPageFragments{ + Summary: templates.RenderHostSummaryString(d), + Actions: templates.RenderHostActionsString(d), + InFlightBanner: templates.RenderInFlightBannerString(d), + RunRows: rows, + }, true + } + + orchestrator.RunPageRenderer = func(ctx context.Context, runID int64) (orchestrator.RunPageFragments, bool) { + d, err := ui.LoadRunPageData(ctx, runID) + if err != nil { + return orchestrator.RunPageFragments{}, false } - return f, true + return orchestrator.RunPageFragments{ + Header: templates.RenderRunHeaderString(d), + Hold: templates.RenderHoldBannerString(d), + SpecDiffs: templates.RenderRunSpecDiffsString(d), + }, true } agentAPI := &api.Agent{ diff --git a/internal/api/host_detail_test.go b/internal/api/host_detail_test.go deleted file mode 100644 index 9e5ef29..0000000 --- a/internal/api/host_detail_test.go +++ /dev/null @@ -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 -//
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 -// `
` 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) - } -} diff --git a/internal/api/host_page_test.go b/internal/api/host_page_test.go new file mode 100644 index 0000000..e51e7b8 --- /dev/null +++ b/internal/api/host_page_test.go @@ -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 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) + } +} diff --git a/internal/api/run_page_test.go b/internal/api/run_page_test.go new file mode 100644 index 0000000..586a509 --- /dev/null +++ b/internal/api/run_page_test.go @@ -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 +// `
` 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 +//
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) + } +} diff --git a/internal/api/tile.go b/internal/api/tile.go index 9d82823..5151668 100644 --- a/internal/api/tile.go +++ b/internal/api/tile.go @@ -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 } diff --git a/internal/api/ui_handlers.go b/internal/api/ui_handlers.go index 6f36e82..c3f2a9f 100644 --- a/internal/api/ui_handlers.go +++ b/internal/api/ui_handlers.go @@ -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) { diff --git a/internal/httpserver/router.go b/internal/httpserver/router.go index b8c9d08..e3a4c2d 100644 --- a/internal/httpserver/router.go +++ b/internal/httpserver/router.go @@ -70,11 +70,12 @@ func NewRouter(d Deps) http.Handler { r.Get("/", d.UI.Dashboard) r.Get("/hosts/new", d.UI.NewHostForm) r.Post("/hosts", d.UI.CreateHost) - r.Get("/hosts/{id}", d.UI.HostDetail) + r.Get("/hosts/{id}", d.UI.HostPage) r.Post("/hosts/{id}/delete", d.UI.DeleteHost) r.Post("/hosts/{id}/start", d.UI.StartRun) r.Post("/hosts/{id}/cancel", d.UI.CancelRun) r.Post("/hosts/{id}/override-wipe", d.UI.OverrideWipeStorage) + r.Get("/runs/{runID}", d.UI.RunPage) r.Get("/reports/{runID}", d.UI.Report) r.Get("/register/quick.sh", d.UI.QuickRegisterScript) r.Get("/events", d.UI.SSE) diff --git a/internal/orchestrator/runner.go b/internal/orchestrator/runner.go index d8c3e0e..4c51799 100644 --- a/internal/orchestrator/runner.go +++ b/internal/orchestrator/runner.go @@ -72,19 +72,21 @@ func (r *Runner) PublishTileUpdate(ctx context.Context, hostID int64) { r.publishTileUpdate(ctx, hostID) } -// PublishHostDetail broadcasts fresh HTML fragments for every non-log, -// non-pipeline region of the host detail page: summary header, actions -// row, spec-diffs list, and the hold-key SSH block. Callers should -// invoke this alongside PublishTileUpdate from any site that mutates -// state visible on the detail page. +// PublishHostPage broadcasts fresh HTML fragments for every host-keyed +// region on /hosts/{id}: summary card, primary-actions row, and the +// in-flight banner. It also fires a runrow-{runID} swap for every run +// whose row is affected by this state change (the active one plus any +// run that just completed). Callers should invoke this alongside +// PublishTileUpdate at every site that mutates state visible on the +// host page or its runs table. // // Safe to call when no renderer has been registered or the host has // been deleted; the call is silently dropped. -func (r *Runner) PublishHostDetail(ctx context.Context, hostID int64) { - if HostDetailRenderer == nil || r.EventHub == nil { +func (r *Runner) PublishHostPage(ctx context.Context, hostID int64) { + if HostPageRenderer == nil || r.EventHub == nil { return } - f, ok := HostDetailRenderer(ctx, hostID) + f, ok := HostPageRenderer(ctx, hostID) if !ok { return } @@ -96,18 +98,48 @@ func (r *Runner) PublishHostDetail(ctx context.Context, hostID int64) { Name: fmt.Sprintf("detail-actions-%d", hostID), Payload: f.Actions, }) - if f.LatestRunID != 0 { + r.EventHub.Publish(events.Event{ + Name: fmt.Sprintf("detail-inflight-%d", hostID), + Payload: f.InFlightBanner, + }) + for runID, payload := range f.RunRows { r.EventHub.Publish(events.Event{ - Name: fmt.Sprintf("detail-specdiffs-%d", f.LatestRunID), - Payload: f.SpecDiffs, - }) - r.EventHub.Publish(events.Event{ - Name: fmt.Sprintf("detail-hold-%d", f.LatestRunID), - Payload: f.Hold, + Name: fmt.Sprintf("runrow-%d", runID), + Payload: payload, }) } } +// PublishRunPage broadcasts fresh HTML fragments for every run-keyed +// region on /runs/{runID}: header (with Cancel / Start-new-run / +// View-report), hold banner, and spec diffs. The pipeline is already +// fired separately from publishTileUpdate. Caller is any site that +// transitions run state or writes a spec-diff / hold row. +// +// Safe to call when no renderer has been registered or the run has +// been deleted; the call is silently dropped. +func (r *Runner) PublishRunPage(ctx context.Context, runID int64) { + if RunPageRenderer == nil || r.EventHub == nil { + return + } + f, ok := RunPageRenderer(ctx, runID) + if !ok { + return + } + r.EventHub.Publish(events.Event{ + Name: fmt.Sprintf("run-header-%d", runID), + Payload: f.Header, + }) + r.EventHub.Publish(events.Event{ + Name: fmt.Sprintf("detail-hold-%d", runID), + Payload: f.Hold, + }) + r.EventHub.Publish(events.Event{ + Name: fmt.Sprintf("detail-specdiffs-%d", runID), + Payload: f.SpecDiffs, + }) +} + func (r *Runner) publishTileUpdate(ctx context.Context, hostID int64) { host, err := r.Hosts.Get(ctx, hostID) if err != nil { @@ -135,11 +167,19 @@ func (r *Runner) publishTileUpdate(ctx context.Context, hostID int64) { } } - // Detail-page fragments — everything on /hosts/{id} that isn't the - // pipeline or the log pane. Co-located here so every site that - // already publishes a tile refresh also refreshes the detail page - // without the caller having to remember a second call. - r.PublishHostDetail(ctx, hostID) + // Host-page fragments — everything on /hosts/{id} that isn't the + // pipeline or the log pane: summary card, primary actions, in-flight + // banner, and per-run row swaps in the runs table. Co-located here + // so every tile-refresh site also refreshes the host page without + // the caller having to remember a second call. + r.PublishHostPage(ctx, hostID) + + // Run-page fragments — header (cancel button visibility), hold + // banner, spec diffs. Fires alongside the tile + pipeline refreshes + // so any state-change site covers both /hosts/{id} and /runs/{runID}. + if latest != nil { + r.PublishRunPage(ctx, latest.ID) + } } // TileRenderer renders a single tile fragment. Registered at startup @@ -173,24 +213,39 @@ func (r *Runner) PublishSubStepUpdate(ctx context.Context, ss model.SubStep) { }) } -// HostDetailFragments is the pre-rendered bundle of HTML fragments a -// single PublishHostDetail call broadcasts over SSE. Summary and Actions -// are always set; SpecDiffs and Hold are empty strings when there is no -// latest run (the corresponding events are not published in that case). -type HostDetailFragments struct { - Summary string - Actions string - SpecDiffs string - Hold string - LatestRunID int64 // 0 when the host has no runs yet +// HostPageFragments is the pre-rendered bundle a single PublishHostPage +// call broadcasts over SSE. Summary, Actions, and InFlightBanner are +// always set. RunRows is a runID → pre-rendered map so every row +// whose state just changed refreshes atomically (typically only the +// active run, plus whichever run just became terminal). +type HostPageFragments struct { + Summary string + Actions string + InFlightBanner string + RunRows map[int64]string } -// HostDetailRenderer produces the four fragments for a given host. -// Registered at startup by main so the orchestrator doesn't import the -// template or store-enrichment layers. Returns ok=false when the host -// cannot be loaded (deleted, DB error); caller skips publish in that -// case. -var HostDetailRenderer func(ctx context.Context, hostID int64) (HostDetailFragments, bool) +// HostPageRenderer produces the fragments for a given host. Registered +// at startup by main so the orchestrator doesn't import the template +// or store-enrichment layers. Returns ok=false when the host cannot be +// loaded (deleted, DB error); caller skips publish in that case. +var HostPageRenderer func(ctx context.Context, hostID int64) (HostPageFragments, bool) + +// RunPageFragments is the pre-rendered bundle a single PublishRunPage +// call broadcasts over SSE. Header is always set; Hold and SpecDiffs +// are always set too (they emit an empty placeholder when no hold / +// diffs exist, so the first real event has a DOM target). +type RunPageFragments struct { + Header string + Hold string + SpecDiffs string +} + +// RunPageRenderer produces the fragments for a given run. Registered at +// startup by main so the orchestrator doesn't import the template or +// store-enrichment layers. Returns ok=false when the run cannot be +// loaded. +var RunPageRenderer func(ctx context.Context, runID int64) (RunPageFragments, bool) func renderTileSSE(ctx context.Context, host model.Host, latest *model.Run) string { if TileRenderer == nil { diff --git a/internal/orchestrator/runner_test.go b/internal/orchestrator/runner_test.go index cd07efc..c138f81 100644 --- a/internal/orchestrator/runner_test.go +++ b/internal/orchestrator/runner_test.go @@ -33,30 +33,38 @@ func setupRunner(t *testing.T) (*orchestrator.Runner, *store.Hosts, *store.Runs, // grep the published fragments without parsing HTML. prevTile := orchestrator.TileRenderer prevPipe := orchestrator.PipelineRenderer - prevDetail := orchestrator.HostDetailRenderer + prevHost := orchestrator.HostPageRenderer + prevRun := orchestrator.RunPageRenderer orchestrator.TileRenderer = func(_ context.Context, host model.Host, _ *model.Run) string { return fmt.Sprintf(`
tile
`, host.ID) } orchestrator.PipelineRenderer = func(run *model.Run, _ []model.Stage) string { return fmt.Sprintf(`
pipeline
`, run.ID) } - orchestrator.HostDetailRenderer = func(_ context.Context, hostID int64) (orchestrator.HostDetailFragments, bool) { - var runID int64 + orchestrator.HostPageRenderer = func(_ context.Context, hostID int64) (orchestrator.HostPageFragments, bool) { + rows := map[int64]string{} if latest, err := runs.LatestForHost(context.Background(), hostID); err == nil && latest != nil { - runID = latest.ID + rows[latest.ID] = fmt.Sprintf(`row`, latest.ID) } - return orchestrator.HostDetailFragments{ - Summary: fmt.Sprintf(`
summary
`, hostID), - Actions: fmt.Sprintf(`
actions
`, hostID), - SpecDiffs: fmt.Sprintf(`
diffs
`, runID), - Hold: fmt.Sprintf(`
hold
`, runID), - LatestRunID: runID, + return orchestrator.HostPageFragments{ + Summary: fmt.Sprintf(`
summary
`, hostID), + Actions: fmt.Sprintf(`
actions
`, hostID), + InFlightBanner: fmt.Sprintf(`
inflight
`, hostID), + RunRows: rows, + }, true + } + orchestrator.RunPageRenderer = func(_ context.Context, runID int64) (orchestrator.RunPageFragments, bool) { + return orchestrator.RunPageFragments{ + Header: fmt.Sprintf(`
header
`, runID), + Hold: fmt.Sprintf(`
hold
`, runID), + SpecDiffs: fmt.Sprintf(`
diffs
`, runID), }, true } cleanup := func() { orchestrator.TileRenderer = prevTile orchestrator.PipelineRenderer = prevPipe - orchestrator.HostDetailRenderer = prevDetail + orchestrator.HostPageRenderer = prevHost + orchestrator.RunPageRenderer = prevRun _ = conn.Close() } return runner, hosts, runs, hub, cleanup @@ -128,11 +136,12 @@ loop: } } -// TestPublishesHostDetailFragments asserts that every state-change -// publish site also emits the four detail-page SSE events (summary, -// actions, specdiffs, hold). Without this, the host detail page -// stays frozen on the state at page-load time. -func TestPublishesHostDetailFragments(t *testing.T) { +// TestPublishesHostPageAndRunPageFragments asserts that every state- +// change publish site emits the full set of host-page SSE events +// (summary, actions, in-flight banner, runrow) *and* the run-page +// events (header, hold, specdiffs). Without this, neither /hosts/{id} +// nor /runs/{runID} update live. +func TestPublishesHostPageAndRunPageFragments(t *testing.T) { runner, hosts, runs, hub, cleanup := setupRunner(t) defer cleanup() ctx := context.Background() @@ -160,10 +169,13 @@ func TestPublishesHostDetailFragments(t *testing.T) { } want := map[string]bool{ - fmt.Sprintf("detail-summary-%d", hostID): false, - fmt.Sprintf("detail-actions-%d", hostID): false, - fmt.Sprintf("detail-specdiffs-%d", runID): false, - fmt.Sprintf("detail-hold-%d", runID): false, + fmt.Sprintf("detail-summary-%d", hostID): false, + fmt.Sprintf("detail-actions-%d", hostID): false, + fmt.Sprintf("detail-inflight-%d", hostID): false, + fmt.Sprintf("runrow-%d", runID): false, + fmt.Sprintf("run-header-%d", runID): false, + fmt.Sprintf("detail-hold-%d", runID): false, + fmt.Sprintf("detail-specdiffs-%d", runID): false, } deadline := time.After(500 * time.Millisecond) for { diff --git a/internal/store/runs.go b/internal/store/runs.go index d388f50..59ab104 100644 --- a/internal/store/runs.go +++ b/internal/store/runs.go @@ -155,9 +155,8 @@ func (r *Runs) LatestForHost(ctx context.Context, hostID int64) (*model.Run, err } // ListForHost returns the most recent `limit` runs for a host, newest -// first. Caller uses this to drive the host-detail runs sidebar (last 20 -// by default, Phase 2). Zero/negative limit falls back to a safe cap so -// a mistaken call can't scan the whole history into memory. +// first. Zero/negative limit falls back to a safe cap so a mistaken call +// can't scan the whole history into memory. func (r *Runs) ListForHost(ctx context.Context, hostID int64, limit int) ([]model.Run, error) { if limit <= 0 { limit = 20 @@ -193,6 +192,14 @@ func (r *Runs) ListForHost(ctx context.Context, hostID int64, limit int) ([]mode return out, rows.Err() } +// ListForHostAll returns every run for a host, newest first. Caps at a +// defensive 1000 rows so a runaway host that somehow accumulated tens +// of thousands of runs doesn't blow up the page load; typical hosts +// finish with < 50. +func (r *Runs) ListForHostAll(ctx context.Context, hostID int64) ([]model.Run, error) { + return r.ListForHost(ctx, hostID, 1000) +} + // Active returns all runs in non-terminal states. func (r *Runs) Active(ctx context.Context) ([]model.Run, error) { rows, err := r.DB.QueryContext(ctx, ` diff --git a/internal/web/static/app.css b/internal/web/static/app.css index d8e896e..5d94577 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -822,59 +822,6 @@ body.bare main { max-width: none; } .log-line.log-debug { opacity: .6; } .log-line.log-hit { background: rgba(228,169,75,.08); } -.runs-sidebar { - background: var(--bg-elev); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 12px; - position: sticky; - top: 16px; - max-height: calc(100vh - 32px); - overflow-y: auto; -} -.runs-sidebar-heading { - margin: 0 0 10px; - font-size: 12px; - color: var(--text-dim); - text-transform: uppercase; - letter-spacing: .5px; - font-weight: 600; -} -.runs-sidebar-empty { color: var(--text-dim); font-size: 13px; margin: 0; } -.runs-sidebar-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; } -.runs-sidebar-item a { - display: grid; - grid-template-columns: 16px auto 1fr auto; - align-items: center; - gap: 8px; - padding: 6px 8px; - border-radius: 6px; - color: var(--text); - text-decoration: none; - font-size: 12px; -} -.runs-sidebar-item a:hover { background: var(--bg-elev-2); text-decoration: none; } -.runs-sidebar-active a { background: rgba(60,130,246,.12); border: 1px solid rgba(60,130,246,.5); } -.runs-sidebar-dot { - width: 14px; - height: 14px; - border-radius: 50%; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 9px; - font-weight: 700; - background: var(--bg-elev-2); - border: 1px solid var(--border); - color: var(--text-dim); -} -.runs-sidebar-dot-pass { background: var(--success); border-color: var(--success); color: #0b0d12; } -.runs-sidebar-dot-fail { background: var(--danger); border-color: var(--danger); color: #fff; } -.runs-sidebar-dot-active { background: var(--accent-strong); border-color: var(--accent); color: #fff; } -.runs-sidebar-id { font-family: var(--mono); font-weight: 600; } -.runs-sidebar-started { color: var(--text-dim); } -.runs-sidebar-duration { font-family: var(--mono); color: var(--text-dim); font-size: 11px; } - .btn-primary { background: var(--accent-strong); border-color: var(--accent-strong); @@ -888,55 +835,137 @@ body.bare main { max-width: none; } } .btn-danger:hover { background: rgba(229,100,102,.1); } -/* ---------- Dashboard tile mini run-view (Phase 3) ---------------- */ +/* ---------- Host page (/hosts/{id}) ------------------------------- */ -/* Small variant of stage-dot for the compact step list. Same colour - rules as the full-size pipeline dot so operators read one language - everywhere; only the geometry shrinks. */ +/* Small variant of stage-dot, reused by the runs-table stage-strip so + per-row progress reads with the same visual language as the pipeline + on the run page. Kept lean — no borders, no glyphs, just colour. */ .stage-dot-sm { - width: 14px; - height: 14px; - font-size: 9px; + width: 10px; + height: 10px; + font-size: 0; border-width: 1px; flex-shrink: 0; + border-radius: 50%; } -.tile-meta-row { - display: flex; - gap: 8px; - align-items: baseline; - font-size: 12px; - color: var(--text-dim); - padding: 4px 0 6px; -} -.tile-run-id { font-variant-numeric: tabular-nums; } -.tile-run-duration { margin-left: auto; font-variant-numeric: tabular-nums; } +.host-page { display: flex; flex-direction: column; gap: 12px; } -.tile-steplist { - list-style: none; - margin: 0 0 8px; - padding: 0; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 2px 10px; +.host-summary { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px; } -.tile-steplist .tile-step { +.host-summary-head { display: flex; align-items: center; - gap: 6px; - font-size: 11px; - line-height: 1.4; - color: var(--text-dim); - min-width: 0; + gap: 10px; + margin-bottom: 10px; } -.tile-steplist .tile-step-name { +.host-summary-name { margin: 0; font-size: 22px; font-weight: 600; } +.host-summary-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 6px 20px; + margin: 0 0 6px; + font-size: 13px; +} +.host-summary-meta dt { color: var(--text-dim); font-size: 11px; text-transform: uppercase; letter-spacing: .4px; } +.host-summary-meta dd { margin: 0; font-family: var(--mono); } +.host-summary-notes { margin-top: 8px; } +.host-summary-notes h3 { margin: 0 0 4px; font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .4px; } +.host-summary-spec summary { cursor: pointer; color: var(--text-dim); font-size: 12px; } +.host-summary-spec-yaml { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 10px; + font-family: var(--mono); + font-size: 12px; + overflow-x: auto; + margin: 6px 0 0; +} + +.host-actions { padding: 0; } +.host-actions-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; } +.host-nd-toggle { display: inline-flex; gap: 6px; align-items: center; color: var(--text-dim); font-size: 13px; } + +.in-flight-banner-wrap { display: contents; } +.in-flight-banner { + display: flex; + align-items: center; + gap: 10px; + background: rgba(60,130,246,.12); + border: 1px solid rgba(60,130,246,.5); + border-radius: var(--radius); + padding: 10px 14px; + color: var(--text); + text-decoration: none; + font-size: 14px; +} +.in-flight-banner:hover { background: rgba(60,130,246,.20); text-decoration: none; } +.in-flight-label { font-weight: 600; } +.in-flight-state { color: var(--text-dim); font-family: var(--mono); } +.in-flight-open { margin-left: auto; color: var(--accent); } + +.host-empty-state { + text-align: center; + background: var(--bg-elev); + border: 1px dashed var(--border); + border-radius: var(--radius); + padding: 40px 20px; +} +.host-empty-title { font-size: 18px; font-weight: 600; margin: 0 0 4px; } +.host-empty-sub { color: var(--text-dim); margin: 0 0 16px; font-size: 13px; } +.btn-primary.big { font-size: 15px; padding: 10px 20px; } + +.host-runs { } +.host-runs h2 { font-size: 14px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .4px; margin: 0 0 8px; } + +.runs-table { + width: 100%; + border-collapse: collapse; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + font-size: 13px; } -/* Passed/failed/running steps keep full-strength text so the eye jumps - to active work; pending/skipped fade back into the background. */ -.tile-step-passed .tile-step-name, -.tile-step-failed .tile-step-name, -.tile-step-running .tile-step-name { color: var(--text); } -.tile-step-skipped { opacity: .5; } +.runs-table thead th { + text-align: left; + padding: 8px 10px; + color: var(--text-dim); + font-size: 11px; + text-transform: uppercase; + letter-spacing: .4px; + font-weight: 600; + border-bottom: 1px solid var(--border); + background: var(--bg-elev-2); +} +.runs-table tbody td { + padding: 8px 10px; + border-top: 1px solid var(--border); + vertical-align: middle; +} +.runs-table tbody tr:first-child td { border-top: none; } +.runs-table tbody tr:hover { background: var(--bg-elev-2); } +.runs-row-live { background: rgba(60,130,246,.08); } +.runs-row-live:hover { background: rgba(60,130,246,.14); } +.runs-col-id a { font-family: var(--mono); font-weight: 600; color: var(--text); text-decoration: none; } +.runs-col-id a:hover { color: var(--accent); } +.runs-col-started, .runs-col-duration { color: var(--text-dim); font-family: var(--mono); white-space: nowrap; } +.runs-open-link { color: var(--accent); text-decoration: none; font-size: 12px; white-space: nowrap; } +.runs-open-link:hover { text-decoration: underline; } + +.stage-strip { + display: inline-flex; + gap: 3px; + align-items: center; +} + +/* ---------- Run page (/runs/{runID}) ------------------------------ */ + +.run-page { display: flex; flex-direction: column; gap: 12px; } +.run-body { display: flex; flex-direction: column; gap: 10px; } +.run-header-name { margin: 0; font-size: 20px; font-weight: 600; } diff --git a/internal/web/templates/dashboard.templ b/internal/web/templates/dashboard.templ index 052502a..b2fe522 100644 --- a/internal/web/templates/dashboard.templ +++ b/internal/web/templates/dashboard.templ @@ -9,14 +9,10 @@ import ( // TileData pairs a host with its latest run and the derived fields the // tile needs to render: spec-diff count (server-side diff result) and // the on-disk path to the hold-key artifact when the run is holding. -// LastSeenAt is the host-mode agent's most recent heartbeat. Stages is -// the list of canonical-order stage rows for Latest, used by HostTile -// to render the mini run-view; nil/empty for never-run hosts (a ghost -// dot strip is rendered from DefaultStageOrder). +// LastSeenAt is the host-mode agent's most recent heartbeat. type TileData struct { Host model.Host Latest *model.Run - Stages []model.Stage SpecDiffCritical int HoldKeyPath string LastSeenAt *time.Time diff --git a/internal/web/templates/dashboard_templ.go b/internal/web/templates/dashboard_templ.go index 638853b..32deb7f 100644 --- a/internal/web/templates/dashboard_templ.go +++ b/internal/web/templates/dashboard_templ.go @@ -17,14 +17,10 @@ import ( // TileData pairs a host with its latest run and the derived fields the // tile needs to render: spec-diff count (server-side diff result) and // the on-disk path to the hold-key artifact when the run is holding. -// LastSeenAt is the host-mode agent's most recent heartbeat. Stages is -// the list of canonical-order stage rows for Latest, used by HostTile -// to render the mini run-view; nil/empty for never-run hosts (a ghost -// dot strip is rendered from DefaultStageOrder). +// LastSeenAt is the host-mode agent's most recent heartbeat. type TileData struct { Host model.Host Latest *model.Run - Stages []model.Stage SpecDiffCritical int HoldKeyPath string LastSeenAt *time.Time diff --git a/internal/web/templates/host_detail.templ b/internal/web/templates/host_detail.templ deleted file mode 100644 index 68b6c85..0000000 --- a/internal/web/templates/host_detail.templ +++ /dev/null @@ -1,423 +0,0 @@ -package templates - -import ( - "bytes" - "context" - "fmt" - "time" - - "vetting/internal/model" - "vetting/internal/store" -) - -// HostDetailData is the full payload the detail handler hands to the -// HostDetail template. Tile carries host + viewed-run enrichment (same -// shape the dashboard tile uses), Stages/SpecDiffs/SubSteps drive the -// pipeline, diff list, and expanded step panel. History backs the runs -// sidebar (last 20, newest first). DefaultStepStage is the stage name -// whose
opens by default on page load — running → failed → -// Reporting. LogReplay is the pre-rendered history fragment produced -// by logs.Hub.Replay on the initial page render so the operator sees -// prior output without waiting for a fresh SSE event. -type HostDetailData struct { - Tile TileData - Stages []model.Stage - SpecDiffs []model.SpecDiff - SubSteps []model.SubStep - History []model.Run - DefaultStepStage string - LogReplay string - // LogReplayByStage is the pre-rendered log HTML grouped by stage - // name. Each ActiveStep panel picks its own bucket so the detail - // page doesn't fire nine disk scans per reload. The "" key holds - // orphan/framing lines (no stage set), surfaced under the "Run" - // pseudo-step at the top of the page. - LogReplayByStage map[string]string -} - -// HostDetail is the GitHub-Actions-style run view. Layout is: meta -// drawer (collapsed) → run header + actions → hold banner → horizontal -// pipeline → two-column body (active-step pane + runs sidebar) → spec -// diffs at the bottom. Each section keeps its own sse-swap target so -// live updates don't trigger whole-page reflows. -templ HostDetail(d HostDetailData) { - @Layout(d.Tile.Host.Name) { -
- - - @HostMetaDrawer(d) - - @DetailSummary(d) - @DetailActions(d) - @DetailHold(d) - - if d.Tile.Latest != nil { - @PipelineSection(d.Tile.Latest, BuildPipeline(d.Tile.Latest, d.Stages)) - } else { -
-

Pipeline

- @Pipeline(BuildPipeline(nil, nil)) -
- } - -
-
- if d.Tile.Latest != nil { - for _, stageName := range store.DefaultStageOrder { - @ActiveStep(ActiveStepData{ - RunID: d.Tile.Latest.ID, - Stage: stageForName(d.Stages, stageName), - SubSteps: SubStepsForStage(d.SubSteps, stageName), - LogReplay: d.LogReplayByStage[stageName], - Open: stageName == d.DefaultStepStage, - }) - } - } else { -

No run yet. Click Start vetting to begin.

- } -
- @RunsSidebar(d) -
- - @DetailSpecDiffs(d) -
- } -} - -// HostMetaDrawer is the collapsed "host details" block at the top of the -// page: MAC, WoL, last-seen, expected spec, and notes.
defaults -// to closed so the run itself stays above the fold; operators open it -// when they need the provisioning info. -templ HostMetaDrawer(d HostDetailData) { -
- - Host details - { lastSeenLabel(d.Tile.LastSeenAt) } - { d.Tile.Host.MAC } - -
-
-
MAC
-
{ d.Tile.Host.MAC }
-
-
-
WoL
-
{ fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort) }
-
-
- if d.Tile.Host.Notes != "" { -
-

Notes

-

{ d.Tile.Host.Notes }

-
- } -
-

Expected spec

-
{ d.Tile.Host.ExpectedSpecYAML }
-
-
-} - -// DetailSummary is the run header: host name on the left, run number, -// status icon, and elapsed/total duration. Keyed on host ID so the SSE -// event name is stable across run turnover. -templ DetailSummary(d HostDetailData) { -
-
-

{ d.Tile.Host.Name }

- if d.Tile.Latest != nil { - { fmt.Sprintf("run #%d", d.Tile.Latest.ID) } - } - { tileStatus(d.Tile.Latest) } - if d.Tile.Latest != nil { - { runDuration(d.Tile.Latest) } - } -
-
- if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" { - failed at { d.Tile.Latest.FailedStage } - } - if d.Tile.SpecDiffCritical > 0 { - { fmt.Sprintf("%d critical diff", d.Tile.SpecDiffCritical) } - } -
-
-} - -// DetailActions is the button row (Start / Cancel / Override / View -// report / Delete). Enabled/disabled state depends on the latest run's -// state and host heartbeat; both change live, so this section re-renders -// on every state change. Keyed on host ID — the actions exist even -// without a run. -templ DetailActions(d HostDetailData) { -
-
- if canStart(d.Tile) { -
- - -
- } else if canStartIfOnline(d.Tile.Latest) { - - } else { - - } - if canCancel(d.Tile.Latest) { -
- -
- } - if canOverrideWipe(d.Tile.Latest) { -
- -
- } - if hasReport(d.Tile.Latest) { - View report - } -
- -
-
-
-} - -// DetailSpecDiffs renders the "Spec diffs (N)" collapsible when a run -// exists; otherwise it emits a bare empty wrapper so a later SSE push -// after SpecValidate writes has a target to swap into. The wrapper is -// keyed on run ID because the diffs belong to a specific run — a new -// run publishes to a new event name, and the detail page navigates to -// the new target via outerHTML swap only when the whole DetailSpecDiffs -// section is re-rendered by a page reload. -templ DetailSpecDiffs(d HostDetailData) { - if d.Tile.Latest != nil { -
- if len(d.SpecDiffs) > 0 { -
-

Spec diffs ({ fmt.Sprintf("%d", len(d.SpecDiffs)) })

-
    - for _, diff := range d.SpecDiffs { -
  • -
    { diff.Field }
    -
    expected: { diff.Expected }
    -
    actual: { diff.Actual }
    -
  • - } -
-
- } -
- } -} - -// DetailHold renders the "Host is holding — SSH available" strip across -// the top when a run is in FailedHolding with an IP recorded. Otherwise -// it emits an empty wrapper so the first SSE push when the hold actually -// fires has a target. Keyed on run ID for the same reason as -// DetailSpecDiffs. -templ DetailHold(d HostDetailData) { - if d.Tile.Latest != nil { - if d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" { -
- Host is holding — SSH available: - { sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP) } -
- } else { -
- } - } -} - -// RunsSidebar is the right-rail history list: last 20 runs for this -// host, newest first. Each entry links back to /hosts/{id}?run=N for -// navigation into a past run. The row for the currently-viewed run is -// flagged so CSS can highlight it. -templ RunsSidebar(d HostDetailData) { - -} - -// RenderDetailSummaryString, RenderDetailActionsString, -// RenderDetailSpecDiffsString, RenderDetailHoldString each render one -// component to a string so the orchestrator can publish SSE fragments -// without importing the HTTP layer. Matches the RenderTileString / -// RenderPipelineString pattern. -func RenderDetailSummaryString(d HostDetailData) string { - var buf bytes.Buffer - _ = DetailSummary(d).Render(context.Background(), &buf) - return buf.String() -} - -func RenderDetailActionsString(d HostDetailData) string { - var buf bytes.Buffer - _ = DetailActions(d).Render(context.Background(), &buf) - return buf.String() -} - -func RenderDetailSpecDiffsString(d HostDetailData) string { - var buf bytes.Buffer - _ = DetailSpecDiffs(d).Render(context.Background(), &buf) - return buf.String() -} - -func RenderDetailHoldString(d HostDetailData) string { - var buf bytes.Buffer - _ = DetailHold(d).Render(context.Background(), &buf) - return buf.String() -} - -// hasCriticalDiff opens the spec-diff
by default when any -// diff is critical — operator shouldn't have to click to see the blocker. -func hasCriticalDiff(diffs []model.SpecDiff) bool { - for _, d := range diffs { - if d.Severity == "critical" && !d.Ignored { - return true - } - } - return false -} - -// stageForName returns the persisted Stage row for a given name, or a -// synthetic pending-state stub when no row has been seeded yet (e.g. -// the run is still in a pre-stage). Keeps the template free of nil -// checks and ghost logic — ActiveStep always gets a concrete Stage. -func stageForName(stages []model.Stage, name string) model.Stage { - for _, s := range stages { - if s.Name == name { - return s - } - } - return model.Stage{Name: name, State: model.StagePending} -} - -// runSidebarActiveClass marks the row for the currently-viewed run so -// CSS can highlight it. Empty string (no class added) when the row isn't -// the active one. -func runSidebarActiveClass(viewed *model.Run, rowID int64) string { - if viewed != nil && viewed.ID == rowID { - return "runs-sidebar-active" - } - return "" -} - -// runDuration formats the elapsed time for a run using the same buckets -// as stageDuration. In-flight runs clock from StartedAt to now so the -// header duration keeps updating on each SSE tick. -func runDuration(r *model.Run) string { - if r == nil || r.StartedAt.IsZero() { - return "" - } - end := time.Now() - if r.CompletedAt != nil { - end = *r.CompletedAt - } - d := end.Sub(r.StartedAt) - if d < 0 { - d = 0 - } - switch { - case d < time.Second: - return fmt.Sprintf("%dms", int(d/time.Millisecond)) - case d < 10*time.Second: - return fmt.Sprintf("%.1fs", d.Seconds()) - case d < time.Minute: - return fmt.Sprintf("%ds", int(d/time.Second)) - case d < time.Hour: - return fmt.Sprintf("%dm %ds", int(d/time.Minute), int((d%time.Minute)/time.Second)) - default: - return fmt.Sprintf("%dh %dm", int(d/time.Hour), int((d%time.Hour)/time.Minute)) - } -} - -// relativeTime renders a past time as "2m ago" / "1h ago" / "3d ago" -// for the runs-sidebar. Future times (clock skew on the host) render as -// "now" so the sidebar never shows nonsense. -func relativeTime(t time.Time) string { - if t.IsZero() { - return "" - } - d := time.Since(t) - if d < 0 { - return "now" - } - if d < time.Minute { - return "just now" - } - if d < time.Hour { - return fmt.Sprintf("%dm ago", int(d/time.Minute)) - } - if d < 24*time.Hour { - return fmt.Sprintf("%dh ago", int(d/time.Hour)) - } - return fmt.Sprintf("%dd ago", int(d/(24*time.Hour))) -} - -// runSidebarGlyph mirrors stageMarker for run-state: ✓ / ! / ● / –. -// Used inside the sidebar dot so the color + glyph carry redundant -// meaning. -func runSidebarGlyph(r *model.Run) string { - if r == nil { - return "" - } - switch r.State { - case model.StateCompleted: - return "✓" - case model.StateFailed, model.StateFailedHolding: - return "!" - case model.StateReleased, model.StateCancelled: - return "–" - } - if r.State.IsTerminal() { - return "" - } - return "●" -} diff --git a/internal/web/templates/host_detail_templ.go b/internal/web/templates/host_detail_templ.go deleted file mode 100644 index 7e69059..0000000 --- a/internal/web/templates/host_detail_templ.go +++ /dev/null @@ -1,1274 +0,0 @@ -// Code generated by templ - DO NOT EDIT. - -// templ: version: v0.3.1001 -package templates - -//lint:file-ignore SA4006 This context is only used if a nested component is present. - -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - -import ( - "bytes" - "context" - "fmt" - "time" - - "vetting/internal/model" - "vetting/internal/store" -) - -// HostDetailData is the full payload the detail handler hands to the -// HostDetail template. Tile carries host + viewed-run enrichment (same -// shape the dashboard tile uses), Stages/SpecDiffs/SubSteps drive the -// pipeline, diff list, and expanded step panel. History backs the runs -// sidebar (last 20, newest first). DefaultStepStage is the stage name -// whose
opens by default on page load — running → failed → -// Reporting. LogReplay is the pre-rendered history fragment produced -// by logs.Hub.Replay on the initial page render so the operator sees -// prior output without waiting for a fresh SSE event. -type HostDetailData struct { - Tile TileData - Stages []model.Stage - SpecDiffs []model.SpecDiff - SubSteps []model.SubStep - History []model.Run - DefaultStepStage string - LogReplay string - // LogReplayByStage is the pre-rendered log HTML grouped by stage - // name. Each ActiveStep panel picks its own bucket so the detail - // page doesn't fire nine disk scans per reload. The "" key holds - // orphan/framing lines (no stage set), surfaced under the "Run" - // pseudo-step at the top of the page. - LogReplayByStage map[string]string -} - -// HostDetail is the GitHub-Actions-style run view. Layout is: meta -// drawer (collapsed) → run header + actions → hold banner → horizontal -// pipeline → two-column body (active-step pane + runs sidebar) → spec -// diffs at the bottom. Each section keeps its own sse-swap target so -// live updates don't trigger whole-page reflows. -func HostDetail(d HostDetailData) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var1 := templ.GetChildren(ctx) - if templ_7745c5c3_Var1 == nil { - templ_7745c5c3_Var1 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = HostMetaDrawer(d).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = DetailSummary(d).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = DetailActions(d).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = DetailHold(d).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if d.Tile.Latest != nil { - templ_7745c5c3_Err = PipelineSection(d.Tile.Latest, BuildPipeline(d.Tile.Latest, d.Stages)).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

Pipeline

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = Pipeline(BuildPipeline(nil, nil)).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if d.Tile.Latest != nil { - for _, stageName := range store.DefaultStageOrder { - templ_7745c5c3_Err = ActiveStep(ActiveStepData{ - RunID: d.Tile.Latest.ID, - Stage: stageForName(d.Stages, stageName), - SubSteps: SubStepsForStage(d.SubSteps, stageName), - LogReplay: d.LogReplayByStage[stageName], - Open: stageName == d.DefaultStepStage, - }).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

No run yet. Click Start vetting to begin.

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = RunsSidebar(d).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = DetailSpecDiffs(d).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) - templ_7745c5c3_Err = Layout(d.Tile.Host.Name).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -// HostMetaDrawer is the collapsed "host details" block at the top of the -// page: MAC, WoL, last-seen, expected spec, and notes.
defaults -// to closed so the run itself stays above the fold; operators open it -// when they need the provisioning info. -func HostMetaDrawer(d HostDetailData) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var4 := templ.GetChildren(ctx) - if templ_7745c5c3_Var4 == nil { - templ_7745c5c3_Var4 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
Host details ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var5 = []any{"tile-last-seen", lastSeenClass(d.Tile.LastSeenAt)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(d.Tile.LastSeenAt)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 99, Col: 104} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.MAC) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 100, Col: 51} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
MAC
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.MAC) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 105, Col: 25} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
WoL
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 109, Col: 79} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if d.Tile.Host.Notes != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

Notes

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Notes) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 115, Col: 26} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

Expected spec

")
-		if templ_7745c5c3_Err != nil {
-			return templ_7745c5c3_Err
-		}
-		var templ_7745c5c3_Var12 string
-		templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.ExpectedSpecYAML)
-		if templ_7745c5c3_Err != nil {
-			return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 120, Col: 63}
-		}
-		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
-		if templ_7745c5c3_Err != nil {
-			return templ_7745c5c3_Err
-		}
-		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -// DetailSummary is the run header: host name on the left, run number, -// status icon, and elapsed/total duration. Keyed on host ID so the SSE -// event name is stable across run turnover. -func DetailSummary(d HostDetailData) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var13 := templ.GetChildren(ctx) - if templ_7745c5c3_Var13 == nil { - templ_7745c5c3_Var13 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var14 = []any{"run-header", "tile-" + tileMood(d.Tile.Latest)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var18 string - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 136, Col: 45} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if d.Tile.Latest != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var19 string - templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("run #%d", d.Tile.Latest.ID)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 138, Col: 71} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - var templ_7745c5c3_Var20 = []any{"run-status-badge", "run-status-" + tileMood(d.Tile.Latest)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var22 string - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(d.Tile.Latest)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 140, Col: 106} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if d.Tile.Latest != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var23 string - templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(runDuration(d.Tile.Latest)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 142, Col: 59} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "failed at ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var24 string - templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Latest.FailedStage) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 147, Col: 80} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if d.Tile.SpecDiffCritical > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var25 string - templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d critical diff", d.Tile.SpecDiffCritical)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 150, Col: 90} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -// DetailActions is the button row (Start / Cancel / Override / View -// report / Delete). Enabled/disabled state depends on the latest run's -// state and host heartbeat; both change live, so this section re-renders -// on every state change. Keyed on host ID — the actions exist even -// without a run. -func DetailActions(d HostDetailData) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var26 := templ.GetChildren(ctx) - if templ_7745c5c3_Var26 == nil { - templ_7745c5c3_Var26 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if canStart(d.Tile) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else if canStartIfOnline(d.Tile.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if canCancel(d.Tile.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if canOverrideWipe(d.Tile.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if hasReport(d.Tile.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "View report") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -// DetailSpecDiffs renders the "Spec diffs (N)" collapsible when a run -// exists; otherwise it emits a bare empty wrapper so a later SSE push -// after SpecValidate writes has a target to swap into. The wrapper is -// keyed on run ID because the diffs belong to a specific run — a new -// run publishes to a new event name, and the detail page navigates to -// the new target via outerHTML swap only when the whole DetailSpecDiffs -// section is re-rendered by a page reload. -func DetailSpecDiffs(d HostDetailData) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var34 := templ.GetChildren(ctx) - if templ_7745c5c3_Var34 == nil { - templ_7745c5c3_Var34 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - if d.Tile.Latest != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if len(d.SpecDiffs) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "

Spec diffs (") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var37 string - templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs))) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 219, Col: 67} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, ")

    ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - for _, diff := range d.SpecDiffs { - var templ_7745c5c3_Var38 = []any{"diff-row", "diff-" + diff.Severity} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var38...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
  • ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var40 string - templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 223, Col: 44} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
    expected: ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var41 string - templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 224, Col: 66} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
    actual: ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var42 string - templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 225, Col: 60} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
  • ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - return nil - }) -} - -// DetailHold renders the "Host is holding — SSH available" strip across -// the top when a run is in FailedHolding with an IP recorded. Otherwise -// it emits an empty wrapper so the first SSE push when the hold actually -// fires has a target. Keyed on run ID for the same reason as -// DetailSpecDiffs. -func DetailHold(d HostDetailData) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var43 := templ.GetChildren(ctx) - if templ_7745c5c3_Var43 == nil { - templ_7745c5c3_Var43 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - if d.Tile.Latest != nil { - if d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
Host is holding — SSH available: ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var46 string - templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 250, Col: 84} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - } - return nil - }) -} - -// RunsSidebar is the right-rail history list: last 20 runs for this -// host, newest first. Each entry links back to /hosts/{id}?run=N for -// navigation into a past run. The row for the currently-viewed run is -// flagged so CSS can highlight it. -func RunsSidebar(d HostDetailData) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var49 := templ.GetChildren(ctx) - if templ_7745c5c3_Var49 == nil { - templ_7745c5c3_Var49 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -// RenderDetailSummaryString, RenderDetailActionsString, -// RenderDetailSpecDiffsString, RenderDetailHoldString each render one -// component to a string so the orchestrator can publish SSE fragments -// without importing the HTTP layer. Matches the RenderTileString / -// RenderPipelineString pattern. -func RenderDetailSummaryString(d HostDetailData) string { - var buf bytes.Buffer - _ = DetailSummary(d).Render(context.Background(), &buf) - return buf.String() -} - -func RenderDetailActionsString(d HostDetailData) string { - var buf bytes.Buffer - _ = DetailActions(d).Render(context.Background(), &buf) - return buf.String() -} - -func RenderDetailSpecDiffsString(d HostDetailData) string { - var buf bytes.Buffer - _ = DetailSpecDiffs(d).Render(context.Background(), &buf) - return buf.String() -} - -func RenderDetailHoldString(d HostDetailData) string { - var buf bytes.Buffer - _ = DetailHold(d).Render(context.Background(), &buf) - return buf.String() -} - -// hasCriticalDiff opens the spec-diff
by default when any -// diff is critical — operator shouldn't have to click to see the blocker. -func hasCriticalDiff(diffs []model.SpecDiff) bool { - for _, d := range diffs { - if d.Severity == "critical" && !d.Ignored { - return true - } - } - return false -} - -// stageForName returns the persisted Stage row for a given name, or a -// synthetic pending-state stub when no row has been seeded yet (e.g. -// the run is still in a pre-stage). Keeps the template free of nil -// checks and ghost logic — ActiveStep always gets a concrete Stage. -func stageForName(stages []model.Stage, name string) model.Stage { - for _, s := range stages { - if s.Name == name { - return s - } - } - return model.Stage{Name: name, State: model.StagePending} -} - -// runSidebarActiveClass marks the row for the currently-viewed run so -// CSS can highlight it. Empty string (no class added) when the row isn't -// the active one. -func runSidebarActiveClass(viewed *model.Run, rowID int64) string { - if viewed != nil && viewed.ID == rowID { - return "runs-sidebar-active" - } - return "" -} - -// runDuration formats the elapsed time for a run using the same buckets -// as stageDuration. In-flight runs clock from StartedAt to now so the -// header duration keeps updating on each SSE tick. -func runDuration(r *model.Run) string { - if r == nil || r.StartedAt.IsZero() { - return "" - } - end := time.Now() - if r.CompletedAt != nil { - end = *r.CompletedAt - } - d := end.Sub(r.StartedAt) - if d < 0 { - d = 0 - } - switch { - case d < time.Second: - return fmt.Sprintf("%dms", int(d/time.Millisecond)) - case d < 10*time.Second: - return fmt.Sprintf("%.1fs", d.Seconds()) - case d < time.Minute: - return fmt.Sprintf("%ds", int(d/time.Second)) - case d < time.Hour: - return fmt.Sprintf("%dm %ds", int(d/time.Minute), int((d%time.Minute)/time.Second)) - default: - return fmt.Sprintf("%dh %dm", int(d/time.Hour), int((d%time.Hour)/time.Minute)) - } -} - -// relativeTime renders a past time as "2m ago" / "1h ago" / "3d ago" -// for the runs-sidebar. Future times (clock skew on the host) render as -// "now" so the sidebar never shows nonsense. -func relativeTime(t time.Time) string { - if t.IsZero() { - return "" - } - d := time.Since(t) - if d < 0 { - return "now" - } - if d < time.Minute { - return "just now" - } - if d < time.Hour { - return fmt.Sprintf("%dm ago", int(d/time.Minute)) - } - if d < 24*time.Hour { - return fmt.Sprintf("%dh ago", int(d/time.Hour)) - } - return fmt.Sprintf("%dd ago", int(d/(24*time.Hour))) -} - -// runSidebarGlyph mirrors stageMarker for run-state: ✓ / ! / ● / –. -// Used inside the sidebar dot so the color + glyph carry redundant -// meaning. -func runSidebarGlyph(r *model.Run) string { - if r == nil { - return "" - } - switch r.State { - case model.StateCompleted: - return "✓" - case model.StateFailed, model.StateFailedHolding: - return "!" - case model.StateReleased, model.StateCancelled: - return "–" - } - if r.State.IsTerminal() { - return "" - } - return "●" -} - -var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/host_detail_test.go b/internal/web/templates/host_detail_test.go deleted file mode 100644 index f1691fb..0000000 --- a/internal/web/templates/host_detail_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package templates - -import ( - "strings" - "testing" - - "vetting/internal/model" -) - -// TestDetailSummary_RootAttrs asserts the root
carries the -// stable id and sse-swap target. Successive SSE swaps replace the -// outer element, so without these attributes the second swap would -// have nothing to target. -func TestDetailSummary_RootAttrs(t *testing.T) { - d := HostDetailData{ - Tile: TileData{ - Host: model.Host{ID: 7, Name: "alpha", MAC: "aa:bb:cc:dd:ee:ff"}, - }, - } - html := RenderDetailSummaryString(d) - for _, want := range []string{ - `id="detail-summary-7"`, - `sse-swap="detail-summary-7"`, - `hx-swap="outerHTML"`, - } { - if !strings.Contains(html, want) { - t.Errorf("DetailSummary missing %q in:\n%s", want, html) - } - } -} - -func TestDetailActions_RootAttrs(t *testing.T) { - d := HostDetailData{ - Tile: TileData{ - Host: model.Host{ID: 7, Name: "alpha", MAC: "aa:bb:cc:dd:ee:ff"}, - }, - } - html := RenderDetailActionsString(d) - for _, want := range []string{ - `id="detail-actions-7"`, - `sse-swap="detail-actions-7"`, - `hx-swap="outerHTML"`, - } { - if !strings.Contains(html, want) { - t.Errorf("DetailActions missing %q in:\n%s", want, html) - } - } -} - -// TestDetailSpecDiffs_EmptyWrapper: when a run exists but has no diffs, -// the
wrapper still renders so a later SSE push has a target. -// Without this, the very first SpecValidate diff write would have no -// DOM element to swap into. -func TestDetailSpecDiffs_EmptyWrapper(t *testing.T) { - d := HostDetailData{ - Tile: TileData{ - Host: model.Host{ID: 7}, - Latest: &model.Run{ID: 42}, - }, - } - html := RenderDetailSpecDiffsString(d) - for _, want := range []string{ - `id="detail-specdiffs-42"`, - `sse-swap="detail-specdiffs-42"`, - } { - if !strings.Contains(html, want) { - t.Errorf("DetailSpecDiffs missing %q in empty state:\n%s", want, html) - } - } - if strings.Contains(html, ":\n%s", html) - } -} - -// TestDetailHold_EmptyWrapper: same rationale as specdiffs — the -// section wrapper is always present when a run exists so the first -// hold event has a target. -func TestDetailHold_EmptyWrapper(t *testing.T) { - d := HostDetailData{ - Tile: TileData{ - Host: model.Host{ID: 7}, - Latest: &model.Run{ID: 42, State: model.StateInventoryCheck}, - }, - } - html := RenderDetailHoldString(d) - for _, want := range []string{ - `id="detail-hold-42"`, - `sse-swap="detail-hold-42"`, - } { - if !strings.Contains(html, want) { - t.Errorf("DetailHold missing %q in empty state:\n%s", want, html) - } - } - if strings.Contains(html, "SSH available") { - t.Errorf("DetailHold non-holding state must not render SSH block:\n%s", html) - } -} - -// TestDetailHold_HoldingRendersSSH: once the run enters FailedHolding -// with an IP, the wrapper renders the ssh invocation. -func TestDetailHold_HoldingRendersSSH(t *testing.T) { - d := HostDetailData{ - Tile: TileData{ - Host: model.Host{ID: 7}, - HoldKeyPath: "/tmp/hold.key", - Latest: &model.Run{ - ID: 42, - State: model.StateFailedHolding, - HoldIP: "10.0.0.7", - }, - }, - } - html := RenderDetailHoldString(d) - if !strings.Contains(html, "ssh -i /tmp/hold.key root@10.0.0.7") { - t.Errorf("DetailHold missing ssh invocation:\n%s", html) - } -} diff --git a/internal/web/templates/host_page.templ b/internal/web/templates/host_page.templ new file mode 100644 index 0000000..5e29e50 --- /dev/null +++ b/internal/web/templates/host_page.templ @@ -0,0 +1,365 @@ +package templates + +import ( + "bytes" + "context" + "fmt" + "time" + + "vetting/internal/model" + "vetting/internal/store" +) + +// HostPageData is the payload HostPage renders. Host + LastSeenAt drive +// the summary drawer; Runs is the full newest-first run list for this +// host; ActiveRun is the non-terminal run (if any) that fills the sticky +// in-flight banner and highlights one row in the runs table; RunStages +// maps runID → stage rows so each row can paint its own 9-dot strip +// without a per-render query ladder in the template. +type HostPageData struct { + Host model.Host + LastSeenAt *time.Time + Runs []model.Run + ActiveRun *model.Run + RunStages map[int64][]model.Stage +} + +// HostPage is the host-focused URL: summary + actions + in-flight banner +// + runs table. Everything run-specific (pipeline, logs, sub-steps, spec +// diffs, hold banner) lives on /runs/{runID} instead. SSE targets are +// scoped per region so live tile refreshes don't reflow the whole page. +templ HostPage(d HostPageData) { + @Layout(d.Host.Name) { +
+ + + @HostSummary(d) + @HostActions(d) + @InFlightBanner(d) + + if len(d.Runs) == 0 { + @HostEmptyState(d) + } else { + @RunsTable(d) + } +
+ } +} + +// HostSummary is the compact meta card at the top of the host page: +// hostname, last-seen chip, MAC, WoL target, expected spec (collapsed). +// SSE-swap target so an operator edit / heartbeat arriving mid-view +// updates the card without a reload. +templ HostSummary(d HostPageData) { +
+
+

{ d.Host.Name }

+ { lastSeenLabel(d.LastSeenAt) } +
+
+
+
MAC
+
{ d.Host.MAC }
+
+
+
WoL
+
{ fmt.Sprintf("%s:%d", d.Host.WoLBroadcastIP, d.Host.WoLPort) }
+
+
+ if d.Host.Notes != "" { +
+

Notes

+

{ d.Host.Notes }

+
+ } +
+ Expected spec +
{ d.Host.ExpectedSpecYAML }
+
+
+} + +// HostActions is the primary-action row: Start vetting (enabled only when +// no active run AND host is heartbeating), Delete host. Run-level actions +// (Cancel / Override / View report) live on the run page — the host page +// only exposes things scoped to the host itself. +templ HostActions(d HostPageData) { +
+
+ if hostCanStart(d) { +
+ + +
+ } else if hostCanStartIfOnline(d) { + + } else { + + } +
+ +
+
+
+} + +// InFlightBanner is the sticky "Run #N in progress — open →" strip that +// shows only when an active (non-terminal) run exists. SSE target so a +// run starting or ending flips the banner live. +templ InFlightBanner(d HostPageData) { +
+ if d.ActiveRun != nil { + + Run #{ fmt.Sprintf("%d", d.ActiveRun.ID) } in progress — + { tileStatus(d.ActiveRun) } + open → + + } +
+} + +// HostEmptyState replaces the runs table with a big call-to-action when +// this host has never had a run. Only renders when the host is both +// reachable AND has no runs — the standard "Run in flight"-ish disabled +// button from HostActions handles the other corners. +templ HostEmptyState(d HostPageData) { +
+

No runs yet.

+

Kick off the first vetting run whenever the host is heartbeating.

+ if hostCanStart(d) { +
+ +
+ } else { + + } +
+} + +// RunsTable is one row per run, newest first. Each row carries its own +// SSE-swap target so live state changes (a running row flipping to +// passed) update one without re-rendering the whole table. +templ RunsTable(d HostPageData) { +
+

Runs

+ + + + + + + + + + + + + for _, r := range d.Runs { + @RunRow(RunRowData{ + Run: r, + Stages: d.RunStages[r.ID], + Live: d.ActiveRun != nil && d.ActiveRun.ID == r.ID, + }) + } + +
RunStateStartedDurationStages
+
+} + +// RunRowData is a single row's payload. Live is true for the currently +// non-terminal run so CSS can highlight it at the top of the table. +type RunRowData struct { + Run model.Run + Stages []model.Stage + Live bool +} + +// RunRow renders one keyed by runrow-{runID}. State changes fire +// runrow-{runID} from the orchestrator so the single row re-renders with +// its updated state + stage-strip without reloading the host page. +templ RunRow(d RunRowData) { + + + { fmt.Sprintf("#%d", d.Run.ID) } + + + { tileStatus(&d.Run) } + + { relativeTime(d.Run.StartedAt) } + { runDuration(&d.Run) } + +
+ for _, name := range store.DefaultStageOrder { + {{ st := stageForName(d.Stages, name) }} + + } +
+ + + open → + + +} + +// runRowLiveClass tags the currently non-terminal run so CSS can +// highlight it. Empty string for every other row. +func runRowLiveClass(live bool) string { + if live { + return "runs-row-live" + } + return "" +} + +// hostCanStart is the host-page analogue of canStart. Guards the Start +// button on two things: there's no active run, AND the host is currently +// heartbeating. Mirrors the StartRun handler's preflight so the button +// never offers a click the server rejects. +func hostCanStart(d HostPageData) bool { + if !hostCanStartIfOnline(d) { + return false + } + if d.LastSeenAt == nil { + return false + } + return time.Since(*d.LastSeenAt) <= 60*time.Second +} + +// hostCanStartIfOnline is the run-state half of hostCanStart, split out +// so HostActions can distinguish "run in flight" (no button) from "run +// is done / no run yet but host is offline" (disabled button). +func hostCanStartIfOnline(d HostPageData) bool { + return d.ActiveRun == nil +} + +// runDuration formats the elapsed time for a run using the same buckets +// as stageDuration. In-flight runs clock from StartedAt to now so the +// run-page header + runs-table row keep ticking on each SSE push. +func runDuration(r *model.Run) string { + if r == nil || r.StartedAt.IsZero() { + return "" + } + end := time.Now() + if r.CompletedAt != nil { + end = *r.CompletedAt + } + d := end.Sub(r.StartedAt) + if d < 0 { + d = 0 + } + switch { + case d < time.Second: + return fmt.Sprintf("%dms", int(d/time.Millisecond)) + case d < 10*time.Second: + return fmt.Sprintf("%.1fs", d.Seconds()) + case d < time.Minute: + return fmt.Sprintf("%ds", int(d/time.Second)) + case d < time.Hour: + return fmt.Sprintf("%dm %ds", int(d/time.Minute), int((d%time.Minute)/time.Second)) + default: + return fmt.Sprintf("%dh %dm", int(d/time.Hour), int((d%time.Hour)/time.Minute)) + } +} + +// stageForName returns the persisted Stage row for a given name, or a +// synthetic pending-state stub when no row has been seeded yet (e.g. +// a run still in a pre-stage). Keeps the template free of nil checks — +// the caller always gets a concrete Stage. +func stageForName(stages []model.Stage, name string) model.Stage { + for _, s := range stages { + if s.Name == name { + return s + } + } + return model.Stage{Name: name, State: model.StagePending} +} + +// hasCriticalDiff opens the spec-diff
by default when any diff +// is critical — operator shouldn't have to click to see the blocker. +func hasCriticalDiff(diffs []model.SpecDiff) bool { + for _, d := range diffs { + if d.Severity == "critical" && !d.Ignored { + return true + } + } + return false +} + +// relativeTime renders a past time as "2m ago" / "1h ago" / "3d ago". +// Future times (clock skew) render as "now" so the runs table never +// shows nonsense when a host's clock is ahead of the orchestrator. +func relativeTime(t time.Time) string { + if t.IsZero() { + return "" + } + d := time.Since(t) + if d < 0 { + return "now" + } + if d < time.Minute { + return "just now" + } + if d < time.Hour { + return fmt.Sprintf("%dm ago", int(d/time.Minute)) + } + if d < 24*time.Hour { + return fmt.Sprintf("%dh ago", int(d/time.Hour)) + } + return fmt.Sprintf("%dd ago", int(d/(24*time.Hour))) +} + +// RenderHostSummaryString, RenderHostActionsString, and +// RenderInFlightBannerString render one region to a string for the +// orchestrator's SSE publish path. Matches the RenderTileString pattern. +func RenderHostSummaryString(d HostPageData) string { + var buf bytes.Buffer + _ = HostSummary(d).Render(context.Background(), &buf) + return buf.String() +} + +func RenderHostActionsString(d HostPageData) string { + var buf bytes.Buffer + _ = HostActions(d).Render(context.Background(), &buf) + return buf.String() +} + +func RenderInFlightBannerString(d HostPageData) string { + var buf bytes.Buffer + _ = InFlightBanner(d).Render(context.Background(), &buf) + return buf.String() +} + +// RenderRunRowString renders one row for the runs table over SSE when +// a run's state changes. The orchestrator fires runrow-{runID} at every +// site that already fires tile-{hostID} + pipeline-{runID}. +func RenderRunRowString(d RunRowData) string { + var buf bytes.Buffer + _ = RunRow(d).Render(context.Background(), &buf) + return buf.String() +} diff --git a/internal/web/templates/host_page_templ.go b/internal/web/templates/host_page_templ.go new file mode 100644 index 0000000..cfc9c85 --- /dev/null +++ b/internal/web/templates/host_page_templ.go @@ -0,0 +1,976 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "bytes" + "context" + "fmt" + "time" + + "vetting/internal/model" + "vetting/internal/store" +) + +// HostPageData is the payload HostPage renders. Host + LastSeenAt drive +// the summary drawer; Runs is the full newest-first run list for this +// host; ActiveRun is the non-terminal run (if any) that fills the sticky +// in-flight banner and highlights one row in the runs table; RunStages +// maps runID → stage rows so each row can paint its own 9-dot strip +// without a per-render query ladder in the template. +type HostPageData struct { + Host model.Host + LastSeenAt *time.Time + Runs []model.Run + ActiveRun *model.Run + RunStages map[int64][]model.Stage +} + +// HostPage is the host-focused URL: summary + actions + in-flight banner +// + runs table. Everything run-specific (pipeline, logs, sub-steps, spec +// diffs, hold banner) lives on /runs/{runID} instead. SSE targets are +// scoped per region so live tile refreshes don't reflow the whole page. +func HostPage(d HostPageData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = HostSummary(d).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = HostActions(d).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = InFlightBanner(d).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(d.Runs) == 0 { + templ_7745c5c3_Err = HostEmptyState(d).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = RunsTable(d).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout(d.Host.Name).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// HostSummary is the compact meta card at the top of the host page: +// hostname, last-seen chip, MAC, WoL target, expected spec (collapsed). +// SSE-swap target so an operator edit / heartbeat arriving mid-view +// updates the card without a reload. +func HostSummary(d HostPageData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var4 := templ.GetChildren(ctx) + if templ_7745c5c3_Var4 == nil { + templ_7745c5c3_Var4 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(d.Host.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 65, Col: 46} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 = []any{"tile-last-seen", lastSeenClass(d.LastSeenAt)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(d.LastSeenAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 66, Col: 94} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
MAC
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.Host.MAC) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 71, Col: 20} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
WoL
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s:%d", d.Host.WoLBroadcastIP, d.Host.WoLPort)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 75, Col: 69} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Host.Notes != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

Notes

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.Host.Notes) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 81, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
Expected spec
")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		var templ_7745c5c3_Var14 string
+		templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(d.Host.ExpectedSpecYAML)
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 86, Col: 64}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// HostActions is the primary-action row: Start vetting (enabled only when +// no active run AND host is heartbeating), Delete host. Run-level actions +// (Cancel / Override / View report) live on the run page — the host page +// only exposes things scoped to the host itself. +func HostActions(d HostPageData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var15 := templ.GetChildren(ctx) + if templ_7745c5c3_Var15 == nil { + templ_7745c5c3_Var15 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if hostCanStart(d) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if hostCanStartIfOnline(d) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// InFlightBanner is the sticky "Run #N in progress — open →" strip that +// shows only when an active (non-terminal) run exists. SSE target so a +// run starting or ending flips the banner live. +func InFlightBanner(d HostPageData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var20 := templ.GetChildren(ctx) + if templ_7745c5c3_Var20 == nil { + templ_7745c5c3_Var20 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.ActiveRun != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "Run #") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ActiveRun.ID)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 135, Col: 74} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " in progress — ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(d.ActiveRun)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 136, Col: 59} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " open →") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// HostEmptyState replaces the runs table with a big call-to-action when +// this host has never had a run. Only renders when the host is both +// reachable AND has no runs — the standard "Run in flight"-ish disabled +// button from HostActions handles the other corners. +func HostEmptyState(d HostPageData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var26 := templ.GetChildren(ctx) + if templ_7745c5c3_Var26 == nil { + templ_7745c5c3_Var26 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "

No runs yet.

Kick off the first vetting run whenever the host is heartbeating.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if hostCanStart(d) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// RunsTable is one row per run, newest first. Each row carries its own +// SSE-swap target so live state changes (a running row flipping to +// passed) update one without re-rendering the whole table. +func RunsTable(d HostPageData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var28 := templ.GetChildren(ctx) + if templ_7745c5c3_Var28 == nil { + templ_7745c5c3_Var28 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "

Runs

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, r := range d.Runs { + templ_7745c5c3_Err = RunRow(RunRowData{ + Run: r, + Stages: d.RunStages[r.ID], + Live: d.ActiveRun != nil && d.ActiveRun.ID == r.ID, + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
RunStateStartedDurationStages
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// RunRowData is a single row's payload. Live is true for the currently +// non-terminal run so CSS can highlight it at the top of the table. +type RunRowData struct { + Run model.Run + Stages []model.Stage + Live bool +} + +// RunRow renders one keyed by runrow-{runID}. State changes fire +// runrow-{runID} from the orchestrator so the single row re-renders with +// its updated state + stage-strip without reloading the host page. +func RunRow(d RunRowData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var29 := templ.GetChildren(ctx) + if templ_7745c5c3_Var29 == nil { + templ_7745c5c3_Var29 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + var templ_7745c5c3_Var30 = []any{"runs-row", "runs-row-" + tileMood(&d.Run), runRowLiveClass(d.Live)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var30...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("#%d", d.Run.ID)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 210, Col: 94} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var36 = []any{"run-status-badge", "run-status-" + tileMood(&d.Run)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var36...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var38 string + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(&d.Run)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 213, Col: 92} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var39 string + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(relativeTime(d.Run.StartedAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 215, Col: 62} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var40 string + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(runDuration(&d.Run)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 216, Col: 53} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, name := range store.DefaultStageOrder { + st := stageForName(d.Stages, name) + var templ_7745c5c3_Var41 = []any{"stage-dot", "stage-dot-sm", "stage-dot-" + string(st.State)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var41...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
open →") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// runRowLiveClass tags the currently non-terminal run so CSS can +// highlight it. Empty string for every other row. +func runRowLiveClass(live bool) string { + if live { + return "runs-row-live" + } + return "" +} + +// hostCanStart is the host-page analogue of canStart. Guards the Start +// button on two things: there's no active run, AND the host is currently +// heartbeating. Mirrors the StartRun handler's preflight so the button +// never offers a click the server rejects. +func hostCanStart(d HostPageData) bool { + if !hostCanStartIfOnline(d) { + return false + } + if d.LastSeenAt == nil { + return false + } + return time.Since(*d.LastSeenAt) <= 60*time.Second +} + +// hostCanStartIfOnline is the run-state half of hostCanStart, split out +// so HostActions can distinguish "run in flight" (no button) from "run +// is done / no run yet but host is offline" (disabled button). +func hostCanStartIfOnline(d HostPageData) bool { + return d.ActiveRun == nil +} + +// runDuration formats the elapsed time for a run using the same buckets +// as stageDuration. In-flight runs clock from StartedAt to now so the +// run-page header + runs-table row keep ticking on each SSE push. +func runDuration(r *model.Run) string { + if r == nil || r.StartedAt.IsZero() { + return "" + } + end := time.Now() + if r.CompletedAt != nil { + end = *r.CompletedAt + } + d := end.Sub(r.StartedAt) + if d < 0 { + d = 0 + } + switch { + case d < time.Second: + return fmt.Sprintf("%dms", int(d/time.Millisecond)) + case d < 10*time.Second: + return fmt.Sprintf("%.1fs", d.Seconds()) + case d < time.Minute: + return fmt.Sprintf("%ds", int(d/time.Second)) + case d < time.Hour: + return fmt.Sprintf("%dm %ds", int(d/time.Minute), int((d%time.Minute)/time.Second)) + default: + return fmt.Sprintf("%dh %dm", int(d/time.Hour), int((d%time.Hour)/time.Minute)) + } +} + +// stageForName returns the persisted Stage row for a given name, or a +// synthetic pending-state stub when no row has been seeded yet (e.g. +// a run still in a pre-stage). Keeps the template free of nil checks — +// the caller always gets a concrete Stage. +func stageForName(stages []model.Stage, name string) model.Stage { + for _, s := range stages { + if s.Name == name { + return s + } + } + return model.Stage{Name: name, State: model.StagePending} +} + +// hasCriticalDiff opens the spec-diff
by default when any diff +// is critical — operator shouldn't have to click to see the blocker. +func hasCriticalDiff(diffs []model.SpecDiff) bool { + for _, d := range diffs { + if d.Severity == "critical" && !d.Ignored { + return true + } + } + return false +} + +// relativeTime renders a past time as "2m ago" / "1h ago" / "3d ago". +// Future times (clock skew) render as "now" so the runs table never +// shows nonsense when a host's clock is ahead of the orchestrator. +func relativeTime(t time.Time) string { + if t.IsZero() { + return "" + } + d := time.Since(t) + if d < 0 { + return "now" + } + if d < time.Minute { + return "just now" + } + if d < time.Hour { + return fmt.Sprintf("%dm ago", int(d/time.Minute)) + } + if d < 24*time.Hour { + return fmt.Sprintf("%dh ago", int(d/time.Hour)) + } + return fmt.Sprintf("%dd ago", int(d/(24*time.Hour))) +} + +// RenderHostSummaryString, RenderHostActionsString, and +// RenderInFlightBannerString render one region to a string for the +// orchestrator's SSE publish path. Matches the RenderTileString pattern. +func RenderHostSummaryString(d HostPageData) string { + var buf bytes.Buffer + _ = HostSummary(d).Render(context.Background(), &buf) + return buf.String() +} + +func RenderHostActionsString(d HostPageData) string { + var buf bytes.Buffer + _ = HostActions(d).Render(context.Background(), &buf) + return buf.String() +} + +func RenderInFlightBannerString(d HostPageData) string { + var buf bytes.Buffer + _ = InFlightBanner(d).Render(context.Background(), &buf) + return buf.String() +} + +// RenderRunRowString renders one row for the runs table over SSE when +// a run's state changes. The orchestrator fires runrow-{runID} at every +// site that already fires tile-{hostID} + pipeline-{runID}. +func RenderRunRowString(d RunRowData) string { + var buf bytes.Buffer + _ = RunRow(d).Render(context.Background(), &buf) + return buf.String() +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/host_tile.templ b/internal/web/templates/host_tile.templ index 33e38d3..d4ac3c4 100644 --- a/internal/web/templates/host_tile.templ +++ b/internal/web/templates/host_tile.templ @@ -7,16 +7,13 @@ import ( "time" "vetting/internal/model" - "vetting/internal/store" ) -// HostTile renders a single dashboard card as a mini run-view. The whole -// tile is a link to /hosts/{id} (via a CSS-overlay ) — every control -// beyond the one primary action lives on the detail page. It's the SSE- -// swap target for per-host tile refreshes (`tile-N`). The step list is -// a compact vertical strip of the 9 canonical stages with just a -// coloured dot per stage; operators can read run health at a glance -// across the whole dashboard without drilling in. +// HostTile renders a single dashboard card: hostname, heartbeat badge, +// latest run status, and the primary action (Start / Cancel / View +// report). The whole tile is a link to /hosts/{id} via a CSS-overlay +// ; every deeper control lives on the host page or the run page. +// It's the SSE-swap target for per-host tile refreshes (`tile-N`). templ HostTile(t TileData) {
{ tileStatus(t.Latest) }
- if t.Latest != nil { -
- { fmt.Sprintf("#%d", t.Latest.ID) } - { runDuration(t.Latest) } -
- } -
    - for _, name := range store.DefaultStageOrder { - @tileStep(stageForName(t.Stages, name)) - } -
if canStart(t) {
@@ -65,17 +51,6 @@ templ HostTile(t TileData) { } -// tileStep renders one entry of the tile's mini step-list: a small -// coloured dot plus the short stage name. Kept as its own templ so the -// markup stays consistent with the detail page's larger stage-dot -// elements (same class prefix, different size via the `-sm` modifier). -templ tileStep(s model.Stage) { -
  • - { stageMarker(string(s.State)) } - { s.Name } -
  • -} - func canOverrideWipe(r *model.Run) bool { if r == nil { return false diff --git a/internal/web/templates/host_tile_templ.go b/internal/web/templates/host_tile_templ.go index 620b470..c3663b0 100644 --- a/internal/web/templates/host_tile_templ.go +++ b/internal/web/templates/host_tile_templ.go @@ -15,16 +15,13 @@ import ( "time" "vetting/internal/model" - "vetting/internal/store" ) -// HostTile renders a single dashboard card as a mini run-view. The whole -// tile is a link to /hosts/{id} (via a CSS-overlay
    ) — every control -// beyond the one primary action lives on the detail page. It's the SSE- -// swap target for per-host tile refreshes (`tile-N`). The step list is -// a compact vertical strip of the 9 canonical stages with just a -// coloured dot per stage; operators can read run health at a glance -// across the whole dashboard without drilling in. +// HostTile renders a single dashboard card: hostname, heartbeat badge, +// latest run status, and the primary action (Start / Cancel / View +// report). The whole tile is a link to /hosts/{id} via a CSS-overlay +// ; every deeper control lives on the host page or the run page. +// It's the SSE-swap target for per-host tile refreshes (`tile-N`). func HostTile(t TileData) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context @@ -58,7 +55,7 @@ func HostTile(t TileData) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("host-%d", t.Host.ID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 22, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 19, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -84,7 +81,7 @@ func HostTile(t TileData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("tile-%d", t.Host.ID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 21, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -97,7 +94,7 @@ func HostTile(t TileData) templ.Component { var templ_7745c5c3_Var6 templ.SafeURL templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d", t.Host.ID))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 27, Col: 80} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 80} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -110,7 +107,7 @@ func HostTile(t TileData) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs("Open " + t.Host.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 27, Col: 117} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 117} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -123,7 +120,7 @@ func HostTile(t TileData) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(t.Host.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 29, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 26, Col: 39} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -158,7 +155,7 @@ func HostTile(t TileData) templ.Component { var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(t.LastSeenAt)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 31, Col: 95} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 28, Col: 95} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -171,222 +168,77 @@ func HostTile(t TileData) templ.Component { var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(t.Latest)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 32, Col: 51} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 29, Col: 51} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if t.Latest != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
    ") + if canStart(t) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"inline tile-start-form\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(runDuration(t.Latest)) + } else if canStartIfOnline(t.Latest) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 38, Col: 59} + return templ_7745c5c3_Err + } + } else if canCancel(t.Latest) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" class=\"inline tile-cancel-form\" onsubmit=\"return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
      ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - for _, name := range store.DefaultStageOrder { - templ_7745c5c3_Err = tileStep(stageForName(t.Stages, name)).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
    ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -// tileStep renders one entry of the tile's mini step-list: a small -// coloured dot plus the short stage name. Kept as its own templ so the -// markup stays consistent with the detail page's larger stage-dot -// elements (same class prefix, different size via the `-sm` modifier). -func tileStep(s model.Stage) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var18 := templ.GetChildren(ctx) - if templ_7745c5c3_Var18 == nil { - templ_7745c5c3_Var18 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var19 = []any{"tile-step", "tile-step-" + string(s.State)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var19...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
  • ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var21 = []any{"stage-dot", "stage-dot-sm", "stage-dot-" + string(s.State)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var21...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var23 string - templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(string(s.State))) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 74, Col: 108} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var24 string - templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 75, Col: 39} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/internal/web/templates/host_tile_test.go b/internal/web/templates/host_tile_test.go index 108d463..e331d4e 100644 --- a/internal/web/templates/host_tile_test.go +++ b/internal/web/templates/host_tile_test.go @@ -2,13 +2,11 @@ package templates import ( "context" - "fmt" "strings" "testing" "time" "vetting/internal/model" - "vetting/internal/store" ) func TestHumanAgoFrom(t *testing.T) { @@ -98,30 +96,20 @@ func TestHostTile_DisabledStartWhenOffline(t *testing.T) { } } -// TestHostTile_MiniRunView asserts the tile renders a step-list entry -// for every canonical stage, colours the dots according to the mixed -// stage states in the fixture, and surfaces the run id + duration in -// the meta row. This is the contract the dashboard leans on: the -// operator should be able to read run health across all tiles without -// drilling into any of them. -func TestHostTile_MiniRunView(t *testing.T) { +// TestHostTile_NoStageStrip: the tile no longer carries the Phase 3 +// per-stage mini run-view — the runs-table on /hosts/{id} owns the +// stage-strip now. Guards against the regression that would bring +// `tile-steplist` / `tile-step-name` / `tile-run-duration` back. +func TestHostTile_NoStageStrip(t *testing.T) { now := time.Now() - started := now.Add(-3 * time.Minute) latest := &model.Run{ ID: 17, State: model.StateSMART, - StartedAt: started, - } - // Mixed states: first two stages passed, SMART running, rest pending. - stages := []model.Stage{ - {Name: "Inventory", State: model.StagePassed}, - {Name: "SpecValidate", State: model.StagePassed}, - {Name: "SMART", State: model.StageRunning}, + StartedAt: now.Add(-3 * time.Minute), } data := TileData{ Host: model.Host{ID: 42, Name: "tile-test", MAC: "aa:bb:cc:dd:ee:ff"}, Latest: latest, - Stages: stages, LastSeenAt: &now, } var buf strings.Builder @@ -129,66 +117,18 @@ func TestHostTile_MiniRunView(t *testing.T) { t.Fatalf("render: %v", err) } html := buf.String() - - // Step list exists and contains every canonical stage name so the - // operator reads a full 9-dot strip regardless of how far the run got. - if !strings.Contains(html, `
      `) { - t.Fatalf("tile missing step list: %s", html) - } - for _, s := range store.DefaultStageOrder { - want := fmt.Sprintf(`%s`, s) - if !strings.Contains(html, want) { - t.Fatalf("tile missing step name %q: %s", s, html) + for _, dropped := range []string{ + `tile-steplist`, + `tile-step-name`, + `tile-step-dot`, + `tile-run-id`, + `tile-run-duration`, + `tile-meta-row`, + } { + if strings.Contains(html, dropped) { + t.Errorf("host tile leaked dropped class %q: %s", dropped, html) } } - // Colours: the two passed stages got passed dots; SMART got a running - // dot; CPUStress (no fixture row) falls back to pending. - mustContain := []string{ - `stage-dot stage-dot-sm stage-dot-passed`, - `stage-dot stage-dot-sm stage-dot-running`, - `stage-dot stage-dot-sm stage-dot-pending`, - } - for _, c := range mustContain { - if !strings.Contains(html, c) { - t.Fatalf("tile missing expected dot classes %q: %s", c, html) - } - } - // Meta row: run id + a duration string (minutes for a 3m-old run). - if !strings.Contains(html, `#17`) { - t.Fatalf("tile missing run id #17: %s", html) - } - if !strings.Contains(html, `class="tile-run-duration"`) { - t.Fatalf("tile missing duration element: %s", html) - } -} - -// TestHostTile_GhostSteplist: a never-run host still gets a 9-dot -// ghost strip (all pending). Keeps the tile height stable so the -// dashboard grid doesn't reflow as hosts gain their first run. -func TestHostTile_GhostSteplist(t *testing.T) { - now := time.Now() - data := TileData{ - Host: model.Host{ID: 1, Name: "fresh", MAC: "aa:bb:cc:dd:ee:01"}, - LastSeenAt: &now, - } - var buf strings.Builder - if err := HostTile(data).Render(context.Background(), &buf); err != nil { - t.Fatalf("render: %v", err) - } - html := buf.String() - for _, s := range store.DefaultStageOrder { - want := fmt.Sprintf(`%s`, s) - if !strings.Contains(html, want) { - t.Fatalf("ghost tile missing stage %q: %s", s, html) - } - } - if strings.Contains(html, `stage-dot-passed`) || strings.Contains(html, `stage-dot-running`) || strings.Contains(html, `stage-dot-failed`) { - t.Fatalf("ghost tile should have only pending dots: %s", html) - } - // No run → no meta row (suppresses "#0 · 0s" when no run exists). - if strings.Contains(html, `class="tile-run-id"`) { - t.Fatalf("ghost tile should omit run id: %s", html) - } } func TestLastSeenLabelAndClass(t *testing.T) { diff --git a/internal/web/templates/run_detail.templ b/internal/web/templates/run_detail.templ new file mode 100644 index 0000000..1b2421a --- /dev/null +++ b/internal/web/templates/run_detail.templ @@ -0,0 +1,188 @@ +package templates + +import ( + "bytes" + "context" + "fmt" + + "vetting/internal/model" + "vetting/internal/store" +) + +// RunPageData is the full payload for /runs/{runID}. Host is resolved +// from Run.HostID so the breadcrumb + run actions (which post to +// /hosts/{hostID}/...) have the host context without a separate call. +// Stages/SubSteps/SpecDiffs drive the pipeline + active-step panels + +// diff list. DefaultStepStage is the stage name whose
      opens +// on first render — running → failed → Reporting. HoldKeyPath is the +// on-disk path of the hold_key artifact, needed to print the ssh +// invocation in the hold banner. SpecDiffCritical is the count of +// unignored critical diffs shown in the header. +type RunPageData struct { + Host model.Host + Run model.Run + Stages []model.Stage + SubSteps []model.SubStep + SpecDiffs []model.SpecDiff + DefaultStepStage string + LogReplayByStage map[string]string + HoldKeyPath string + SpecDiffCritical int +} + +// RunPage is the run-focused URL: pipeline + per-stage active-step panels +// + spec diffs + hold banner. Host metadata stays on /hosts/{id}; this +// page carries only run-scoped content so the operator can read one run +// without surrounding noise. +templ RunPage(d RunPageData) { + @Layout(fmt.Sprintf("%s — run #%d", d.Host.Name, d.Run.ID)) { +
      + + + @RunHeader(d) + @HoldBanner(d) + @PipelineSection(&d.Run, BuildPipeline(&d.Run, d.Stages)) + +
      +
      + for _, stageName := range store.DefaultStageOrder { + @ActiveStep(ActiveStepData{ + RunID: d.Run.ID, + Stage: stageForName(d.Stages, stageName), + SubSteps: SubStepsForStage(d.SubSteps, stageName), + LogReplay: d.LogReplayByStage[stageName], + Open: stageName == d.DefaultStepStage, + }) + } +
      +
      + + @RunSpecDiffs(d) +
      + } +} + +// RunHeader is the run-page header: run id, state badge, elapsed, and +// the primary action on the right (Cancel during a non-terminal run; +// Start-new-run + View-report after). Keyed on run ID so SSE updates +// don't collide with a newer run's header. Rendered as a section rather +// than a bare header so it composes with the breadcrumb strip above. +templ RunHeader(d RunPageData) { +
      +
      +

      { fmt.Sprintf("Run #%d", d.Run.ID) }

      + { tileStatus(&d.Run) } + { runDuration(&d.Run) } + if d.Run.FailedStage != "" { + failed at { d.Run.FailedStage } + } + if d.SpecDiffCritical > 0 { + { fmt.Sprintf("%d critical diff", d.SpecDiffCritical) } + } +
      +
      + if canCancel(&d.Run) { +
      + +
      + } + if canOverrideWipe(&d.Run) { +
      + +
      + } + if hasReport(&d.Run) { + View report + } + if d.Run.State.IsTerminal() { +
      + +
      + } +
      +
      +} + +// HoldBanner is the "Host is holding — SSH available" strip when a run +// is FailedHolding with an IP recorded. Emits an empty placeholder +// otherwise so the first SSE push when a hold actually fires has a +// target to swap into. +templ HoldBanner(d RunPageData) { + if d.Run.State == model.StateFailedHolding && d.Run.HoldIP != "" { +
      + Host is holding — SSH available: + { sshInvocation(d.HoldKeyPath, d.Run.HoldIP) } +
      + } else { +
      + } +} + +// RunSpecDiffs renders the "Spec diffs (N)" collapsible. The wrapper is +// always emitted (even when empty) so SpecValidate-time SSE pushes have +// a target; the
      body only renders when diffs exist. +templ RunSpecDiffs(d RunPageData) { +
      + if len(d.SpecDiffs) > 0 { +
      +

      Spec diffs ({ fmt.Sprintf("%d", len(d.SpecDiffs)) })

      +
        + for _, diff := range d.SpecDiffs { +
      • +
        { diff.Field }
        +
        expected: { diff.Expected }
        +
        actual: { diff.Actual }
        +
      • + } +
      +
      + } +
      +} + +// RenderRunHeaderString, RenderHoldBannerString, and +// RenderRunSpecDiffsString render each region to a string for the +// orchestrator's SSE publish path. Matches the RenderTileString pattern. +func RenderRunHeaderString(d RunPageData) string { + var buf bytes.Buffer + _ = RunHeader(d).Render(context.Background(), &buf) + return buf.String() +} + +func RenderHoldBannerString(d RunPageData) string { + var buf bytes.Buffer + _ = HoldBanner(d).Render(context.Background(), &buf) + return buf.String() +} + +func RenderRunSpecDiffsString(d RunPageData) string { + var buf bytes.Buffer + _ = RunSpecDiffs(d).Render(context.Background(), &buf) + return buf.String() +} diff --git a/internal/web/templates/run_detail_templ.go b/internal/web/templates/run_detail_templ.go new file mode 100644 index 0000000..7ffd9b5 --- /dev/null +++ b/internal/web/templates/run_detail_templ.go @@ -0,0 +1,716 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "bytes" + "context" + "fmt" + + "vetting/internal/model" + "vetting/internal/store" +) + +// RunPageData is the full payload for /runs/{runID}. Host is resolved +// from Run.HostID so the breadcrumb + run actions (which post to +// /hosts/{hostID}/...) have the host context without a separate call. +// Stages/SubSteps/SpecDiffs drive the pipeline + active-step panels + +// diff list. DefaultStepStage is the stage name whose
      opens +// on first render — running → failed → Reporting. HoldKeyPath is the +// on-disk path of the hold_key artifact, needed to print the ssh +// invocation in the hold banner. SpecDiffCritical is the count of +// unignored critical diffs shown in the header. +type RunPageData struct { + Host model.Host + Run model.Run + Stages []model.Stage + SubSteps []model.SubStep + SpecDiffs []model.SpecDiff + DefaultStepStage string + LogReplayByStage map[string]string + HoldKeyPath string + SpecDiffCritical int +} + +// RunPage is the run-focused URL: pipeline + per-stage active-step panels +// + spec diffs + hold banner. Host metadata stays on /hosts/{id}; this +// page carries only run-scoped content so the operator can read one run +// without surrounding noise. +func RunPage(d RunPageData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
      ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = RunHeader(d).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = HoldBanner(d).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = PipelineSection(&d.Run, BuildPipeline(&d.Run, d.Stages)).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
      ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, stageName := range store.DefaultStageOrder { + templ_7745c5c3_Err = ActiveStep(ActiveStepData{ + RunID: d.Run.ID, + Stage: stageForName(d.Stages, stageName), + SubSteps: SubStepsForStage(d.SubSteps, stageName), + LogReplay: d.LogReplayByStage[stageName], + Open: stageName == d.DefaultStepStage, + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
      ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = RunSpecDiffs(d).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
      ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout(fmt.Sprintf("%s — run #%d", d.Host.Name, d.Run.ID)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// RunHeader is the run-page header: run id, state badge, elapsed, and +// the primary action on the right (Cancel during a non-terminal run; +// Start-new-run + View-report after). Keyed on run ID so SSE updates +// don't collide with a newer run's header. Rendered as a section rather +// than a bare header so it composes with the breadcrumb strip above. +func RunHeader(d RunPageData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var6 := templ.GetChildren(ctx) + if templ_7745c5c3_Var6 == nil { + templ_7745c5c3_Var6 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + var templ_7745c5c3_Var7 = []any{"run-header", "tile-" + tileMood(&d.Run)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

      ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Run #%d", d.Run.ID)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 84, Col: 65} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

      ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 = []any{"run-status-badge", "run-status-" + tileMood(&d.Run)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(&d.Run)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 85, Col: 92} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(runDuration(&d.Run)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 86, Col: 51} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Run.FailedStage != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "failed at ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.Run.FailedStage) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 88, Col: 72} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if d.SpecDiffCritical > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d critical diff", d.SpecDiffCritical)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 91, Col: 85} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
      ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if canCancel(&d.Run) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
      ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if canOverrideWipe(&d.Run) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
      ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if hasReport(&d.Run) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "View report ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if d.Run.State.IsTerminal() { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
      ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
      ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// HoldBanner is the "Host is holding — SSH available" strip when a run +// is FailedHolding with an IP recorded. Emits an empty placeholder +// otherwise so the first SSE push when a hold actually fires has a +// target to swap into. +func HoldBanner(d RunPageData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var22 := templ.GetChildren(ctx) + if templ_7745c5c3_Var22 == nil { + templ_7745c5c3_Var22 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if d.Run.State == model.StateFailedHolding && d.Run.HoldIP != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
      Host is holding — SSH available: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(d.HoldKeyPath, d.Run.HoldIP)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 130, Col: 70} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
      ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
      ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +// RunSpecDiffs renders the "Spec diffs (N)" collapsible. The wrapper is +// always emitted (even when empty) so SpecValidate-time SSE pushes have +// a target; the
      body only renders when diffs exist. +func RunSpecDiffs(d RunPageData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var28 := templ.GetChildren(ctx) + if templ_7745c5c3_Var28 == nil { + templ_7745c5c3_Var28 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
      ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(d.SpecDiffs) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "

      Spec diffs (") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var31 string + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 154, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, ")

        ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, diff := range d.SpecDiffs { + var templ_7745c5c3_Var32 = []any{"diff-row", "diff-" + diff.Severity} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var32...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
      • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 158, Col: 43} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
        expected: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 159, Col: 65} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
        actual: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var36 string + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 160, Col: 59} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
      • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
      ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// RenderRunHeaderString, RenderHoldBannerString, and +// RenderRunSpecDiffsString render each region to a string for the +// orchestrator's SSE publish path. Matches the RenderTileString pattern. +func RenderRunHeaderString(d RunPageData) string { + var buf bytes.Buffer + _ = RunHeader(d).Render(context.Background(), &buf) + return buf.String() +} + +func RenderHoldBannerString(d RunPageData) string { + var buf bytes.Buffer + _ = HoldBanner(d).Render(context.Background(), &buf) + return buf.String() +} + +func RenderRunSpecDiffsString(d RunPageData) string { + var buf bytes.Buffer + _ = RunSpecDiffs(d).Render(context.Background(), &buf) + return buf.String() +} + +var _ = templruntime.GeneratedTemplate