From bb658a8435a54c520a02fd68305cb5a364d3aeb0 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 17 Apr 2026 23:59:43 -0400 Subject: [PATCH] Host detail page + pipeline timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click a tile to open /hosts/{id} — the canonical control surface per host. Timeline renders every pre-stage, stage, and terminal node in order, with the current one pulsing, failed ones flagged, and downstream ones dimmed as skipped. Detail page shows summary, hold card (when holding), all action buttons, spec diffs, a full-height log pane, and a collapsed expected-spec YAML. Tile slims to name, last-seen, status, and one primary action; a CSS-overlay makes the whole card clickable while buttons stay receptive via z-index. Runner.publishTileUpdate now also emits pipeline-{runID} fragments, and CompleteStage wraps Stages.CompleteByName so stage completions advance the timeline live — without this the dots only moved on state transitions. Co-Authored-By: Claude Opus 4.7 --- cmd/vetting/main.go | 3 + internal/api/agent_handlers.go | 10 +- internal/api/host_detail_test.go | 144 +++++ internal/api/ui_handlers.go | 47 ++ internal/httpserver/router.go | 1 + internal/orchestrator/runner.go | 33 ++ internal/orchestrator/runner_test.go | 164 ++++++ internal/web/static/app.css | 196 +++++++ internal/web/templates/host_detail.templ | 165 ++++++ internal/web/templates/host_detail_templ.go | 572 +++++++++++++++++++ internal/web/templates/host_tile.templ | 59 +- internal/web/templates/host_tile_templ.go | 228 ++------ internal/web/templates/host_tile_test.go | 35 ++ internal/web/templates/layout_templ.go | 4 +- internal/web/templates/pipeline.templ | 225 ++++++++ internal/web/templates/pipeline_templ.go | 366 ++++++++++++ internal/web/templates/pipeline_test.go | 133 +++++ internal/web/templates/registration_templ.go | 16 +- 18 files changed, 2153 insertions(+), 248 deletions(-) create mode 100644 internal/api/host_detail_test.go create mode 100644 internal/orchestrator/runner_test.go create mode 100644 internal/web/templates/host_detail.templ create mode 100644 internal/web/templates/host_detail_templ.go create mode 100644 internal/web/templates/pipeline.templ create mode 100644 internal/web/templates/pipeline_templ.go create mode 100644 internal/web/templates/pipeline_test.go diff --git a/cmd/vetting/main.go b/cmd/vetting/main.go index c3c55a9..1b15372 100644 --- a/cmd/vetting/main.go +++ b/cmd/vetting/main.go @@ -89,6 +89,7 @@ func main() { orchestrator.TileRenderer = func(ctx context.Context, host model.Host, latest *model.Run) string { return templates.RenderTileString(tiles.Build(ctx, host, latest)) } + orchestrator.PipelineRenderer = templates.RenderPipelineString notifyReg, err := notify.BuildRegistry(cfg.Notifiers, cfg.Routes) if err != nil { @@ -98,6 +99,8 @@ func main() { ui := &api.UI{ Hosts: hostStore, Runs: runStore, + Stages: stageStore, + SpecDiffs: specDiffStore, Artifacts: artifactStore, EventHub: hub, Runner: runner, diff --git a/internal/api/agent_handlers.go b/internal/api/agent_handlers.go index 74257e3..0239138 100644 --- a/internal/api/agent_handlers.go +++ b/internal/api/agent_handlers.go @@ -408,7 +408,7 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) { if len(body.Summary) > 0 { summaryJSON = string(body.Summary) } - if err := a.Stages.CompleteByName(r.Context(), runID, body.Stage, stageState, summaryJSON); err != nil { + if err := a.Runner.CompleteStage(r.Context(), runID, body.Stage, stageState, summaryJSON); err != nil { http.Error(w, "complete stage: "+err.Error(), http.StatusInternalServerError) return } @@ -544,7 +544,7 @@ func (a *Agent) resolveSpecValidate(r *http.Request, runID int64) { "critical": critical, }) if critical > 0 { - _ = a.Stages.CompleteByName(r.Context(), runID, "SpecValidate", model.StageFailed, string(summaryBuf)) + _ = a.Runner.CompleteStage(r.Context(), runID, "SpecValidate", model.StageFailed, string(summaryBuf)) _ = a.Runs.SetFailedStage(r.Context(), runID, "SpecValidate") if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageFailed); err != nil { log.Printf("specvalidate: failed-transition: %v", err) @@ -561,7 +561,7 @@ func (a *Agent) resolveSpecValidate(r *http.Request, runID int64) { URL: a.runLinkURL(runID), }) } else { - _ = a.Stages.CompleteByName(r.Context(), runID, "SpecValidate", model.StagePassed, string(summaryBuf)) + _ = a.Runner.CompleteStage(r.Context(), runID, "SpecValidate", model.StagePassed, string(summaryBuf)) if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageCompleted); err != nil { log.Printf("specvalidate: advance: %v", err) } @@ -591,7 +591,7 @@ func (a *Agent) readInventoryArtifact(r *http.Request, runID int64) (*spec.Inven } func (a *Agent) failStage(r *http.Request, runID int64, stage, message string) { - _ = a.Stages.CompleteByName(r.Context(), runID, stage, model.StageFailed, fmt.Sprintf(`{"error":%q}`, message)) + _ = a.Runner.CompleteStage(r.Context(), runID, stage, model.StageFailed, fmt.Sprintf(`{"error":%q}`, message)) _ = a.Runs.SetFailedStage(r.Context(), runID, stage) if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageFailed); err != nil { log.Printf("failStage: transition run %d: %v", runID, err) @@ -889,7 +889,7 @@ func (a *Agent) resolveReporting(r *http.Request, runID int64) { "stages": len(stages), "diffs": len(diffs), }) - if err := a.Stages.CompleteByName(ctx, runID, "Reporting", model.StagePassed, string(summaryBuf)); err != nil { + if err := a.Runner.CompleteStage(ctx, runID, "Reporting", model.StagePassed, string(summaryBuf)); err != nil { log.Printf("reporting: complete stage: %v", err) } if err := a.Runs.MarkCompleted(ctx, runID, path); err != nil { diff --git a/internal/api/host_detail_test.go b/internal/api/host_detail_test.go new file mode 100644 index 0000000..9001eaa --- /dev/null +++ b/internal/api/host_detail_test.go @@ -0,0 +1,144 @@ +package api_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + "vetting/internal/api" + "vetting/internal/db" + "vetting/internal/events" + "vetting/internal/model" + "vetting/internal/orchestrator" + "vetting/internal/store" +) + +func setupDetail(t *testing.T) (*api.UI, *store.Hosts, *store.Runs) { + t.Helper() + conn, err := db.Open(filepath.Join(t.TempDir(), "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} + diffs := &store.SpecDiffs{DB: conn} + arts := &store.Artifacts{DB: conn} + hub := events.NewHub() + 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, + SpecDiffs: diffs, + Artifacts: arts, + EventHub: hub, + 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)) +} + +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") + if err != nil { + t.Fatalf("create run: %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) + } + wantLogID := fmt.Sprintf(`id="log-%d"`, 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) + } +} + +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/ui_handlers.go b/internal/api/ui_handlers.go index 74ccbc8..ceeb02a 100644 --- a/internal/api/ui_handlers.go +++ b/internal/api/ui_handlers.go @@ -26,6 +26,8 @@ import ( type UI struct { Hosts *store.Hosts Runs *store.Runs + Stages *store.Stages + SpecDiffs *store.SpecDiffs Artifacts *store.Artifacts EventHub *events.Hub Runner *orchestrator.Runner @@ -74,6 +76,51 @@ 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) { + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + http.Error(w, "bad host id", http.StatusBadRequest) + return + } + host, err := u.Hosts.Get(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + latest, err := u.Runs.LatestForHost(r.Context(), id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + var stages []model.Stage + var diffs []model.SpecDiff + if latest != nil { + if u.Stages != nil { + stages, _ = u.Stages.ListForRun(r.Context(), latest.ID) + } + if u.SpecDiffs != nil { + diffs, _ = u.SpecDiffs.ListForRun(r.Context(), latest.ID) + } + } + t := u.Tiles.Build(r.Context(), *host, latest) + data := templates.HostDetailData{ + Tile: t, + Stages: stages, + SpecDiffs: diffs, + } + _ = templates.HostDetail(data).Render(r.Context(), w) +} + // StartRun creates a new Run for the host, issues an agent token, and // transitions Registered→Queued. The dispatcher goroutine picks it up // and fires WoL. diff --git a/internal/httpserver/router.go b/internal/httpserver/router.go index 7f6d5c5..37a5b1e 100644 --- a/internal/httpserver/router.go +++ b/internal/httpserver/router.go @@ -70,6 +70,7 @@ 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.Post("/hosts/{id}/delete", d.UI.DeleteHost) r.Post("/hosts/{id}/start", d.UI.StartRun) r.Post("/hosts/{id}/override-wipe", d.UI.OverrideWipeStorage) diff --git a/internal/orchestrator/runner.go b/internal/orchestrator/runner.go index 85f6c40..7e0613f 100644 --- a/internal/orchestrator/runner.go +++ b/internal/orchestrator/runner.go @@ -50,6 +50,21 @@ func (r *Runner) StartStage(ctx context.Context, runID int64, name string) error return nil } +// CompleteStage marks a stage row passed/failed/skipped and publishes a +// tile + pipeline refresh. Wrapper around Stages.CompleteByName so every +// stage completion triggers an SSE update — without this, stage dots on +// the pipeline wouldn't advance until the next run-state transition. +func (r *Runner) CompleteStage(ctx context.Context, runID int64, name string, state model.StageState, summaryJSON string) error { + if err := r.Stages.CompleteByName(ctx, runID, name, state, summaryJSON); err != nil { + return err + } + run, err := r.Runs.Get(ctx, runID) + if err == nil { + r.publishTileUpdate(ctx, run.HostID) + } + return nil +} + // PublishTileUpdate is the exported entry point for non-orchestrator // callers (the UI heartbeat handler) that change tile-visible state // without going through Transition. @@ -70,6 +85,19 @@ func (r *Runner) publishTileUpdate(ctx context.Context, hostID int64) { } payload := renderTileSSE(ctx, *host, latest) r.EventHub.Publish(events.Event{Name: fmt.Sprintf("tile-%d", hostID), Payload: payload}) + + // Pipeline fragment — same call sites as the tile refresh, keyed by + // run ID so the detail page's
picks + // it up. Silently skips when no renderer is wired or no run exists. + if latest != nil && PipelineRenderer != nil && r.Stages != nil { + stages, err := r.Stages.ListForRun(ctx, latest.ID) + if err != nil { + log.Printf("publishTileUpdate: list stages run %d: %v", latest.ID, err) + return + } + pipePayload := PipelineRenderer(latest, stages) + r.EventHub.Publish(events.Event{Name: fmt.Sprintf("pipeline-%d", latest.ID), Payload: pipePayload}) + } } // TileRenderer renders a single tile fragment. Registered at startup @@ -79,6 +107,11 @@ func (r *Runner) publishTileUpdate(ctx context.Context, hostID int64) { // template package. var TileRenderer func(ctx context.Context, host model.Host, latest *model.Run) string +// PipelineRenderer renders the detail-page pipeline fragment for the +// given run + its stage rows. Registered alongside TileRenderer so +// orchestrator stays free of template imports. +var PipelineRenderer func(run *model.Run, stages []model.Stage) string + func renderTileSSE(ctx context.Context, host model.Host, latest *model.Run) string { if TileRenderer == nil { return fmt.Sprintf(`
state change
`, host.ID) diff --git a/internal/orchestrator/runner_test.go b/internal/orchestrator/runner_test.go new file mode 100644 index 0000000..eab109a --- /dev/null +++ b/internal/orchestrator/runner_test.go @@ -0,0 +1,164 @@ +package orchestrator_test + +import ( + "context" + "fmt" + "path/filepath" + "testing" + "time" + + "vetting/internal/db" + "vetting/internal/events" + "vetting/internal/model" + "vetting/internal/orchestrator" + "vetting/internal/store" +) + +// setupRunner wires a real DB + stores + hub and registers a minimal +// TileRenderer/PipelineRenderer so publishTileUpdate emits something +// recognisable. Returns Runner plus helpers to drain events. +func setupRunner(t *testing.T) (*orchestrator.Runner, *store.Hosts, *store.Runs, *events.Hub, func()) { + t.Helper() + conn, err := db.Open(filepath.Join(t.TempDir(), "vetting.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + hub := events.NewHub() + hosts := &store.Hosts{DB: conn} + runs := &store.Runs{DB: conn} + stages := &store.Stages{DB: conn} + runner := &orchestrator.Runner{Runs: runs, Hosts: hosts, Stages: stages, EventHub: hub} + + // Deterministic renderer stubs — use known substrings so tests can + // grep the published fragments without parsing HTML. + prevTile := orchestrator.TileRenderer + prevPipe := orchestrator.PipelineRenderer + 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) + } + cleanup := func() { + orchestrator.TileRenderer = prevTile + orchestrator.PipelineRenderer = prevPipe + _ = conn.Close() + } + return runner, hosts, runs, hub, cleanup +} + +// TestPublishesTileAndPipelineOnTransition asserts that a single +// Transition call publishes both the tile-{hostID} and pipeline-{runID} +// fragments — the detail-page timeline needs this to advance on every +// state change without its own call site. +func TestPublishesTileAndPipelineOnTransition(t *testing.T) { + runner, hosts, runs, hub, cleanup := setupRunner(t) + defer cleanup() + ctx := context.Background() + + hostID, err := hosts.Create(ctx, model.Host{ + Name: "runner-tile", + 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, hostID, "deadbeef") + if err != nil { + t.Fatalf("create run: %v", err) + } + + _, _, cancel := hub.Subscribe() + defer cancel() + // Subscribe one more time so we have a channel we can drain. + _, ch, cancel2 := hub.Subscribe() + defer cancel2() + + // Queued → WaitingWoL on Dispatched. + if _, err := runner.Transition(ctx, runID, orchestrator.TriggerDispatched); err != nil { + t.Fatalf("transition: %v", err) + } + + // Collect events with a short deadline; we expect tile + pipeline + // from this one Transition call. + wantTile := fmt.Sprintf("tile-%d", hostID) + wantPipeline := fmt.Sprintf("pipeline-%d", runID) + sawTile, sawPipeline := false, false + deadline := time.After(500 * time.Millisecond) +loop: + for { + select { + case ev := <-ch: + if ev.Name == wantTile { + sawTile = true + } + if ev.Name == wantPipeline { + sawPipeline = true + } + if sawTile && sawPipeline { + break loop + } + case <-deadline: + break loop + } + } + if !sawTile { + t.Errorf("no %s event published", wantTile) + } + if !sawPipeline { + t.Errorf("no %s event published", wantPipeline) + } +} + +// TestCompleteStagePublishesPipeline covers the stage-completion path +// that used to go direct-to-Stages, bypassing the SSE refresh. The +// Runner.CompleteStage wrapper exists so stage-dot advancements show up +// on the detail page without waiting for the next run-state transition. +func TestCompleteStagePublishesPipeline(t *testing.T) { + runner, hosts, runs, hub, cleanup := setupRunner(t) + defer cleanup() + ctx := context.Background() + + hostID, err := hosts.Create(ctx, model.Host{ + Name: "runner-cs", + MAC: "aa:bb:cc:dd:ee:41", + 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, hostID, "deadbeef") + if err != nil { + t.Fatalf("create run: %v", err) + } + // CompleteStage needs stage rows to exist first — Seed them. + stages := &store.Stages{DB: runs.DB} + if err := stages.Seed(ctx, runID); err != nil { + t.Fatalf("seed stages: %v", err) + } + + _, ch, cancel := hub.Subscribe() + defer cancel() + + if err := runner.CompleteStage(ctx, runID, "Inventory", model.StagePassed, `{}`); err != nil { + t.Fatalf("CompleteStage: %v", err) + } + + wantPipeline := fmt.Sprintf("pipeline-%d", runID) + deadline := time.After(500 * time.Millisecond) + for { + select { + case ev := <-ch: + if ev.Name == wantPipeline { + return + } + case <-deadline: + t.Fatalf("no %s event published by CompleteStage", wantPipeline) + } + } +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 8cb7600..5dfbb50 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -104,7 +104,22 @@ button.danger:hover { background: rgba(229,100,102,.1); } display: flex; flex-direction: column; gap: 12px; + position: relative; + transition: border-color .12s ease, transform .12s ease; } +.tile:hover { border-color: var(--accent); } +.tile-link { + position: absolute; + inset: 0; + z-index: 0; + border-radius: var(--radius); + background: transparent; + text-decoration: none; +} +.tile > *:not(.tile-link) { position: relative; z-index: 1; } +.tile-primary-action { display: flex; gap: 8px; } +.tile-primary-action .inline { margin: 0; } +.tile-primary-action:empty { display: none; } .tile-head { display: flex; justify-content: space-between; align-items: center; } .tile-name { font-weight: 600; } .tile-header-right { display: flex; align-items: center; gap: 10px; } @@ -264,3 +279,184 @@ body.bare main { max-width: none; } } .manual-register summary:hover { color: var(--text); } .manual-register[open] summary { margin-bottom: 12px; } + +/* ===== Host detail page ===== */ +.detail { display: flex; flex-direction: column; gap: 20px; } +.breadcrumb { color: var(--text-dim); font-size: 13px; display: flex; gap: 6px; } +.breadcrumb a { color: var(--text-dim); } +.breadcrumb a:hover { color: var(--text); } +.breadcrumb-sep { opacity: .5; } + +.detail-summary { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px 20px; + display: flex; + flex-direction: column; + gap: 14px; +} +.detail-summary.tile-fail { border-color: rgba(229,100,102,.6); } +.detail-summary.tile-pass { border-color: rgba(53,194,123,.5); } +.detail-summary.tile-active { border-color: var(--accent); } +.detail-summary-head { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; flex-wrap: wrap; } +.detail-name { margin: 0; font-size: 22px; } +.detail-status-row { display: flex; align-items: center; gap: 12px; } +.detail-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 8px 24px; + margin: 0; + font-size: 13px; +} +.detail-meta div { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; } +.detail-meta dt { color: var(--text-dim); } +.detail-meta dd { margin: 0; font-family: var(--mono); } +.detail-meta dd.bad { color: var(--danger); } + +.detail-section { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px 20px; +} +.detail-section h2 { margin: 0 0 12px; font-size: 15px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .5px; font-weight: 600; } +.detail-section details > summary { list-style: none; cursor: pointer; display: flex; align-items: center; gap: 8px; } +.detail-section details > summary::before { content: "▸"; color: var(--text-dim); font-size: 12px; transition: transform .1s ease; } +.detail-section details[open] > summary::before { transform: rotate(90deg); } +.detail-section details > summary h2 { margin: 0; } + +.detail-hold { + background: rgba(229,100,102,.08); + border-color: rgba(229,100,102,.35); +} +.detail-hold h2 { color: var(--danger); } +.hold-ssh { + font-family: var(--mono); + font-size: 13px; + color: var(--text); + word-break: break-all; + user-select: all; + display: block; + padding: 10px 12px; + background: #0b0d12; + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.detail-actions-row { display: flex; flex-wrap: wrap; gap: 10px; } +.detail-actions-row .inline { margin: 0; } + +.detail-log { + background: #0b0d12; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 12px; + font-family: var(--mono); + font-size: 12px; + color: var(--text-dim); + max-height: 500px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; +} +.detail-log:empty::before { content: "(no log output yet)"; color: var(--text-dim); opacity: .5; } +.detail-log .log-line { white-space: pre-wrap; } +.detail-log .log-warn { color: var(--warn); } +.detail-log .log-error { color: var(--danger); } + +.diff-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } +.diff-row { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 12px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 13px; +} +.diff-row code { font-family: var(--mono); font-size: 12px; color: var(--text); } +.diff-field { font-weight: 600; } +.diff-expected, .diff-actual { color: var(--text-dim); } +.diff-critical { border-color: rgba(229,100,102,.5); background: rgba(229,100,102,.06); } +.diff-critical .diff-field { color: var(--danger); } +.diff-warning { border-color: rgba(228,169,75,.45); background: rgba(228,169,75,.05); } +.diff-warning .diff-field { color: var(--warn); } +.diff-info { opacity: .75; } + +.detail-host-meta h3 { margin: 12px 0 6px; font-size: 13px; color: var(--text-dim); } +.detail-notes p { margin: 0; color: var(--text); } +.detail-spec-yaml { + font-family: var(--mono); + font-size: 12px; + background: #0b0d12; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 12px; + white-space: pre; + overflow-x: auto; + margin: 0; +} + +/* ===== Pipeline timeline ===== */ +.pipeline { + display: flex; + align-items: stretch; + gap: 0; + overflow-x: auto; + padding: 12px 4px 6px; +} +.stage-node { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + min-width: 82px; + padding: 0 6px; + flex-shrink: 0; +} +.stage-dot { + width: 22px; + height: 22px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + border: 2px solid var(--border); + background: var(--bg-elev-2); + color: var(--text-dim); + line-height: 1; +} +.stage-dot-passed { background: var(--success); border-color: var(--success); color: #0b0d12; } +.stage-dot-running { background: var(--accent-strong); border-color: var(--accent); color: #fff; animation: pulse 1.2s ease-in-out infinite; } +.stage-dot-failed { background: var(--danger); border-color: var(--danger); color: #fff; } +.stage-dot-skipped { background: transparent; border-color: var(--border); color: var(--text-dim); opacity: .45; } +.stage-dot-pending { background: transparent; border-color: var(--border); color: transparent; } + +.stage-name { font-size: 11px; color: var(--text-dim); text-align: center; } +.stage-node-passed .stage-name { color: var(--text); } +.stage-node-running .stage-name { color: var(--accent); } +.stage-node-failed .stage-name { color: var(--danger); } +.stage-node-skipped .stage-name { opacity: .5; } +.stage-duration { font-size: 10px; color: var(--text-dim); font-family: var(--mono); min-height: 12px; } + +.stage-connector { + flex: 1; + min-width: 12px; + height: 2px; + align-self: center; + margin-top: -18px; + background: var(--border); +} +.stage-connector-passed { background: var(--success); } +.stage-connector-running { background: linear-gradient(90deg, var(--success), var(--accent)); } +.stage-connector-failed { background: var(--danger); } +.stage-connector-skipped { background: var(--border); opacity: .5; } + +@keyframes pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(60,130,246,.55); } + 50% { box-shadow: 0 0 0 6px rgba(60,130,246,0); } +} diff --git a/internal/web/templates/host_detail.templ b/internal/web/templates/host_detail.templ new file mode 100644 index 0000000..42d5f3b --- /dev/null +++ b/internal/web/templates/host_detail.templ @@ -0,0 +1,165 @@ +package templates + +import ( + "fmt" + + "vetting/internal/model" +) + +// HostDetailData is the full payload the detail handler hands to the +// HostDetail template. Tile carries host + latest-run enrichment (same +// shape the dashboard tile uses), Stages/SpecDiffs drive the pipeline +// and diff list. +type HostDetailData struct { + Tile TileData + Stages []model.Stage + SpecDiffs []model.SpecDiff +} + +templ HostDetail(d HostDetailData) { + @Layout(d.Tile.Host.Name) { +
+ + +
+
+

{ d.Tile.Host.Name }

+
+ { lastSeenLabel(d.Tile.LastSeenAt) } + { tileStatus(d.Tile.Latest) } +
+
+
+
+
MAC
+
{ d.Tile.Host.MAC }
+
+
+
WoL
+
{ fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort) }
+
+ if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" { +
+
Failed at
+
{ d.Tile.Latest.FailedStage }
+
+ } + if d.Tile.SpecDiffCritical > 0 { +
+
Spec diffs
+
{ fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical) }
+
+ } +
+
+ + if d.Tile.Latest != nil { +
+

Pipeline

+ @Pipeline(BuildPipeline(d.Tile.Latest, d.Stages)) +
+ } else { +
+

Pipeline

+ @Pipeline(BuildPipeline(nil, nil)) +
+ } + + if d.Tile.Latest != nil && d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" { +
+

Host is holding — SSH available

+ { sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP) } +
+ } + +
+

Actions

+
+ if canStart(d.Tile.Latest) { +
+ +
+ } else { + + } + if canOverrideWipe(d.Tile.Latest) { +
+ +
+ } + if hasReport(d.Tile.Latest) { + View report + } +
+ +
+
+
+ + 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 }
    +
  • + } +
+
+
+ } + + if d.Tile.Latest != nil { +
+

Log

+
+
+ } + +
+
+

Host details

+ if d.Tile.Host.Notes != "" { +
+

Notes

+

{ d.Tile.Host.Notes }

+
+ } +
+

Expected spec

+
{ d.Tile.Host.ExpectedSpecYAML }
+
+
+
+
+ } +} + +// 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 +} diff --git a/internal/web/templates/host_detail_templ.go b/internal/web/templates/host_detail_templ.go new file mode 100644 index 0000000..3c8cc6f --- /dev/null +++ b/internal/web/templates/host_detail_templ.go @@ -0,0 +1,572 @@ +// 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 ( + "fmt" + + "vetting/internal/model" +) + +// HostDetailData is the full payload the detail handler hands to the +// HostDetail template. Tile carries host + latest-run enrichment (same +// shape the dashboard tile uses), Stages/SpecDiffs drive the pipeline +// and diff list. +type HostDetailData struct { + Tile TileData + Stages []model.Stage + SpecDiffs []model.SpecDiff +} + +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 + } + var templ_7745c5c3_Var4 = []any{"detail-summary", "tile-" + tileMood(d.Tile.Latest)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...) + 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 + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, 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: 30, Col: 47} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + 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 + } + var templ_7745c5c3_Var7 = []any{"tile-last-seen", lastSeenClass(d.Tile.LastSeenAt)} + 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, 6, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, 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: 32, Col: 107} + } + _, 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, 8, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, 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: 33, Col: 59} + } + _, 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, 9, "
MAC
") + 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.MAC) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 39, Col: 27} + } + _, 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, 10, "
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.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: 43, Col: 81} + } + _, 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, "
") + 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, 12, "
Failed at
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, 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: 48, Col: 50} + } + _, 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, 13, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if d.Tile.SpecDiffCritical > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
Spec diffs
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 54, Col: 76} + } + _, 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 + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Tile.Latest != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

Pipeline

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Pipeline(BuildPipeline(d.Tile.Latest, d.Stages)).Render(ctx, templ_7745c5c3_Buffer) + 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 + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "

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, 22, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if d.Tile.Latest != nil && d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "

Host is holding — SSH available

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, 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: 80, 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, 24, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

Actions

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if canStart(d.Tile.Latest) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if canOverrideWipe(d.Tile.Latest) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if hasReport(d.Tile.Latest) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "View report") + 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 len(d.SpecDiffs) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "

Spec diffs (") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, 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: 111, Col: 68} + } + _, 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, 38, ")

    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, diff := range d.SpecDiffs { + var templ_7745c5c3_Var23 = []any{"diff-row", "diff-" + diff.Severity} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var23...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, 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: 115, Col: 45} + } + _, 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, 41, "
    expected: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, 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: 116, Col: 67} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
    actual: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var27 string + templ_7745c5c3_Var27, 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: 117, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
  • ") + 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 + } + } + if d.Tile.Latest != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "

Log

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

Host details

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Tile.Host.Notes != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "

Notes

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var30 string + templ_7745c5c3_Var30, 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: 143, Col: 29} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) + 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, "

Expected spec

")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var31 string
+			templ_7745c5c3_Var31, 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: 148, 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, 52, "
") + 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 + }) +} + +// 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 +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/host_tile.templ b/internal/web/templates/host_tile.templ index bd37361..81c1450 100644 --- a/internal/web/templates/host_tile.templ +++ b/internal/web/templates/host_tile.templ @@ -8,9 +8,10 @@ import ( "vetting/internal/model" ) -// HostTile renders a single dashboard card. It's the SSE-swap target -// for per-host tile refreshes (`tile-N`) and contains a per-run log -// pane (`log-M`) whose live tail is appended by the events hub. +// HostTile renders a single dashboard card. 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`). templ HostTile(t TileData) {
+
{ t.Host.Name }
@@ -25,61 +27,14 @@ templ HostTile(t TileData) {
{ tileStatus(t.Latest) }
-
-
-
MAC
-
{ t.Host.MAC }
-
-
-
WoL
-
{ fmt.Sprintf("%s:%d", t.Host.WoLBroadcastIP, t.Host.WoLPort) }
-
- if t.Latest != nil && t.Latest.FailedStage != "" { -
-
Failed at
-
{ t.Latest.FailedStage }
-
- } - if t.SpecDiffCritical > 0 { -
-
Spec diffs
-
{ fmt.Sprintf("%d critical", t.SpecDiffCritical) }
-
- } -
- if t.Latest != nil && t.Latest.State == model.StateFailedHolding && t.Latest.HoldIP != "" { -
-
Host is holding — SSH available
- { sshInvocation(t.HoldKeyPath, t.Latest.HoldIP) } -
- } - if t.Latest != nil { -
- } -
+
if canStart(t.Latest) {
- } else { - - } - if canOverrideWipe(t.Latest) { -
- -
- } - if hasReport(t.Latest) { + } else if hasReport(t.Latest) { View report } -
- -
} diff --git a/internal/web/templates/host_tile_templ.go b/internal/web/templates/host_tile_templ.go index 6e9269c..ba3067d 100644 --- a/internal/web/templates/host_tile_templ.go +++ b/internal/web/templates/host_tile_templ.go @@ -16,9 +16,10 @@ import ( "vetting/internal/model" ) -// HostTile renders a single dashboard card. It's the SSE-swap target -// for per-host tile refreshes (`tile-N`) and contains a per-run log -// pane (`log-M`) whose live tail is appended by the events hub. +// HostTile renders a single dashboard card. 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`). 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 @@ -52,7 +53,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: `host_tile.templ`, Line: 16, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 17, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -65,7 +66,7 @@ func HostTile(t TileData) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 1, Col: 0} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 1, Col: 0} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -78,276 +79,141 @@ 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: `host_tile.templ`, Line: 18, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 19, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" hx-swap=\"outerHTML\">
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" hx-swap=\"outerHTML\">
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" aria-label=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var7 = []any{"tile-last-seen", lastSeenClass(t.LastSeenAt)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...) + 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: 22, Col: 117} + } + _, 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, 6, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var7).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: `host_tile.templ`, Line: 1, Col: 0} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 39} } _, 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, 7, "\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(t.LastSeenAt)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 24, Col: 95} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + var templ_7745c5c3_Var9 = []any{"tile-last-seen", lastSeenClass(t.LastSeenAt)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
MAC
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(t.Host.MAC) + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(t.LastSeenAt)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 31, Col: 20} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 26, Col: 95} } _, 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, 10, "
WoL
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") 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", t.Host.WoLBroadcastIP, t.Host.WoLPort)) + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(t.Latest)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 35, Col: 69} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 27, 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 && t.Latest.FailedStage != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
Failed at
") + if canStart(t.Latest) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"inline\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } - if t.SpecDiffCritical > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
Spec diffs
") + } else if hasReport(t.Latest) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" target=\"_blank\" rel=\"noopener\">View report") 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 t.Latest != nil && t.Latest.State == model.StateFailedHolding && t.Latest.HoldIP != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
Host is holding — SSH available
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(t.HoldKeyPath, t.Latest.HoldIP)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 53, Col: 74} - } - _, 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, 18, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if t.Latest != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if canStart(t.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if canOverrideWipe(t.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if hasReport(t.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "View report") - 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, 16, "
") 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 e9adbff..f497e38 100644 --- a/internal/web/templates/host_tile_test.go +++ b/internal/web/templates/host_tile_test.go @@ -1,8 +1,12 @@ package templates import ( + "context" + "strings" "testing" "time" + + "vetting/internal/model" ) func TestHumanAgoFrom(t *testing.T) { @@ -35,6 +39,37 @@ func TestHumanAgoFrom(t *testing.T) { } } +// TestHostTile_OverlayLink asserts the tile includes the tile-link +// that makes the whole card clickable. The action button stays a +// sibling element, so CSS (z-index) keeps it on top of the overlay. +func TestHostTile_OverlayLink(t *testing.T) { + data := TileData{ + Host: model.Host{ID: 42, Name: "tile-test", MAC: "aa:bb:cc:dd:ee:ff"}, + } + var buf strings.Builder + if err := HostTile(data).Render(context.Background(), &buf); err != nil { + t.Fatalf("render: %v", err) + } + html := buf.String() + if !strings.Contains(html, `href="/hosts/42"`) { + t.Fatalf("tile missing overlay href: %s", html) + } + if !strings.Contains(html, `class="tile-link"`) { + t.Fatalf("tile missing tile-link class: %s", html) + } + // canStart(nil) is true → Start form must be present. + if !strings.Contains(html, `/hosts/42/start`) { + t.Fatalf("expected Start vetting form in tile: %s", html) + } + // Dropped content that used to live on the tile — confirm it has + // actually moved off so the slim-down is real. + for _, dropped := range []string{`tile-meta`, `tile-log`, `tile-actions`, `tile-hold`} { + if strings.Contains(html, dropped) { + t.Errorf("slim tile still contains dropped class %q", dropped) + } + } +} + func TestLastSeenLabelAndClass(t *testing.T) { if got := lastSeenLabel(nil); got != "never" { t.Fatalf("label nil = %q, want never", got) diff --git a/internal/web/templates/layout_templ.go b/internal/web/templates/layout_templ.go index 9d12605..1dfc9aa 100644 --- a/internal/web/templates/layout_templ.go +++ b/internal/web/templates/layout_templ.go @@ -36,7 +36,7 @@ func Layout(title string) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `layout.templ`, Line: 9, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 9, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -86,7 +86,7 @@ func BareLayout(title string) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `layout.templ`, Line: 38, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 38, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { diff --git a/internal/web/templates/pipeline.templ b/internal/web/templates/pipeline.templ new file mode 100644 index 0000000..cdc0277 --- /dev/null +++ b/internal/web/templates/pipeline.templ @@ -0,0 +1,225 @@ +package templates + +import ( + "bytes" + "context" + "fmt" + "time" + + "vetting/internal/model" +) + +// PipelineNode is one dot on the detail-page timeline. The template +// doesn't know stages from pre-stages — it just renders whatever the +// BuildPipeline helper produces, in order. +type PipelineNode struct { + Name string + State string // pending|running|passed|failed|skipped + StartedAt *time.Time + CompletedAt *time.Time +} + +// preStageOrder are the nodes that show before the first real stage. +// Derived from run.State rather than stage rows since we don't persist +// pre-stage timestamps. +var preStageOrder = []model.RunState{ + model.StateQueued, + model.StateWaitingWoL, + model.StateBooting, +} + +// runStateRank returns how far along the state machine a run is, using +// a linear ranking across pre-stages, stage states, and terminals. Used +// by BuildPipeline to decide which pre-stage nodes are "past" (passed), +// "current" (running), or "pending". +func runStateRank(s model.RunState) int { + order := []model.RunState{ + model.StateRegistered, + model.StateQueued, + model.StateWaitingWoL, + model.StateBooting, + model.StateInventoryCheck, + model.StateSpecValidate, + model.StateSMART, + model.StateCPUStress, + model.StateStorage, + model.StateNetwork, + model.StateGPU, + model.StatePSU, + model.StateReporting, + model.StateCompleted, + } + for i, v := range order { + if v == s { + return i + } + } + return -1 +} + +// BuildPipeline projects (run, stages) into a linear slice of nodes +// covering the whole lifecycle: pre-stage → stage rows → Completed. +// +// When run == nil we emit a ghost timeline (everything pending) so a +// never-run host still shows what's coming. +func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode { + nodes := make([]PipelineNode, 0, len(preStageOrder)+len(stages)+1) + + // --- pre-stage nodes --- + for _, ps := range preStageOrder { + n := PipelineNode{Name: string(ps), State: "pending"} + if run != nil { + switch { + case run.State == model.StateFailedHolding || run.State == model.StateFailed: + // If we failed before reaching a stage, a pre-stage may + // still have been entered — keep the "past" rank logic. + if runStateRank(ps) < runStateRank(firstStageState(run)) { + n.State = "passed" + } + case run.State == ps: + n.State = "running" + case runStateRank(run.State) > runStateRank(ps): + n.State = "passed" + } + } + nodes = append(nodes, n) + } + + // --- stage nodes (from stage rows) --- + failedBefore := false + for _, st := range stages { + n := PipelineNode{ + Name: st.Name, + StartedAt: st.StartedAt, + CompletedAt: st.CompletedAt, + } + switch { + case failedBefore: + n.State = "skipped" + case st.State == model.StagePassed: + n.State = "passed" + case st.State == model.StageRunning: + n.State = "running" + case st.State == model.StageFailed: + n.State = "failed" + failedBefore = true + case st.State == model.StageSkipped: + n.State = "skipped" + default: + n.State = "pending" + } + nodes = append(nodes, n) + } + + // --- terminal Completed node --- + term := PipelineNode{Name: "Completed", State: "pending"} + if run != nil && run.State == model.StateCompleted { + term.State = "passed" + term.CompletedAt = run.CompletedAt + } + nodes = append(nodes, term) + + return nodes +} + +// firstStageState returns the stage-state the run was in when it failed, +// or the current state for runs still in-flight. Used only by the +// pre-stage "past" check to decide if a Booting node should render +// "passed" even after the run failed further along. +func firstStageState(run *model.Run) model.RunState { + if run.FailedStage != "" { + if s, ok := stageStateByName(run.FailedStage); ok { + return s + } + } + return run.State +} + +// stageStateByName mirrors orchestrator.StateForStage without the +// import (templates can't see orchestrator). +func stageStateByName(name string) (model.RunState, bool) { + m := map[string]model.RunState{ + "Inventory": model.StateInventoryCheck, + "SpecValidate": model.StateSpecValidate, + "SMART": model.StateSMART, + "CPUStress": model.StateCPUStress, + "Storage": model.StateStorage, + "Network": model.StateNetwork, + "GPU": model.StateGPU, + "PSU": model.StatePSU, + "Reporting": model.StateReporting, + } + s, ok := m[name] + return s, ok +} + +// stageDuration renders node timing as "1.2s" / "12s" / "4m". Empty +// string when the node hasn't started or hasn't finished. +func stageDuration(n PipelineNode) string { + if n.StartedAt == nil { + return "" + } + end := time.Now() + if n.CompletedAt != nil { + end = *n.CompletedAt + } + d := end.Sub(*n.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", int(d/time.Minute)) + default: + return fmt.Sprintf("%dh", int(d/time.Hour)) + } +} + +// stageMarker returns the single-char glyph shown in the node's dot. +// Dots stay colored-via-class; the glyph is redundant-but-helpful. +func stageMarker(state string) string { + switch state { + case "passed": + return "✓" + case "failed": + return "!" + case "running": + return "●" + case "skipped": + return "–" + } + return "" +} + +// Pipeline renders the ordered dot-and-line timeline. The caller wraps +// it in a
so the runner can +// re-emit the fragment as stages progress. +templ Pipeline(nodes []PipelineNode) { +
+ for i, n := range nodes { + if i > 0 { +
+ } +
+
{ stageMarker(n.State) }
+
{ n.Name }
+
{ stageDuration(n) }
+
+ } +
+} + +// RenderPipelineString is the one-shot renderer the orchestrator +// registers at startup so it can publish pipeline fragments over SSE +// without pulling in the template package directly. +func RenderPipelineString(run *model.Run, stages []model.Stage) string { + var buf bytes.Buffer + _ = Pipeline(BuildPipeline(run, stages)).Render(context.Background(), &buf) + return buf.String() +} diff --git a/internal/web/templates/pipeline_templ.go b/internal/web/templates/pipeline_templ.go new file mode 100644 index 0000000..a58aea8 --- /dev/null +++ b/internal/web/templates/pipeline_templ.go @@ -0,0 +1,366 @@ +// 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" +) + +// PipelineNode is one dot on the detail-page timeline. The template +// doesn't know stages from pre-stages — it just renders whatever the +// BuildPipeline helper produces, in order. +type PipelineNode struct { + Name string + State string // pending|running|passed|failed|skipped + StartedAt *time.Time + CompletedAt *time.Time +} + +// preStageOrder are the nodes that show before the first real stage. +// Derived from run.State rather than stage rows since we don't persist +// pre-stage timestamps. +var preStageOrder = []model.RunState{ + model.StateQueued, + model.StateWaitingWoL, + model.StateBooting, +} + +// runStateRank returns how far along the state machine a run is, using +// a linear ranking across pre-stages, stage states, and terminals. Used +// by BuildPipeline to decide which pre-stage nodes are "past" (passed), +// "current" (running), or "pending". +func runStateRank(s model.RunState) int { + order := []model.RunState{ + model.StateRegistered, + model.StateQueued, + model.StateWaitingWoL, + model.StateBooting, + model.StateInventoryCheck, + model.StateSpecValidate, + model.StateSMART, + model.StateCPUStress, + model.StateStorage, + model.StateNetwork, + model.StateGPU, + model.StatePSU, + model.StateReporting, + model.StateCompleted, + } + for i, v := range order { + if v == s { + return i + } + } + return -1 +} + +// BuildPipeline projects (run, stages) into a linear slice of nodes +// covering the whole lifecycle: pre-stage → stage rows → Completed. +// +// When run == nil we emit a ghost timeline (everything pending) so a +// never-run host still shows what's coming. +func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode { + nodes := make([]PipelineNode, 0, len(preStageOrder)+len(stages)+1) + + // --- pre-stage nodes --- + for _, ps := range preStageOrder { + n := PipelineNode{Name: string(ps), State: "pending"} + if run != nil { + switch { + case run.State == model.StateFailedHolding || run.State == model.StateFailed: + // If we failed before reaching a stage, a pre-stage may + // still have been entered — keep the "past" rank logic. + if runStateRank(ps) < runStateRank(firstStageState(run)) { + n.State = "passed" + } + case run.State == ps: + n.State = "running" + case runStateRank(run.State) > runStateRank(ps): + n.State = "passed" + } + } + nodes = append(nodes, n) + } + + // --- stage nodes (from stage rows) --- + failedBefore := false + for _, st := range stages { + n := PipelineNode{ + Name: st.Name, + StartedAt: st.StartedAt, + CompletedAt: st.CompletedAt, + } + switch { + case failedBefore: + n.State = "skipped" + case st.State == model.StagePassed: + n.State = "passed" + case st.State == model.StageRunning: + n.State = "running" + case st.State == model.StageFailed: + n.State = "failed" + failedBefore = true + case st.State == model.StageSkipped: + n.State = "skipped" + default: + n.State = "pending" + } + nodes = append(nodes, n) + } + + // --- terminal Completed node --- + term := PipelineNode{Name: "Completed", State: "pending"} + if run != nil && run.State == model.StateCompleted { + term.State = "passed" + term.CompletedAt = run.CompletedAt + } + nodes = append(nodes, term) + + return nodes +} + +// firstStageState returns the stage-state the run was in when it failed, +// or the current state for runs still in-flight. Used only by the +// pre-stage "past" check to decide if a Booting node should render +// "passed" even after the run failed further along. +func firstStageState(run *model.Run) model.RunState { + if run.FailedStage != "" { + if s, ok := stageStateByName(run.FailedStage); ok { + return s + } + } + return run.State +} + +// stageStateByName mirrors orchestrator.StateForStage without the +// import (templates can't see orchestrator). +func stageStateByName(name string) (model.RunState, bool) { + m := map[string]model.RunState{ + "Inventory": model.StateInventoryCheck, + "SpecValidate": model.StateSpecValidate, + "SMART": model.StateSMART, + "CPUStress": model.StateCPUStress, + "Storage": model.StateStorage, + "Network": model.StateNetwork, + "GPU": model.StateGPU, + "PSU": model.StatePSU, + "Reporting": model.StateReporting, + } + s, ok := m[name] + return s, ok +} + +// stageDuration renders node timing as "1.2s" / "12s" / "4m". Empty +// string when the node hasn't started or hasn't finished. +func stageDuration(n PipelineNode) string { + if n.StartedAt == nil { + return "" + } + end := time.Now() + if n.CompletedAt != nil { + end = *n.CompletedAt + } + d := end.Sub(*n.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", int(d/time.Minute)) + default: + return fmt.Sprintf("%dh", int(d/time.Hour)) + } +} + +// stageMarker returns the single-char glyph shown in the node's dot. +// Dots stay colored-via-class; the glyph is redundant-but-helpful. +func stageMarker(state string) string { + switch state { + case "passed": + return "✓" + case "failed": + return "!" + case "running": + return "●" + case "skipped": + return "–" + } + return "" +} + +// Pipeline renders the ordered dot-and-line timeline. The caller wraps +// it in a
so the runner can +// re-emit the fragment as stages progress. +func Pipeline(nodes []PipelineNode) 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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for i, n := range nodes { + if i > 0 { + var templ_7745c5c3_Var2 = []any{"stage-connector", "stage-connector-" + nodes[i-1].State} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + 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 + } + var templ_7745c5c3_Var4 = []any{"stage-node", "stage-node-" + n.State} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...) + 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 + } + var templ_7745c5c3_Var6 = []any{"stage-dot", "stage-dot-" + n.State} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...) + 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 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(n.State)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 210, Col: 77} + } + _, 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, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(n.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 211, Col: 36} + } + _, 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, 10, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(stageDuration(n)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 212, Col: 50} + } + _, 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, 11, "
") + 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 + } + return nil + }) +} + +// RenderPipelineString is the one-shot renderer the orchestrator +// registers at startup so it can publish pipeline fragments over SSE +// without pulling in the template package directly. +func RenderPipelineString(run *model.Run, stages []model.Stage) string { + var buf bytes.Buffer + _ = Pipeline(BuildPipeline(run, stages)).Render(context.Background(), &buf) + return buf.String() +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/pipeline_test.go b/internal/web/templates/pipeline_test.go new file mode 100644 index 0000000..9730795 --- /dev/null +++ b/internal/web/templates/pipeline_test.go @@ -0,0 +1,133 @@ +package templates + +import ( + "testing" + + "vetting/internal/model" +) + +// node indexes for the default pipeline layout: pre-stages (3) + stage +// rows (9) + terminal Completed (1) = 13 nodes. +const ( + idxQueued = 0 + idxWaitingWoL = 1 + idxBooting = 2 + idxInventory = 3 + idxSpecValidate = 4 + idxSMART = 5 + idxCPUStress = 6 + idxStorage = 7 + idxNetwork = 8 + idxGPU = 9 + idxPSU = 10 + idxReporting = 11 + idxCompleted = 12 +) + +// seedStages returns a fresh all-pending stage slice in the canonical order. +func seedStages() []model.Stage { + names := []string{"Inventory", "SpecValidate", "SMART", "CPUStress", "Storage", "Network", "GPU", "PSU", "Reporting"} + out := make([]model.Stage, len(names)) + for i, n := range names { + out[i] = model.Stage{Name: n, Ordinal: i, State: model.StagePending} + } + return out +} + +func TestBuildPipeline_NoRun(t *testing.T) { + nodes := BuildPipeline(nil, nil) + if len(nodes) != len(preStageOrder)+1 { + // No stage rows = just pre-stages + Completed. + t.Fatalf("len = %d, want %d", len(nodes), len(preStageOrder)+1) + } + for i, n := range nodes { + if n.State != "pending" { + t.Errorf("node %d (%s) state = %q, want pending", i, n.Name, n.State) + } + } +} + +func TestBuildPipeline_Running(t *testing.T) { + run := &model.Run{State: model.StateSMART} + stages := seedStages() + stages[0].State = model.StagePassed + stages[1].State = model.StagePassed + stages[2].State = model.StageRunning + nodes := BuildPipeline(run, stages) + if len(nodes) != 13 { + t.Fatalf("len = %d, want 13", len(nodes)) + } + // Pre-stages are all past for a run that has reached SMART. + for i := idxQueued; i <= idxBooting; i++ { + if nodes[i].State != "passed" { + t.Errorf("prestage %s = %q, want passed", nodes[i].Name, nodes[i].State) + } + } + if nodes[idxInventory].State != "passed" { + t.Errorf("Inventory = %q, want passed", nodes[idxInventory].State) + } + if nodes[idxSpecValidate].State != "passed" { + t.Errorf("SpecValidate = %q, want passed", nodes[idxSpecValidate].State) + } + if nodes[idxSMART].State != "running" { + t.Errorf("SMART = %q, want running", nodes[idxSMART].State) + } + if nodes[idxCPUStress].State != "pending" { + t.Errorf("CPUStress = %q, want pending", nodes[idxCPUStress].State) + } + if nodes[idxCompleted].State != "pending" { + t.Errorf("Completed = %q, want pending", nodes[idxCompleted].State) + } +} + +func TestBuildPipeline_Failed(t *testing.T) { + run := &model.Run{State: model.StateFailedHolding, FailedStage: "Storage"} + stages := seedStages() + for i := 0; i <= 3; i++ { + stages[i].State = model.StagePassed + } + stages[4].State = model.StageFailed // Storage + nodes := BuildPipeline(run, stages) + // Pre-stages are past a run that reached Storage. + for i := idxQueued; i <= idxBooting; i++ { + if nodes[i].State != "passed" { + t.Errorf("prestage %s = %q, want passed", nodes[i].Name, nodes[i].State) + } + } + if nodes[idxStorage].State != "failed" { + t.Errorf("Storage = %q, want failed", nodes[idxStorage].State) + } + for _, i := range []int{idxNetwork, idxGPU, idxPSU, idxReporting} { + if nodes[i].State != "skipped" { + t.Errorf("%s = %q, want skipped", nodes[i].Name, nodes[i].State) + } + } + if nodes[idxCompleted].State != "pending" { + t.Errorf("Completed = %q, want pending on failure", nodes[idxCompleted].State) + } +} + +func TestBuildPipeline_Completed(t *testing.T) { + run := &model.Run{State: model.StateCompleted} + stages := seedStages() + for i := range stages { + stages[i].State = model.StagePassed + } + nodes := BuildPipeline(run, stages) + for i, n := range nodes { + if n.State != "passed" { + t.Errorf("node %d (%s) state = %q, want passed", i, n.Name, n.State) + } + } +} + +func TestBuildPipeline_QueuedNow(t *testing.T) { + run := &model.Run{State: model.StateQueued} + nodes := BuildPipeline(run, seedStages()) + if nodes[idxQueued].State != "running" { + t.Errorf("Queued = %q, want running", nodes[idxQueued].State) + } + if nodes[idxWaitingWoL].State != "pending" { + t.Errorf("WaitingWoL = %q, want pending", nodes[idxWaitingWoL].State) + } +} diff --git a/internal/web/templates/registration_templ.go b/internal/web/templates/registration_templ.go index 63e790e..1d2c0d4 100644 --- a/internal/web/templates/registration_templ.go +++ b/internal/web/templates/registration_templ.go @@ -64,7 +64,7 @@ func Registration(form RegistrationForm) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(form.Error) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 19, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 19, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -83,7 +83,7 @@ func Registration(form RegistrationForm) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("curl -fsSL " + form.QuickRegisterURL + "/register/quick.sh | sudo bash") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 25, Col: 108} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 25, Col: 108} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -101,7 +101,7 @@ func Registration(form RegistrationForm) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(form.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 34, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 34, Col: 54} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -114,7 +114,7 @@ func Registration(form RegistrationForm) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(form.MAC) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 38, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 38, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -127,7 +127,7 @@ func Registration(form RegistrationForm) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(form.WoLBroadcastIP) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 43, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 43, Col: 77} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -140,7 +140,7 @@ func Registration(form RegistrationForm) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(defaultPort(form.WoLPort)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 47, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 47, Col: 77} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -153,7 +153,7 @@ func Registration(form RegistrationForm) templ.Component { var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(form.ExpectedSpecYAML) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 52, Col: 126} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 52, Col: 126} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -166,7 +166,7 @@ func Registration(form RegistrationForm) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(form.Notes) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 56, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 56, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil {