From 1694c20b12a1c9ca2ddd0bb39b4daffe71e05b95 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 18 Apr 2026 00:38:27 -0400 Subject: [PATCH] Host detail v2: full pipeline + per-stage logs + WoL diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pipeline now always renders all 13 nodes (3 pre-stage + 9 stage + Completed), synthesising ghosts from run state when stage rows aren't seeded yet. Makes a WaitingWoL host show the full timeline ahead of it instead of just 4 dots. Agent tags each log line with its stage; logs.Hub fans out to both log-{runID} and log-{runID}-{stage} SSE events so the detail page can show per-stage tabs with a pure-CSS radio-sibling switch. Flat run log prepends [stage] so grep still works. Dispatcher writes picked/sent-WoL/heartbeat lines into the per-run log — the operator opens the detail page, sees WaitingWoL stuck, and reads exactly what the dispatcher did and why nothing's progressing, instead of having to tail journalctl on the LXC. Co-Authored-By: Claude Opus 4.7 --- agent/client.go | 1 + agent/runner.go | 22 +- cmd/vetting/main.go | 3 +- internal/api/agent_handlers.go | 8 +- internal/api/host_detail_test.go | 47 +++ internal/api/ui_handlers.go | 7 + internal/logs/logs.go | 79 ++++- internal/logs/logs_test.go | 82 +++++ internal/orchestrator/dispatcher.go | 106 +++++- internal/orchestrator/dispatcher_test.go | 47 +++ internal/web/static/app.css | 123 ++++++- internal/web/templates/host_detail.templ | 51 ++- internal/web/templates/host_detail_templ.go | 353 ++++++++++++++++---- internal/web/templates/pipeline.templ | 111 ++++-- internal/web/templates/pipeline_templ.go | 117 +++++-- internal/web/templates/pipeline_test.go | 58 +++- 16 files changed, 1053 insertions(+), 162 deletions(-) create mode 100644 internal/orchestrator/dispatcher_test.go diff --git a/agent/client.go b/agent/client.go index dd9ea6b..9b6b7c4 100644 --- a/agent/client.go +++ b/agent/client.go @@ -134,6 +134,7 @@ type HeartbeatResponse struct { type LogLine struct { TS string `json:"ts,omitempty"` Level string `json:"level,omitempty"` + Stage string `json:"stage,omitempty"` Text string `json:"text"` } diff --git a/agent/runner.go b/agent/runner.go index feb6ed3..821fac5 100644 --- a/agent/runner.go +++ b/agent/runner.go @@ -120,6 +120,8 @@ func Run(ctx context.Context, p *bootstate.Params) error { // (the orchestrator persists it as an artifact). Every other stage // returns a tests.Outcome which postResult marshals generically. func runStage(ctx context.Context, stage string, claim *ClaimResponse, fwd *logForwarder, c *Client, ovr overrideFlags) stageOutcome { + fwd.SetStage(stage) + defer fwd.ClearStage() deps := newDeps(ctx, c, fwd, ovr, claim) switch stage { case "Inventory": @@ -436,6 +438,7 @@ type logForwarder struct { c *Client mu sync.Mutex buf []LogLine + stage string // set via SetStage; empties via ClearStage wg sync.WaitGroup cancel context.CancelFunc } @@ -467,7 +470,7 @@ func (f *logForwarder) push(level, text string) { stamp := time.Now().UTC().Format(time.RFC3339Nano) log.Printf("[%s] %s", level, text) f.mu.Lock() - f.buf = append(f.buf, LogLine{TS: stamp, Level: level, Text: text}) + f.buf = append(f.buf, LogLine{TS: stamp, Level: level, Stage: f.stage, Text: text}) f.mu.Unlock() } @@ -475,6 +478,23 @@ func (f *logForwarder) info(s string) { f.push("info", s) } func (f *logForwarder) warn(s string) { f.push("warn", s) } func (f *logForwarder) error(s string) { f.push("error", s) } +// SetStage tags subsequent log lines with a stage name so the orchestrator +// can fan them out on a per-stage SSE event. Safe to call concurrently +// with push — we take the same mutex. +func (f *logForwarder) SetStage(stage string) { + f.mu.Lock() + f.stage = stage + f.mu.Unlock() +} + +// ClearStage reverts to untagged (framing-level) logging. Defer this +// on entry to runStage so hold/override paths don't leak stage context. +func (f *logForwarder) ClearStage() { + f.mu.Lock() + f.stage = "" + f.mu.Unlock() +} + func (f *logForwarder) flush() { f.mu.Lock() if len(f.buf) == 0 { diff --git a/cmd/vetting/main.go b/cmd/vetting/main.go index 1b15372..dde27c6 100644 --- a/cmd/vetting/main.go +++ b/cmd/vetting/main.go @@ -103,6 +103,7 @@ func main() { SpecDiffs: specDiffStore, Artifacts: artifactStore, EventHub: hub, + Logs: logHub, Runner: runner, Tiles: tiles, PublicURL: cfg.Server.PublicURL, @@ -126,7 +127,7 @@ func main() { } agentAPI.LiveKernelURL, agentAPI.LiveInitrdURL = pxe.BuildLiveURLs(cfg.PXE.OrchestratorURL) - dispatcher := orchestrator.NewDispatcher(cfg.Dispatcher.MaxConcurrentRuns, runStore, hostStore, runner) + dispatcher := orchestrator.NewDispatcher(cfg.Dispatcher.MaxConcurrentRuns, runStore, hostStore, runner, logHub) iperfSup := orchestrator.NewIperfSupervisor(cfg.Network.IperfPort) janitorSvc := janitor.New(janitor.Config{ diff --git a/internal/api/agent_handlers.go b/internal/api/agent_handlers.go index 0239138..841980d 100644 --- a/internal/api/agent_handlers.go +++ b/internal/api/agent_handlers.go @@ -181,6 +181,11 @@ func (a *Agent) Claim(w http.ResponseWriter, r *http.Request) { } log.Printf("agent claimed: run=%d agent_ip=%s", runID, agentIP) + if a.Logs != nil { + if w, err := a.Logs.WriterFor(runID); err == nil { + w.Append(logs.Line{Level: "info", Text: fmt.Sprintf("agent claimed from %s — entering Inventory", agentIP)}) + } + } // Stage-driven agent needs a bit of per-run config: the device // allowlist (serial + expected size) for Storage, and the iperf3 @@ -331,6 +336,7 @@ type LogBatch struct { type LogLine struct { TS string `json:"ts,omitempty"` // RFC3339Nano; server clock used if empty Level string `json:"level,omitempty"` // info|warn|error|debug + Stage string `json:"stage,omitempty"` // optional stage tag for per-stage log fan-out Text string `json:"text"` } @@ -356,7 +362,7 @@ func (a *Agent) Log(w http.ResponseWriter, r *http.Request) { } for _, l := range batch.Lines { ts, _ := time.Parse(time.RFC3339Nano, l.TS) - writer.Append(logs.Line{TS: ts, Level: l.Level, Text: l.Text}) + writer.Append(logs.Line{TS: ts, Level: l.Level, Stage: l.Stage, Text: l.Text}) } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "written": len(batch.Lines)}) } diff --git a/internal/api/host_detail_test.go b/internal/api/host_detail_test.go index 9001eaa..d22b4d7 100644 --- a/internal/api/host_detail_test.go +++ b/internal/api/host_detail_test.go @@ -121,6 +121,53 @@ func TestHostDetail_NeverRun(t *testing.T) { } } +// TestHostDetail_LogTabsRendered: when a run exists, the detail page +// emits the log-tabs scaffold with one radio per stage + an "All" tab +// checked by default. CSS sibling selectors drive visibility — no JS. +func TestHostDetail_LogTabsRendered(t *testing.T) { + ui, hosts, runs := setupDetail(t) + ctx := context.Background() + id, err := hosts.Create(ctx, model.Host{ + Name: "tabs-host", + MAC: "aa:bb:cc:dd:ee:40", + WoLBroadcastIP: "10.0.0.255", + WoLPort: 9, + ExpectedSpecYAML: "memory:\n total_gib: 16\n", + }) + if err != nil { + t.Fatalf("create host: %v", err) + } + runID, err := runs.Create(ctx, id, "cafef00d") + 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", rr.Code) + } + body := rr.Body.String() + + // All tab: the default-checked radio, plus its pane. + wantAllID := fmt.Sprintf(`id="log-tab-%d-all"`, runID) + if !strings.Contains(body, wantAllID) { + t.Fatalf("body missing All tab radio %s", wantAllID) + } + // Per-stage tabs: every entry in DefaultStageOrder must have its own + // radio + pane so tabs switch purely via sibling CSS. + for _, s := range store.DefaultStageOrder { + wantRadio := fmt.Sprintf(`id="log-tab-%d-%s"`, runID, s) + if !strings.Contains(body, wantRadio) { + t.Fatalf("body missing stage tab radio %s", wantRadio) + } + wantPane := fmt.Sprintf(`id="log-%d-%s"`, runID, s) + if !strings.Contains(body, wantPane) { + t.Fatalf("body missing stage pane %s", wantPane) + } + } +} + func TestHostDetail_UnknownID(t *testing.T) { ui, _, _ := setupDetail(t) rr := httptest.NewRecorder() diff --git a/internal/api/ui_handlers.go b/internal/api/ui_handlers.go index ceeb02a..2c95f3c 100644 --- a/internal/api/ui_handlers.go +++ b/internal/api/ui_handlers.go @@ -16,6 +16,7 @@ import ( "gopkg.in/yaml.v3" "vetting/internal/events" + "vetting/internal/logs" "vetting/internal/model" "vetting/internal/orchestrator" "vetting/internal/store" @@ -30,6 +31,7 @@ type UI struct { SpecDiffs *store.SpecDiffs Artifacts *store.Artifacts EventHub *events.Hub + Logs *logs.Hub Runner *orchestrator.Runner Tiles *TileEnricher PublicURL string // user-visible base URL baked into the quick-register one-liner @@ -113,10 +115,15 @@ func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) { } } t := u.Tiles.Build(r.Context(), *host, latest) + replay := "" + if latest != nil && u.Logs != nil { + replay = u.Logs.Replay(latest.ID) + } data := templates.HostDetailData{ Tile: t, Stages: stages, SpecDiffs: diffs, + LogReplay: replay, } _ = templates.HostDetail(data).Render(r.Context(), w) } diff --git a/internal/logs/logs.go b/internal/logs/logs.go index 6f13971..32a1638 100644 --- a/internal/logs/logs.go +++ b/internal/logs/logs.go @@ -21,6 +21,7 @@ import ( type Line struct { TS time.Time Level string // info|warn|error|debug + Stage string // optional — one of store.DefaultStageOrder; empty = orchestrator/agent framing Text string } @@ -85,6 +86,54 @@ func (h *Hub) PathFor(runID int64) string { return filepath.Join(h.dir, fmt.Sprintf("run-%d.log", runID)) } +// Replay reads the on-disk log for a run and returns one +//
fragment per line, suitable for inlining into +// the "All" log pane on initial page load. Missing file → empty string; +// the pane just stays empty until live events arrive. Does not subscribe +// to the SSE hub — callers are expected to pair this with a live +// sse-swap target on the same element. +func (h *Hub) Replay(runID int64) string { + path := h.PathFor(runID) + b, err := os.ReadFile(path) + if err != nil { + return "" + } + var out strings.Builder + for _, raw := range strings.Split(string(b), "\n") { + if raw == "" { + continue + } + // Format from Append: " " + // where LEVEL is right-padded to width 5 (e.g. " INFO", + // "ERROR"). TrimLeft the pad before splitting off the level. + tsEnd := strings.IndexByte(raw, ' ') + if tsEnd < 0 { + continue + } + ts, err := time.Parse(time.RFC3339Nano, raw[:tsEnd]) + if err != nil { + continue + } + rest := strings.TrimLeft(raw[tsEnd+1:], " ") + lvEnd := strings.IndexByte(rest, ' ') + if lvEnd < 0 { + continue + } + level := strings.ToLower(rest[:lvEnd]) + text := rest[lvEnd+1:] + // Disk format prepends "[stage] " to text when stage was set. + stage := "" + if strings.HasPrefix(text, "[") { + if end := strings.Index(text, "] "); end > 1 { + stage = text[1:end] + text = text[end+2:] + } + } + out.WriteString(renderLogSSE(Line{TS: ts, Level: level, Stage: stage, Text: text})) + } + return out.String() +} + // Append writes a line to disk and publishes an SSE event. Failures // on disk log but don't block the SSE fan-out — the operator can still // see the live tail even if disk IO is degraded. @@ -97,15 +146,26 @@ func (w *Writer) Append(line Line) { if line.Level == "" { line.Level = "info" } - stamped := fmt.Sprintf("%s %5s %s\n", line.TS.Format(time.RFC3339Nano), strings.ToUpper(line.Level), line.Text) + diskText := line.Text + if line.Stage != "" { + diskText = "[" + line.Stage + "] " + diskText + } + stamped := fmt.Sprintf("%s %5s %s\n", line.TS.Format(time.RFC3339Nano), strings.ToUpper(line.Level), diskText) if _, err := w.f.WriteString(stamped); err != nil { log.Printf("logs: write run-%d: %v", w.runID, err) } if w.hub != nil { + payload := renderLogSSE(line) w.hub.Publish(events.Event{ Name: fmt.Sprintf("log-%d", w.runID), - Payload: renderLogSSE(line), + Payload: payload, }) + if line.Stage != "" { + w.hub.Publish(events.Event{ + Name: fmt.Sprintf("log-%d-%s", w.runID, line.Stage), + Payload: payload, + }) + } } } @@ -120,15 +180,22 @@ func (w *Writer) Close() error { return err } -// renderLogSSE returns an HTMX-compatible fragment. The tile contains -// a
: each event appends one -//
to it. +// renderLogSSE returns an HTMX-compatible fragment. The detail-page +// panes contain
: each event +// appends one
to them. Stage, if set, +// is rendered as a dim prefix so the "All" pane stays disambiguable +// even with multiple stages interleaved. func renderLogSSE(l Line) string { level := strings.ToLower(l.Level) + stagePrefix := "" + if l.Stage != "" { + stagePrefix = fmt.Sprintf(`[%s] `, html.EscapeString(l.Stage)) + } return fmt.Sprintf( - `
%s %s
`, + `
%s %s%s
`, html.EscapeString(level), html.EscapeString(l.TS.Format("15:04:05")), + stagePrefix, html.EscapeString(l.Text), ) } diff --git a/internal/logs/logs_test.go b/internal/logs/logs_test.go index 5678747..15c413f 100644 --- a/internal/logs/logs_test.go +++ b/internal/logs/logs_test.go @@ -76,6 +76,88 @@ func TestAppendFansOutToSSE(t *testing.T) { } } +// TestAppendStagePublishesBothEvents: a line tagged with a stage must +// fan out to BOTH the all-pane event (log-) AND the stage-pane +// event (log--) so the detail page's per-stage tabs see +// their own slice. Disk format prepends "[stage] " so the flat log +// remains greppable. +func TestAppendStagePublishesBothEvents(t *testing.T) { + dir := t.TempDir() + hub := events.NewHub() + lh, err := logs.NewHub(dir, hub) + if err != nil { + t.Fatalf("NewHub: %v", err) + } + defer lh.Close() + + _, ch, cancel := hub.Subscribe() + defer cancel() + + w, err := lh.WriterFor(42) + if err != nil { + t.Fatalf("WriterFor: %v", err) + } + w.Append(logs.Line{Level: "info", Stage: "SMART", Text: "reading attributes"}) + + got := collect(ch, 4, 500*time.Millisecond) + names := map[string]int{} + for _, ev := range got { + if strings.HasPrefix(ev.Name, "log-") { + names[ev.Name]++ + } + } + if names["log-42"] != 1 { + t.Fatalf("expected 1 event on log-42, got %d (names=%+v)", names["log-42"], names) + } + if names["log-42-SMART"] != 1 { + t.Fatalf("expected 1 event on log-42-SMART, got %d (names=%+v)", names["log-42-SMART"], names) + } + + // Disk: stage prepended so flat log is still useful. + body, err := os.ReadFile(filepath.Join(dir, "run-42.log")) + if err != nil { + t.Fatalf("read log file: %v", err) + } + if !strings.Contains(string(body), "[SMART] reading attributes") { + t.Fatalf("disk log missing stage prefix: %q", body) + } +} + +// TestReplay re-parses a file written by Append and emits the same SSE +// fragments — detail-page uses this to seed the All pane on reload of +// an in-flight run. +func TestReplay(t *testing.T) { + dir := t.TempDir() + hub := events.NewHub() + lh, err := logs.NewHub(dir, hub) + if err != nil { + t.Fatalf("NewHub: %v", err) + } + defer lh.Close() + + w, err := lh.WriterFor(99) + if err != nil { + t.Fatalf("WriterFor: %v", err) + } + w.Append(logs.Line{Level: "info", Text: "dispatcher: picked"}) + w.Append(logs.Line{Level: "info", Stage: "SMART", Text: "smartctl /dev/sda"}) + + replay := lh.Replay(99) + if !strings.Contains(replay, "dispatcher: picked") { + t.Fatalf("replay missing untagged line: %q", replay) + } + if !strings.Contains(replay, "smartctl /dev/sda") { + t.Fatalf("replay missing tagged line: %q", replay) + } + if !strings.Contains(replay, `class="log-stage"`) { + t.Fatalf("replay should render stage badge for tagged line: %q", replay) + } + // Missing file → empty string, no panic. + if got := lh.Replay(12345); got != "" { + t.Fatalf("replay of unknown run = %q, want empty", got) + } +} + // TestWriterForIsCached verifies a second call returns the same Writer // — otherwise parallel /log POSTs would race on file opens and possibly // stomp on in-flight writes. diff --git a/internal/orchestrator/dispatcher.go b/internal/orchestrator/dispatcher.go index 38c4951..4b1e2e8 100644 --- a/internal/orchestrator/dispatcher.go +++ b/internal/orchestrator/dispatcher.go @@ -2,9 +2,12 @@ package orchestrator import ( "context" + "fmt" "log" + "sync" "time" + "vetting/internal/logs" "vetting/internal/model" "vetting/internal/store" ) @@ -12,6 +15,10 @@ import ( // Dispatcher picks Queued runs off the DB and drives them through // WaitingWoL (sending a WoL packet). Concurrency is capped at Max. // +// Pre-stage log lines (picked, WoL-sent, heartbeat, agent-claimed) +// are written into the per-run log via Logs so the detail page's +// log pane can show what's happening before the agent is alive. +// // For Phase 2 the dispatcher's job ends at WaitingWoL; further // transitions are driven by iPXE and agent callbacks. Phase 4+ will // return here and shepherd each run through stage execution. @@ -20,22 +27,32 @@ type Dispatcher struct { Runs *store.Runs Hosts *store.Hosts Runner *Runner + Logs *logs.Hub active chan struct{} stop chan struct{} + + // heartbeat tracks the last time we emitted a "still waiting" + // line for a given run, so the ticker doesn't spam the log. + hbMu sync.Mutex + lastBeat map[int64]time.Time + beatEvery time.Duration } -func NewDispatcher(max int, runs *store.Runs, hosts *store.Hosts, runner *Runner) *Dispatcher { +func NewDispatcher(max int, runs *store.Runs, hosts *store.Hosts, runner *Runner, logHub *logs.Hub) *Dispatcher { if max < 1 { max = 1 } return &Dispatcher{ - Max: max, - Runs: runs, - Hosts: hosts, - Runner: runner, - active: make(chan struct{}, max), - stop: make(chan struct{}), + Max: max, + Runs: runs, + Hosts: hosts, + Runner: runner, + Logs: logHub, + active: make(chan struct{}, max), + stop: make(chan struct{}), + lastBeat: map[int64]time.Time{}, + beatEvery: 30 * time.Second, } } @@ -58,6 +75,7 @@ func (d *Dispatcher) loop(ctx context.Context) { return case <-t.C: d.pickNext(ctx) + d.heartbeatWaiting(ctx) } } } @@ -106,19 +124,93 @@ func (d *Dispatcher) pickNext(ctx context.Context) { log.Printf("dispatcher: get host %d: %v", queued.HostID, err) return } + d.runLog(queued.ID, "info", fmt.Sprintf("dispatcher: picked run for host %s (mac=%s wol=%s:%d)", + host.Name, host.MAC, host.WoLBroadcastIP, host.WoLPort)) if _, err := d.Runner.Transition(ctx, queued.ID, TriggerDispatched); err != nil { log.Printf("dispatcher: transition run %d: %v", queued.ID, err) + d.runLog(queued.ID, "error", fmt.Sprintf("dispatcher: transition to WaitingWoL failed: %v", err)) return } if err := SendWoL(host.MAC, host.WoLBroadcastIP, host.WoLPort); err != nil { log.Printf("dispatcher: WoL run %d host %s: %v", queued.ID, host.Name, err) + d.runLog(queued.ID, "error", fmt.Sprintf("dispatcher: WoL send failed: %v — check broadcast %s:%d is reachable", + err, host.WoLBroadcastIP, host.WoLPort)) // Stay in WaitingWoL; operator can retry or investigate. return } log.Printf("dispatcher: WoL sent for run %d (host=%s mac=%s)", queued.ID, host.Name, host.MAC) + d.runLog(queued.ID, "info", fmt.Sprintf("dispatcher: sent WoL packet to %s via %s:%d — waiting for agent claim", + host.MAC, host.WoLBroadcastIP, host.WoLPort)) + + // Prime the heartbeat so the first "still waiting" fires 30s after + // dispatch, not immediately. + d.hbMu.Lock() + d.lastBeat[queued.ID] = time.Now() + d.hbMu.Unlock() // Slot stays reserved until the run leaves active (Phase 4+). // Phase 2 lets the loop observe inFlight via DB state. released = true <-d.active } + +// heartbeatWaiting emits a "still waiting" log line every beatEvery for +// each run still sitting in WaitingWoL. Helps the operator spot hangs +// without having to tail journalctl on the LXC. +func (d *Dispatcher) heartbeatWaiting(ctx context.Context) { + if d.Logs == nil { + return + } + runs, err := d.Runs.Active(ctx) + if err != nil { + return + } + now := time.Now() + d.hbMu.Lock() + defer d.hbMu.Unlock() + seen := map[int64]bool{} + for i := range runs { + r := &runs[i] + seen[r.ID] = true + if r.State != model.StateWaitingWoL { + continue + } + last, ok := d.lastBeat[r.ID] + if !ok { + // Run already in WaitingWoL from a previous process lifetime + // — prime so we don't spam immediately. + d.lastBeat[r.ID] = now + continue + } + if now.Sub(last) < d.beatEvery { + continue + } + elapsed := now.Sub(r.StartedAt).Truncate(time.Second) + d.runLog(r.ID, "info", fmt.Sprintf( + "still waiting for agent claim (%s) — check BIOS WoL, pxe.enabled, and live-image presence", + elapsed)) + d.lastBeat[r.ID] = now + } + // Garbage-collect entries for runs that have left WaitingWoL. + for id := range d.lastBeat { + if !seen[id] { + delete(d.lastBeat, id) + } + } +} + +// runLog writes a single line into the per-run log. Safe to call with a +// nil hub (tests construct Dispatcher directly) — it degrades to a +// stderr log line so nothing silently disappears. +func (d *Dispatcher) runLog(runID int64, level, text string) { + if d.Logs == nil { + log.Printf("run-%d %s: %s", runID, level, text) + return + } + w, err := d.Logs.WriterFor(runID) + if err != nil { + log.Printf("dispatcher: open log for run %d: %v", runID, err) + return + } + w.Append(logs.Line{Level: level, Text: text}) +} diff --git a/internal/orchestrator/dispatcher_test.go b/internal/orchestrator/dispatcher_test.go new file mode 100644 index 0000000..4dea228 --- /dev/null +++ b/internal/orchestrator/dispatcher_test.go @@ -0,0 +1,47 @@ +package orchestrator + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "vetting/internal/events" + "vetting/internal/logs" +) + +// TestDispatcher_RunLogWritesToHub verifies the plumbing between the +// dispatcher and the per-run log hub: runLog must persist to the on-disk +// file so the detail page's replay + SSE fan-out see the same +// pre-stage diagnostics (picked / sent WoL / heartbeat). +func TestDispatcher_RunLogWritesToHub(t *testing.T) { + dir := t.TempDir() + ev := events.NewHub() + lh, err := logs.NewHub(dir, ev) + if err != nil { + t.Fatalf("NewHub: %v", err) + } + defer lh.Close() + + d := &Dispatcher{Logs: lh} + d.runLog(7, "info", "dispatcher: sent WoL packet to aa:bb:cc:dd:ee:ff via 10.0.0.255:9") + + body, err := os.ReadFile(filepath.Join(dir, "run-7.log")) + if err != nil { + t.Fatalf("read run log: %v", err) + } + if !strings.Contains(string(body), "dispatcher: sent WoL packet") { + t.Fatalf("run log missing dispatcher line: %q", body) + } + if !strings.Contains(string(body), "INFO") { + t.Fatalf("run log missing level: %q", body) + } +} + +// TestDispatcher_RunLogNilHubDoesNotPanic: tests construct Dispatcher +// directly without a hub. runLog must degrade to stderr rather than +// panicking so the dispatcher loop stays alive. +func TestDispatcher_RunLogNilHubDoesNotPanic(t *testing.T) { + d := &Dispatcher{} + d.runLog(1, "info", "fallback path") +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 5dfbb50..0f284e7 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -366,6 +366,79 @@ body.bare main { max-width: none; } .detail-log .log-warn { color: var(--warn); } .detail-log .log-error { color: var(--danger); } +/* ===== Log tabs (CSS-only radio switch) ===== */ +/* Radios are visually hidden but still functional: checked state is read + by sibling selectors below to flip the active label + pane. */ +.log-tabs { + display: flex; + flex-wrap: wrap; + gap: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #0b0d12; + overflow: hidden; +} +.log-tab-input { + position: absolute; + opacity: 0; + pointer-events: none; + width: 0; + height: 0; +} +.log-tab-label { + display: inline-flex; + align-items: center; + padding: 8px 14px; + font-size: 12px; + font-family: var(--mono); + color: var(--text-dim); + cursor: pointer; + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + user-select: none; + background: transparent; + transition: background .12s ease, color .12s ease; +} +.log-tab-label:hover { background: rgba(255,255,255,.03); color: var(--text); } +/* Active tab: coloured bar + filled background. */ +.log-tab-input:checked + .log-tab-label { + background: var(--bg-elev-2); + color: var(--text); + box-shadow: inset 0 -2px 0 0 var(--accent); +} +.log-pane { + display: none; + flex-basis: 100%; + flex-direction: column; + gap: 2px; + padding: 10px 12px; + font-family: var(--mono); + font-size: 12px; + color: var(--text-dim); + max-height: 500px; + overflow-y: auto; + order: 99; /* keep panes below the tab labels in flex order */ +} +.log-pane .log-line { white-space: pre-wrap; } +.log-pane .log-warn { color: var(--warn); } +.log-pane .log-error { color: var(--danger); } +.log-pane .log-stage { color: var(--text-dim); opacity: .75; margin-right: 4px; } +.log-pane:empty::before { content: "(no log output yet)"; color: var(--text-dim); opacity: .5; } + +/* Sibling-selector cascade: .log-tab-all:checked flips .log-pane-all, + .log-tab-Inventory:checked flips .log-pane-Inventory, etc. The radios + live as siblings of the panes inside .log-tabs, so ~ works. */ +.log-tab-all:checked ~ .log-pane-all { display: flex; } +.log-tab-Inventory:checked ~ .log-pane-Inventory { display: flex; } +.log-tab-SpecValidate:checked ~ .log-pane-SpecValidate { display: flex; } +.log-tab-SMART:checked ~ .log-pane-SMART { display: flex; } +.log-tab-CPUStress:checked ~ .log-pane-CPUStress { display: flex; } +.log-tab-Storage:checked ~ .log-pane-Storage { display: flex; } +.log-tab-Network:checked ~ .log-pane-Network { display: flex; } +.log-tab-GPU:checked ~ .log-pane-GPU { display: flex; } +.log-tab-PSU:checked ~ .log-pane-PSU { display: flex; } +.log-tab-Reporting:checked ~ .log-pane-Reporting { display: flex; } + .diff-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } .diff-row { display: grid; @@ -399,57 +472,73 @@ body.bare main { max-width: none; } margin: 0; } -/* ===== Pipeline timeline ===== */ +/* ===== Pipeline timeline ===== + 13 nodes (3 pre-stage + 9 stage + Completed). flex:1 on every node + so they share the full width evenly; overflow-x:auto only kicks in + on very narrow viewports. */ .pipeline { display: flex; align-items: stretch; gap: 0; + width: 100%; overflow-x: auto; - padding: 12px 4px 6px; + padding: 28px 12px 16px; + background: #0b0d12; + border: 1px solid var(--border); + border-radius: var(--radius); } .stage-node { display: flex; flex-direction: column; align-items: center; - gap: 4px; - min-width: 82px; - padding: 0 6px; - flex-shrink: 0; + gap: 6px; + flex: 1 1 0; + min-width: 72px; + padding: 0 4px; } .stage-dot { - width: 22px; - height: 22px; + width: 28px; + height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; - font-size: 12px; + font-size: 14px; font-weight: 700; border: 2px solid var(--border); background: var(--bg-elev-2); color: var(--text-dim); line-height: 1; + transition: transform .12s ease, box-shadow .12s ease; } +.stage-node:hover .stage-dot { transform: scale(1.12); } .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-name { + font-size: 12px; + color: var(--text-dim); + text-align: center; + font-weight: 500; + line-height: 1.2; +} .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-running .stage-name { color: var(--accent); font-weight: 600; } +.stage-node-failed .stage-name { color: var(--danger); font-weight: 600; } .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; + flex: 1 1 0; + min-width: 8px; + height: 3px; align-self: center; - margin-top: -18px; + margin-top: -30px; background: var(--border); + border-radius: 2px; } .stage-connector-passed { background: var(--success); } .stage-connector-running { background: linear-gradient(90deg, var(--success), var(--accent)); } @@ -458,5 +547,5 @@ body.bare main { max-width: none; } @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); } + 50% { box-shadow: 0 0 0 8px rgba(60,130,246,0); } } diff --git a/internal/web/templates/host_detail.templ b/internal/web/templates/host_detail.templ index 42d5f3b..3415cac 100644 --- a/internal/web/templates/host_detail.templ +++ b/internal/web/templates/host_detail.templ @@ -4,16 +4,20 @@ import ( "fmt" "vetting/internal/model" + "vetting/internal/store" ) // 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. +// and diff list. LogReplay is the pre-rendered history fragment +// produced by logs.Hub.Replay on the initial page render so the operator +// sees prior output without waiting for a fresh SSE event. type HostDetailData struct { Tile TileData Stages []model.Stage SpecDiffs []model.SpecDiff + LogReplay string } templ HostDetail(d HostDetailData) { @@ -123,15 +127,7 @@ templ HostDetail(d HostDetailData) { } if d.Tile.Latest != nil { -
-

Log

-
-
+ @LogTabs(d.Tile.Latest.ID, d.LogReplay) }
@@ -163,3 +159,38 @@ func hasCriticalDiff(diffs []model.SpecDiff) bool { } return false } + +// LogTabs renders an "All" tab plus one tab per stage in DefaultStageOrder. +// Switching is pure CSS: hidden radio inputs drive sibling-selector +// visibility on the panes. Each pane carries its own sse-swap target so +// live events append only to the relevant pane. The All pane is seeded +// with replay HTML so reload on an in-flight run still shows history. +templ LogTabs(runID int64, replay string) { +
+

Log

+
+ + + for _, s := range store.DefaultStageOrder { + + + } +
+ @templ.Raw(replay) +
+ for _, s := range store.DefaultStageOrder { +
+ } +
+
+} diff --git a/internal/web/templates/host_detail_templ.go b/internal/web/templates/host_detail_templ.go index 3c8cc6f..f5fc45d 100644 --- a/internal/web/templates/host_detail_templ.go +++ b/internal/web/templates/host_detail_templ.go @@ -12,16 +12,20 @@ import ( "fmt" "vetting/internal/model" + "vetting/internal/store" ) // 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. +// and diff list. LogReplay is the pre-rendered history fragment +// produced by logs.Hub.Replay on the initial page render so the operator +// sees prior output without waiting for a fresh SSE event. type HostDetailData struct { Tile TileData Stages []model.Stage SpecDiffs []model.SpecDiff + LogReplay string } func HostDetail(d HostDetailData) templ.Component { @@ -64,7 +68,7 @@ func HostDetail(d HostDetailData) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, 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: 25, Col: 28} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 29, Col: 28} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -99,7 +103,7 @@ func HostDetail(d HostDetailData) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 34, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -134,7 +138,7 @@ func HostDetail(d HostDetailData) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 36, Col: 107} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -147,7 +151,7 @@ func HostDetail(d HostDetailData) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 37, Col: 59} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -160,7 +164,7 @@ func HostDetail(d HostDetailData) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 43, Col: 27} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -173,7 +177,7 @@ func HostDetail(d HostDetailData) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 47, Col: 81} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -191,7 +195,7 @@ func HostDetail(d HostDetailData) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 52, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -210,7 +214,7 @@ func HostDetail(d HostDetailData) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 58, Col: 76} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -233,7 +237,7 @@ func HostDetail(d HostDetailData) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 62, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 66, Col: 54} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -246,7 +250,7 @@ func HostDetail(d HostDetailData) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 64, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 68, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -286,7 +290,7 @@ func HostDetail(d HostDetailData) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 84, Col: 85} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { @@ -309,7 +313,7 @@ func HostDetail(d HostDetailData) templ.Component { var templ_7745c5c3_Var18 templ.SafeURL templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Tile.Host.ID))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 88, Col: 96} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 92, Col: 96} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { @@ -333,7 +337,7 @@ func HostDetail(d HostDetailData) templ.Component { var templ_7745c5c3_Var19 templ.SafeURL templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Tile.Host.ID))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 95, Col: 104} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 99, Col: 104} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { @@ -352,7 +356,7 @@ func HostDetail(d HostDetailData) templ.Component { var templ_7745c5c3_Var20 templ.SafeURL templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", d.Tile.Latest.ID))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 100, Col: 95} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 104, Col: 95} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { @@ -370,7 +374,7 @@ func HostDetail(d HostDetailData) templ.Component { var templ_7745c5c3_Var21 templ.SafeURL templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Tile.Host.ID))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 102, Col: 96} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 106, Col: 96} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { @@ -398,7 +402,7 @@ func HostDetail(d HostDetailData) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 115, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { @@ -434,7 +438,7 @@ func HostDetail(d HostDetailData) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 119, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { @@ -447,7 +451,7 @@ func HostDetail(d HostDetailData) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 120, Col: 67} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) if templ_7745c5c3_Err != nil { @@ -460,7 +464,7 @@ func HostDetail(d HostDetailData) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 121, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { @@ -477,74 +481,48 @@ func HostDetail(d HostDetailData) templ.Component { } } if d.Tile.Latest != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "

Log

Host details

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

Notes

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var28 string - templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d", d.Tile.Latest.ID)) + templ_7745c5c3_Var28, 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: 130, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 139, Col: 29} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" sse-swap=\"") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var29 string - templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d", d.Tile.Latest.ID)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 131, Col: 56} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" hx-swap=\"beforeend\">

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "

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

Host details

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "

Expected spec

")
 			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 - } + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, 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: 144, Col: 66} } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "

Expected spec

")
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
 			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, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -569,4 +547,245 @@ func hasCriticalDiff(diffs []model.SpecDiff) bool { return false } +// LogTabs renders an "All" tab plus one tab per stage in DefaultStageOrder. +// Switching is pure CSS: hidden radio inputs drive sibling-selector +// visibility on the panes. Each pane carries its own sse-swap target so +// live events append only to the relevant pane. The All pane is seeded +// with replay HTML so reload on an in-flight run still shows history. +func LogTabs(runID int64, replay string) 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_Var30 := templ.GetChildren(ctx) + if templ_7745c5c3_Var30 == nil { + templ_7745c5c3_Var30 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "

Log

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, s := range store.DefaultStageOrder { + var templ_7745c5c3_Var34 = []any{"log-tab-input", "log-tab-" + s} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var34...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.Raw(replay).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, s := range store.DefaultStageOrder { + var templ_7745c5c3_Var42 = []any{"log-pane", "log-pane-" + s} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var42...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/pipeline.templ b/internal/web/templates/pipeline.templ index cdc0277..ba86737 100644 --- a/internal/web/templates/pipeline.templ +++ b/internal/web/templates/pipeline.templ @@ -7,6 +7,7 @@ import ( "time" "vetting/internal/model" + "vetting/internal/store" ) // PipelineNode is one dot on the detail-page timeline. The template @@ -58,12 +59,16 @@ func runStateRank(s model.RunState) int { } // BuildPipeline projects (run, stages) into a linear slice of nodes -// covering the whole lifecycle: pre-stage → stage rows → Completed. +// covering the whole lifecycle: pre-stage → all 9 stage nodes → +// Completed. Every stage in store.DefaultStageOrder always appears, +// even if its row hasn't been seeded yet — those show as "pending" +// ghosts. This way a run stuck in WaitingWoL (stages unseeded until +// /claim) still shows the full pipeline ahead of it. // // 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) + nodes := make([]PipelineNode, 0, len(preStageOrder)+len(store.DefaultStageOrder)+1) // --- pre-stage nodes --- for _, ps := range preStageOrder { @@ -85,28 +90,41 @@ func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode { nodes = append(nodes, n) } - // --- stage nodes (from stage rows) --- - failedBefore := false + // --- stage nodes --- + // Iterate DefaultStageOrder, not the stages slice, so the list is + // always the full 9 nodes. For each stage, prefer the persisted row + // if it exists; otherwise synthesize a ghost whose state is derived + // from run state (passed if we've advanced past this stage's + // RunState, running if we're in it, skipped if a prior stage failed, + // pending otherwise). + stageByName := make(map[string]model.Stage, len(stages)) 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" + stageByName[st.Name] = st + } + failedBefore := false + for _, name := range store.DefaultStageOrder { + n := PipelineNode{Name: name} + if st, ok := stageByName[name]; ok { + n.StartedAt = st.StartedAt + n.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" + } + } else { + // Ghost: no row seeded yet. Derive from run state. + n.State = ghostStageState(run, name, failedBefore) } nodes = append(nodes, n) } @@ -122,6 +140,53 @@ func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode { return nodes } +// ghostStageState derives a pipeline-node state for a stage with no DB +// row — either the run hasn't reached /claim yet (pre-seed) or the stage +// is simply later than the run's current state. Mirrors the seeded-row +// logic so a ghost node transitions through the same visual states as a +// real one. +func ghostStageState(run *model.Run, name string, failedBefore bool) string { + if failedBefore { + return "skipped" + } + if run == nil { + return "pending" + } + // Failed/FailedHolding: anything past the failed stage is skipped. + if run.State == model.StateFailed || run.State == model.StateFailedHolding { + if run.FailedStage != "" { + failedRank, ok1 := stageRank(run.FailedStage) + myRank, ok2 := stageRank(name) + if ok1 && ok2 && myRank > failedRank { + return "skipped" + } + } + return "pending" + } + stageState, ok := stageStateByName(name) + if !ok { + return "pending" + } + switch { + case run.State == stageState: + return "running" + case runStateRank(run.State) > runStateRank(stageState): + return "passed" + } + return "pending" +} + +// stageRank returns the ordinal of a stage within DefaultStageOrder, +// used to decide which stages are "after" a failed stage. +func stageRank(name string) (int, bool) { + for i, s := range store.DefaultStageOrder { + if s == name { + return i, true + } + } + return -1, false +} + // 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 diff --git a/internal/web/templates/pipeline_templ.go b/internal/web/templates/pipeline_templ.go index a58aea8..3831f4e 100644 --- a/internal/web/templates/pipeline_templ.go +++ b/internal/web/templates/pipeline_templ.go @@ -15,6 +15,7 @@ import ( "time" "vetting/internal/model" + "vetting/internal/store" ) // PipelineNode is one dot on the detail-page timeline. The template @@ -66,12 +67,16 @@ func runStateRank(s model.RunState) int { } // BuildPipeline projects (run, stages) into a linear slice of nodes -// covering the whole lifecycle: pre-stage → stage rows → Completed. +// covering the whole lifecycle: pre-stage → all 9 stage nodes → +// Completed. Every stage in store.DefaultStageOrder always appears, +// even if its row hasn't been seeded yet — those show as "pending" +// ghosts. This way a run stuck in WaitingWoL (stages unseeded until +// /claim) still shows the full pipeline ahead of it. // // 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) + nodes := make([]PipelineNode, 0, len(preStageOrder)+len(store.DefaultStageOrder)+1) // --- pre-stage nodes --- for _, ps := range preStageOrder { @@ -93,28 +98,41 @@ func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode { nodes = append(nodes, n) } - // --- stage nodes (from stage rows) --- - failedBefore := false + // --- stage nodes --- + // Iterate DefaultStageOrder, not the stages slice, so the list is + // always the full 9 nodes. For each stage, prefer the persisted row + // if it exists; otherwise synthesize a ghost whose state is derived + // from run state (passed if we've advanced past this stage's + // RunState, running if we're in it, skipped if a prior stage failed, + // pending otherwise). + stageByName := make(map[string]model.Stage, len(stages)) 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" + stageByName[st.Name] = st + } + failedBefore := false + for _, name := range store.DefaultStageOrder { + n := PipelineNode{Name: name} + if st, ok := stageByName[name]; ok { + n.StartedAt = st.StartedAt + n.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" + } + } else { + // Ghost: no row seeded yet. Derive from run state. + n.State = ghostStageState(run, name, failedBefore) } nodes = append(nodes, n) } @@ -130,6 +148,53 @@ func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode { return nodes } +// ghostStageState derives a pipeline-node state for a stage with no DB +// row — either the run hasn't reached /claim yet (pre-seed) or the stage +// is simply later than the run's current state. Mirrors the seeded-row +// logic so a ghost node transitions through the same visual states as a +// real one. +func ghostStageState(run *model.Run, name string, failedBefore bool) string { + if failedBefore { + return "skipped" + } + if run == nil { + return "pending" + } + // Failed/FailedHolding: anything past the failed stage is skipped. + if run.State == model.StateFailed || run.State == model.StateFailedHolding { + if run.FailedStage != "" { + failedRank, ok1 := stageRank(run.FailedStage) + myRank, ok2 := stageRank(name) + if ok1 && ok2 && myRank > failedRank { + return "skipped" + } + } + return "pending" + } + stageState, ok := stageStateByName(name) + if !ok { + return "pending" + } + switch { + case run.State == stageState: + return "running" + case runStateRank(run.State) > runStateRank(stageState): + return "passed" + } + return "pending" +} + +// stageRank returns the ordinal of a stage within DefaultStageOrder, +// used to decide which stages are "after" a failed stage. +func stageRank(name string) (int, bool) { + for i, s := range store.DefaultStageOrder { + if s == name { + return i, true + } + } + return -1, false +} + // 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 @@ -309,7 +374,7 @@ func Pipeline(nodes []PipelineNode) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 275, Col: 77} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -322,7 +387,7 @@ func Pipeline(nodes []PipelineNode) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 276, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -335,7 +400,7 @@ func Pipeline(nodes []PipelineNode) templ.Component { 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} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 277, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { diff --git a/internal/web/templates/pipeline_test.go b/internal/web/templates/pipeline_test.go index 9730795..d88ea8e 100644 --- a/internal/web/templates/pipeline_test.go +++ b/internal/web/templates/pipeline_test.go @@ -36,9 +36,10 @@ func seedStages() []model.Stage { 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) + // Ghost pipeline: 3 pre-stages + 9 stage ghosts + 1 terminal = 13 + // nodes, all pending. + if len(nodes) != 13 { + t.Fatalf("len = %d, want 13", len(nodes)) } for i, n := range nodes { if n.State != "pending" { @@ -47,6 +48,57 @@ func TestBuildPipeline_NoRun(t *testing.T) { } } +// TestBuildPipeline_GhostStagesBeforeClaim models the real WaitingWoL +// case: the run exists but agent hasn't called /claim yet, so there are +// no stage rows. Pipeline must still render all 9 stage nodes as ghosts +// so the operator sees the full timeline ahead of them. +func TestBuildPipeline_GhostStagesBeforeClaim(t *testing.T) { + run := &model.Run{State: model.StateWaitingWoL} + nodes := BuildPipeline(run, nil) + if len(nodes) != 13 { + t.Fatalf("len = %d, want 13", len(nodes)) + } + if nodes[idxQueued].State != "passed" { + t.Errorf("Queued = %q, want passed", nodes[idxQueued].State) + } + if nodes[idxWaitingWoL].State != "running" { + t.Errorf("WaitingWoL = %q, want running", nodes[idxWaitingWoL].State) + } + // All 9 stage ghosts must be pending — nothing has started yet. + for i := idxInventory; i <= idxReporting; i++ { + if nodes[i].State != "pending" { + t.Errorf("%s (ghost) = %q, want pending", nodes[i].Name, nodes[i].State) + } + } + if nodes[idxCompleted].State != "pending" { + t.Errorf("Completed = %q, want pending", nodes[idxCompleted].State) + } +} + +// TestBuildPipeline_GhostStagesDuringStage models the in-flight case +// with only some stage rows seeded: later stages must still appear as +// pending ghosts rather than silently disappearing. +func TestBuildPipeline_GhostStagesDuringStage(t *testing.T) { + run := &model.Run{State: model.StateSMART} + // Only Inventory + SpecValidate seeded; SMART onwards are ghosts. + stages := []model.Stage{ + {Name: "Inventory", Ordinal: 0, State: model.StagePassed}, + {Name: "SpecValidate", Ordinal: 1, State: model.StagePassed}, + } + nodes := BuildPipeline(run, stages) + if len(nodes) != 13 { + t.Fatalf("len = %d, want 13", len(nodes)) + } + if nodes[idxSMART].State != "running" { + t.Errorf("SMART (ghost) = %q, want running", nodes[idxSMART].State) + } + for _, i := range []int{idxCPUStress, idxStorage, idxNetwork, idxGPU, idxPSU, idxReporting} { + if nodes[i].State != "pending" { + t.Errorf("%s (ghost) = %q, want pending", nodes[i].Name, nodes[i].State) + } + } +} + func TestBuildPipeline_Running(t *testing.T) { run := &model.Run{State: model.StateSMART} stages := seedStages()