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 {