Host detail v2: full pipeline + per-stage logs + WoL diagnostics
CI / Lint + build + test (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 00:38:27 -04:00
parent a3d5e2d0a4
commit 1694c20b12
16 changed files with 1053 additions and 162 deletions
+1
View File
@@ -134,6 +134,7 @@ type HeartbeatResponse struct {
type LogLine struct { type LogLine struct {
TS string `json:"ts,omitempty"` TS string `json:"ts,omitempty"`
Level string `json:"level,omitempty"` Level string `json:"level,omitempty"`
Stage string `json:"stage,omitempty"`
Text string `json:"text"` Text string `json:"text"`
} }
+21 -1
View File
@@ -120,6 +120,8 @@ func Run(ctx context.Context, p *bootstate.Params) error {
// (the orchestrator persists it as an artifact). Every other stage // (the orchestrator persists it as an artifact). Every other stage
// returns a tests.Outcome which postResult marshals generically. // returns a tests.Outcome which postResult marshals generically.
func runStage(ctx context.Context, stage string, claim *ClaimResponse, fwd *logForwarder, c *Client, ovr overrideFlags) stageOutcome { 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) deps := newDeps(ctx, c, fwd, ovr, claim)
switch stage { switch stage {
case "Inventory": case "Inventory":
@@ -436,6 +438,7 @@ type logForwarder struct {
c *Client c *Client
mu sync.Mutex mu sync.Mutex
buf []LogLine buf []LogLine
stage string // set via SetStage; empties via ClearStage
wg sync.WaitGroup wg sync.WaitGroup
cancel context.CancelFunc cancel context.CancelFunc
} }
@@ -467,7 +470,7 @@ func (f *logForwarder) push(level, text string) {
stamp := time.Now().UTC().Format(time.RFC3339Nano) stamp := time.Now().UTC().Format(time.RFC3339Nano)
log.Printf("[%s] %s", level, text) log.Printf("[%s] %s", level, text)
f.mu.Lock() 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() 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) warn(s string) { f.push("warn", s) }
func (f *logForwarder) error(s string) { f.push("error", 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() { func (f *logForwarder) flush() {
f.mu.Lock() f.mu.Lock()
if len(f.buf) == 0 { if len(f.buf) == 0 {
+2 -1
View File
@@ -103,6 +103,7 @@ func main() {
SpecDiffs: specDiffStore, SpecDiffs: specDiffStore,
Artifacts: artifactStore, Artifacts: artifactStore,
EventHub: hub, EventHub: hub,
Logs: logHub,
Runner: runner, Runner: runner,
Tiles: tiles, Tiles: tiles,
PublicURL: cfg.Server.PublicURL, PublicURL: cfg.Server.PublicURL,
@@ -126,7 +127,7 @@ func main() {
} }
agentAPI.LiveKernelURL, agentAPI.LiveInitrdURL = pxe.BuildLiveURLs(cfg.PXE.OrchestratorURL) 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) iperfSup := orchestrator.NewIperfSupervisor(cfg.Network.IperfPort)
janitorSvc := janitor.New(janitor.Config{ janitorSvc := janitor.New(janitor.Config{
+7 -1
View File
@@ -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) 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 // Stage-driven agent needs a bit of per-run config: the device
// allowlist (serial + expected size) for Storage, and the iperf3 // allowlist (serial + expected size) for Storage, and the iperf3
@@ -331,6 +336,7 @@ type LogBatch struct {
type LogLine struct { type LogLine struct {
TS string `json:"ts,omitempty"` // RFC3339Nano; server clock used if empty TS string `json:"ts,omitempty"` // RFC3339Nano; server clock used if empty
Level string `json:"level,omitempty"` // info|warn|error|debug 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"` Text string `json:"text"`
} }
@@ -356,7 +362,7 @@ func (a *Agent) Log(w http.ResponseWriter, r *http.Request) {
} }
for _, l := range batch.Lines { for _, l := range batch.Lines {
ts, _ := time.Parse(time.RFC3339Nano, l.TS) 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)}) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "written": len(batch.Lines)})
} }
+47
View File
@@ -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) { func TestHostDetail_UnknownID(t *testing.T) {
ui, _, _ := setupDetail(t) ui, _, _ := setupDetail(t)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
+7
View File
@@ -16,6 +16,7 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"vetting/internal/events" "vetting/internal/events"
"vetting/internal/logs"
"vetting/internal/model" "vetting/internal/model"
"vetting/internal/orchestrator" "vetting/internal/orchestrator"
"vetting/internal/store" "vetting/internal/store"
@@ -30,6 +31,7 @@ type UI struct {
SpecDiffs *store.SpecDiffs SpecDiffs *store.SpecDiffs
Artifacts *store.Artifacts Artifacts *store.Artifacts
EventHub *events.Hub EventHub *events.Hub
Logs *logs.Hub
Runner *orchestrator.Runner Runner *orchestrator.Runner
Tiles *TileEnricher Tiles *TileEnricher
PublicURL string // user-visible base URL baked into the quick-register one-liner 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) t := u.Tiles.Build(r.Context(), *host, latest)
replay := ""
if latest != nil && u.Logs != nil {
replay = u.Logs.Replay(latest.ID)
}
data := templates.HostDetailData{ data := templates.HostDetailData{
Tile: t, Tile: t,
Stages: stages, Stages: stages,
SpecDiffs: diffs, SpecDiffs: diffs,
LogReplay: replay,
} }
_ = templates.HostDetail(data).Render(r.Context(), w) _ = templates.HostDetail(data).Render(r.Context(), w)
} }
+73 -6
View File
@@ -21,6 +21,7 @@ import (
type Line struct { type Line struct {
TS time.Time TS time.Time
Level string // info|warn|error|debug Level string // info|warn|error|debug
Stage string // optional — one of store.DefaultStageOrder; empty = orchestrator/agent framing
Text string Text string
} }
@@ -85,6 +86,54 @@ func (h *Hub) PathFor(runID int64) string {
return filepath.Join(h.dir, fmt.Sprintf("run-%d.log", runID)) return filepath.Join(h.dir, fmt.Sprintf("run-%d.log", runID))
} }
// Replay reads the on-disk log for a run and returns one
// <div class="log-line"> 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: "<RFC3339Nano> <LEVEL> <text>"
// 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 // 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 // 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. // see the live tail even if disk IO is degraded.
@@ -97,15 +146,26 @@ func (w *Writer) Append(line Line) {
if line.Level == "" { if line.Level == "" {
line.Level = "info" 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 { if _, err := w.f.WriteString(stamped); err != nil {
log.Printf("logs: write run-%d: %v", w.runID, err) log.Printf("logs: write run-%d: %v", w.runID, err)
} }
if w.hub != nil { if w.hub != nil {
payload := renderLogSSE(line)
w.hub.Publish(events.Event{ w.hub.Publish(events.Event{
Name: fmt.Sprintf("log-%d", w.runID), 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 return err
} }
// renderLogSSE returns an HTMX-compatible fragment. The tile contains // renderLogSSE returns an HTMX-compatible fragment. The detail-page
// a <div id="log-N" hx-swap-oob="beforeend">: each event appends one // panes contain <div id="log-N-..." hx-swap="beforeend">: each event
// <div class="log-line log-LEVEL"> to it. // appends one <div class="log-line log-LEVEL"> 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 { func renderLogSSE(l Line) string {
level := strings.ToLower(l.Level) level := strings.ToLower(l.Level)
stagePrefix := ""
if l.Stage != "" {
stagePrefix = fmt.Sprintf(`<span class="log-stage">[%s]</span> `, html.EscapeString(l.Stage))
}
return fmt.Sprintf( return fmt.Sprintf(
`<div class="log-line log-%s">%s %s</div>`, `<div class="log-line log-%s">%s %s%s</div>`,
html.EscapeString(level), html.EscapeString(level),
html.EscapeString(l.TS.Format("15:04:05")), html.EscapeString(l.TS.Format("15:04:05")),
stagePrefix,
html.EscapeString(l.Text), html.EscapeString(l.Text),
) )
} }
+82
View File
@@ -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-<runID>) AND the stage-pane
// event (log-<runID>-<stage>) 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 // TestWriterForIsCached verifies a second call returns the same Writer
// — otherwise parallel /log POSTs would race on file opens and possibly // — otherwise parallel /log POSTs would race on file opens and possibly
// stomp on in-flight writes. // stomp on in-flight writes.
+99 -7
View File
@@ -2,9 +2,12 @@ package orchestrator
import ( import (
"context" "context"
"fmt"
"log" "log"
"sync"
"time" "time"
"vetting/internal/logs"
"vetting/internal/model" "vetting/internal/model"
"vetting/internal/store" "vetting/internal/store"
) )
@@ -12,6 +15,10 @@ import (
// Dispatcher picks Queued runs off the DB and drives them through // Dispatcher picks Queued runs off the DB and drives them through
// WaitingWoL (sending a WoL packet). Concurrency is capped at Max. // 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 // For Phase 2 the dispatcher's job ends at WaitingWoL; further
// transitions are driven by iPXE and agent callbacks. Phase 4+ will // transitions are driven by iPXE and agent callbacks. Phase 4+ will
// return here and shepherd each run through stage execution. // return here and shepherd each run through stage execution.
@@ -20,22 +27,32 @@ type Dispatcher struct {
Runs *store.Runs Runs *store.Runs
Hosts *store.Hosts Hosts *store.Hosts
Runner *Runner Runner *Runner
Logs *logs.Hub
active chan struct{} active chan struct{}
stop 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 { if max < 1 {
max = 1 max = 1
} }
return &Dispatcher{ return &Dispatcher{
Max: max, Max: max,
Runs: runs, Runs: runs,
Hosts: hosts, Hosts: hosts,
Runner: runner, Runner: runner,
active: make(chan struct{}, max), Logs: logHub,
stop: make(chan struct{}), 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 return
case <-t.C: case <-t.C:
d.pickNext(ctx) 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) log.Printf("dispatcher: get host %d: %v", queued.HostID, err)
return 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 { if _, err := d.Runner.Transition(ctx, queued.ID, TriggerDispatched); err != nil {
log.Printf("dispatcher: transition run %d: %v", queued.ID, err) 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 return
} }
if err := SendWoL(host.MAC, host.WoLBroadcastIP, host.WoLPort); err != nil { 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) 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. // Stay in WaitingWoL; operator can retry or investigate.
return return
} }
log.Printf("dispatcher: WoL sent for run %d (host=%s mac=%s)", queued.ID, host.Name, host.MAC) 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+). // Slot stays reserved until the run leaves active (Phase 4+).
// Phase 2 lets the loop observe inFlight via DB state. // Phase 2 lets the loop observe inFlight via DB state.
released = true released = true
<-d.active <-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})
}
+47
View File
@@ -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")
}
+106 -17
View File
@@ -366,6 +366,79 @@ body.bare main { max-width: none; }
.detail-log .log-warn { color: var(--warn); } .detail-log .log-warn { color: var(--warn); }
.detail-log .log-error { color: var(--danger); } .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-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.diff-row { .diff-row {
display: grid; display: grid;
@@ -399,57 +472,73 @@ body.bare main { max-width: none; }
margin: 0; 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 { .pipeline {
display: flex; display: flex;
align-items: stretch; align-items: stretch;
gap: 0; gap: 0;
width: 100%;
overflow-x: auto; overflow-x: auto;
padding: 12px 4px 6px; padding: 28px 12px 16px;
background: #0b0d12;
border: 1px solid var(--border);
border-radius: var(--radius);
} }
.stage-node { .stage-node {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 4px; gap: 6px;
min-width: 82px; flex: 1 1 0;
padding: 0 6px; min-width: 72px;
flex-shrink: 0; padding: 0 4px;
} }
.stage-dot { .stage-dot {
width: 22px; width: 28px;
height: 22px; height: 28px;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 12px; font-size: 14px;
font-weight: 700; font-weight: 700;
border: 2px solid var(--border); border: 2px solid var(--border);
background: var(--bg-elev-2); background: var(--bg-elev-2);
color: var(--text-dim); color: var(--text-dim);
line-height: 1; 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-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-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-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-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-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-passed .stage-name { color: var(--text); }
.stage-node-running .stage-name { color: var(--accent); } .stage-node-running .stage-name { color: var(--accent); font-weight: 600; }
.stage-node-failed .stage-name { color: var(--danger); } .stage-node-failed .stage-name { color: var(--danger); font-weight: 600; }
.stage-node-skipped .stage-name { opacity: .5; } .stage-node-skipped .stage-name { opacity: .5; }
.stage-duration { font-size: 10px; color: var(--text-dim); font-family: var(--mono); min-height: 12px; } .stage-duration { font-size: 10px; color: var(--text-dim); font-family: var(--mono); min-height: 12px; }
.stage-connector { .stage-connector {
flex: 1; flex: 1 1 0;
min-width: 12px; min-width: 8px;
height: 2px; height: 3px;
align-self: center; align-self: center;
margin-top: -18px; margin-top: -30px;
background: var(--border); background: var(--border);
border-radius: 2px;
} }
.stage-connector-passed { background: var(--success); } .stage-connector-passed { background: var(--success); }
.stage-connector-running { background: linear-gradient(90deg, var(--success), var(--accent)); } .stage-connector-running { background: linear-gradient(90deg, var(--success), var(--accent)); }
@@ -458,5 +547,5 @@ body.bare main { max-width: none; }
@keyframes pulse { @keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(60,130,246,.55); } 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); }
} }
+41 -10
View File
@@ -4,16 +4,20 @@ import (
"fmt" "fmt"
"vetting/internal/model" "vetting/internal/model"
"vetting/internal/store"
) )
// HostDetailData is the full payload the detail handler hands to the // HostDetailData is the full payload the detail handler hands to the
// HostDetail template. Tile carries host + latest-run enrichment (same // HostDetail template. Tile carries host + latest-run enrichment (same
// shape the dashboard tile uses), Stages/SpecDiffs drive the pipeline // 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 { type HostDetailData struct {
Tile TileData Tile TileData
Stages []model.Stage Stages []model.Stage
SpecDiffs []model.SpecDiff SpecDiffs []model.SpecDiff
LogReplay string
} }
templ HostDetail(d HostDetailData) { templ HostDetail(d HostDetailData) {
@@ -123,15 +127,7 @@ templ HostDetail(d HostDetailData) {
} }
if d.Tile.Latest != nil { if d.Tile.Latest != nil {
<section class="detail-section"> @LogTabs(d.Tile.Latest.ID, d.LogReplay)
<h2>Log</h2>
<div
class="detail-log"
id={ fmt.Sprintf("log-%d", d.Tile.Latest.ID) }
sse-swap={ fmt.Sprintf("log-%d", d.Tile.Latest.ID) }
hx-swap="beforeend"
></div>
</section>
} }
<section class="detail-section detail-host-meta"> <section class="detail-section detail-host-meta">
@@ -163,3 +159,38 @@ func hasCriticalDiff(diffs []model.SpecDiff) bool {
} }
return false 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) {
<section class="detail-section log-section">
<h2>Log</h2>
<div class="log-tabs">
<input type="radio" name={ fmt.Sprintf("log-tab-%d", runID) } id={ fmt.Sprintf("log-tab-%d-all", runID) } class="log-tab-input log-tab-all" checked/>
<label for={ fmt.Sprintf("log-tab-%d-all", runID) } class="log-tab-label">All</label>
for _, s := range store.DefaultStageOrder {
<input type="radio" name={ fmt.Sprintf("log-tab-%d", runID) } id={ fmt.Sprintf("log-tab-%d-%s", runID, s) } class={ "log-tab-input", "log-tab-" + s }/>
<label for={ fmt.Sprintf("log-tab-%d-%s", runID, s) } class="log-tab-label">{ s }</label>
}
<div
class="log-pane log-pane-all"
id={ fmt.Sprintf("log-%d", runID) }
sse-swap={ fmt.Sprintf("log-%d", runID) }
hx-swap="beforeend show:bottom"
>
@templ.Raw(replay)
</div>
for _, s := range store.DefaultStageOrder {
<div
class={ "log-pane", "log-pane-" + s }
id={ fmt.Sprintf("log-%d-%s", runID, s) }
sse-swap={ fmt.Sprintf("log-%d-%s", runID, s) }
hx-swap="beforeend show:bottom"
></div>
}
</div>
</section>
}
+286 -67
View File
@@ -12,16 +12,20 @@ import (
"fmt" "fmt"
"vetting/internal/model" "vetting/internal/model"
"vetting/internal/store"
) )
// HostDetailData is the full payload the detail handler hands to the // HostDetailData is the full payload the detail handler hands to the
// HostDetail template. Tile carries host + latest-run enrichment (same // HostDetail template. Tile carries host + latest-run enrichment (same
// shape the dashboard tile uses), Stages/SpecDiffs drive the pipeline // 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 { type HostDetailData struct {
Tile TileData Tile TileData
Stages []model.Stage Stages []model.Stage
SpecDiffs []model.SpecDiff SpecDiffs []model.SpecDiff
LogReplay string
} }
func HostDetail(d HostDetailData) templ.Component { func HostDetail(d HostDetailData) templ.Component {
@@ -64,7 +68,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Name) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Name)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -99,7 +103,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Name) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Name)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -134,7 +138,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(d.Tile.LastSeenAt)) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(d.Tile.LastSeenAt))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -147,7 +151,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(d.Tile.Latest)) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(d.Tile.Latest))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -160,7 +164,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.MAC) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.MAC)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -173,7 +177,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var12 string 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)) 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 { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -191,7 +195,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Latest.FailedStage) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Latest.FailedStage)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -210,7 +214,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical)) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -233,7 +237,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID)) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -246,7 +250,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID)) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -286,7 +290,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP)) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -309,7 +313,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var18 templ.SafeURL 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))) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Tile.Host.ID)))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -333,7 +337,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var19 templ.SafeURL 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))) 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 { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -352,7 +356,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var20 templ.SafeURL var templ_7745c5c3_Var20 templ.SafeURL
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", d.Tile.Latest.ID))) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", d.Tile.Latest.ID)))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -370,7 +374,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var21 templ.SafeURL 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))) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Tile.Host.ID)))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -398,7 +402,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs))) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs)))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -434,7 +438,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var25 string var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field) templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -447,7 +451,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var26 string var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected) templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -460,7 +464,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var27 string var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual) templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -477,74 +481,48 @@ func HostDetail(d HostDetailData) templ.Component {
} }
} }
if d.Tile.Latest != nil { if d.Tile.Latest != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<section class=\"detail-section\"><h2>Log</h2><div class=\"detail-log\" id=\"") templ_7745c5c3_Err = LogTabs(d.Tile.Latest.ID, d.LogReplay).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<section class=\"detail-section detail-host-meta\"><details><summary><h2>Host details</h2></summary> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Tile.Host.Notes != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<div class=\"detail-notes\"><h3>Notes</h3><p>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var28 string 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 { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" sse-swap=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</p></div>")
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\"></div></section>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<section class=\"detail-section detail-host-meta\"><details><summary><h2>Host details</h2></summary> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<div class=\"detail-spec\"><h3>Expected spec</h3><pre class=\"detail-spec-yaml\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if d.Tile.Host.Notes != "" { var templ_7745c5c3_Var29 string
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"detail-notes\"><h3>Notes</h3><p>") templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.ExpectedSpecYAML)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 144, Col: 66}
}
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, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div class=\"detail-spec\"><h3>Expected spec</h3><pre class=\"detail-spec-yaml\">") _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var31 string templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</pre></div></details></section></section>")
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, "</pre></div></details></section></section>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -569,4 +547,245 @@ func hasCriticalDiff(diffs []model.SpecDiff) bool {
return false 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, "<section class=\"detail-section log-section\"><h2>Log</h2><div class=\"log-tabs\"><input type=\"radio\" name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d", runID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 172, Col: 62}
}
_, 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, 51, "\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d-all", runID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 172, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" class=\"log-tab-input log-tab-all\" checked> <label for=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d-all", runID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 173, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" class=\"log-tab-label\">All</label> ")
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, "<input type=\"radio\" name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d", runID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 175, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d-%s", runID, s))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 175, Col: 109}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var34).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\"> <label for=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d-%s", runID, s))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 176, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" class=\"log-tab-label\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(s)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 176, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</label>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<div class=\"log-pane log-pane-all\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d", runID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 180, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" sse-swap=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d", runID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 181, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" hx-swap=\"beforeend show:bottom\">")
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, "</div>")
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, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var42).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", runID, s))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 189, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" sse-swap=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", runID, s))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 190, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" hx-swap=\"beforeend show:bottom\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</div></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate var _ = templruntime.GeneratedTemplate
+88 -23
View File
@@ -7,6 +7,7 @@ import (
"time" "time"
"vetting/internal/model" "vetting/internal/model"
"vetting/internal/store"
) )
// PipelineNode is one dot on the detail-page timeline. The template // 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 // 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 // When run == nil we emit a ghost timeline (everything pending) so a
// never-run host still shows what's coming. // never-run host still shows what's coming.
func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode { 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 --- // --- pre-stage nodes ---
for _, ps := range preStageOrder { for _, ps := range preStageOrder {
@@ -85,28 +90,41 @@ func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode {
nodes = append(nodes, n) nodes = append(nodes, n)
} }
// --- stage nodes (from stage rows) --- // --- stage nodes ---
failedBefore := false // 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 { for _, st := range stages {
n := PipelineNode{ stageByName[st.Name] = st
Name: st.Name, }
StartedAt: st.StartedAt, failedBefore := false
CompletedAt: st.CompletedAt, for _, name := range store.DefaultStageOrder {
} n := PipelineNode{Name: name}
switch { if st, ok := stageByName[name]; ok {
case failedBefore: n.StartedAt = st.StartedAt
n.State = "skipped" n.CompletedAt = st.CompletedAt
case st.State == model.StagePassed: switch {
n.State = "passed" case failedBefore:
case st.State == model.StageRunning: n.State = "skipped"
n.State = "running" case st.State == model.StagePassed:
case st.State == model.StageFailed: n.State = "passed"
n.State = "failed" case st.State == model.StageRunning:
failedBefore = true n.State = "running"
case st.State == model.StageSkipped: case st.State == model.StageFailed:
n.State = "skipped" n.State = "failed"
default: failedBefore = true
n.State = "pending" 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) nodes = append(nodes, n)
} }
@@ -122,6 +140,53 @@ func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode {
return nodes 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, // 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 // 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 // pre-stage "past" check to decide if a Booting node should render
+91 -26
View File
@@ -15,6 +15,7 @@ import (
"time" "time"
"vetting/internal/model" "vetting/internal/model"
"vetting/internal/store"
) )
// PipelineNode is one dot on the detail-page timeline. The template // 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 // 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 // When run == nil we emit a ghost timeline (everything pending) so a
// never-run host still shows what's coming. // never-run host still shows what's coming.
func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode { 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 --- // --- pre-stage nodes ---
for _, ps := range preStageOrder { for _, ps := range preStageOrder {
@@ -93,28 +98,41 @@ func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode {
nodes = append(nodes, n) nodes = append(nodes, n)
} }
// --- stage nodes (from stage rows) --- // --- stage nodes ---
failedBefore := false // 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 { for _, st := range stages {
n := PipelineNode{ stageByName[st.Name] = st
Name: st.Name, }
StartedAt: st.StartedAt, failedBefore := false
CompletedAt: st.CompletedAt, for _, name := range store.DefaultStageOrder {
} n := PipelineNode{Name: name}
switch { if st, ok := stageByName[name]; ok {
case failedBefore: n.StartedAt = st.StartedAt
n.State = "skipped" n.CompletedAt = st.CompletedAt
case st.State == model.StagePassed: switch {
n.State = "passed" case failedBefore:
case st.State == model.StageRunning: n.State = "skipped"
n.State = "running" case st.State == model.StagePassed:
case st.State == model.StageFailed: n.State = "passed"
n.State = "failed" case st.State == model.StageRunning:
failedBefore = true n.State = "running"
case st.State == model.StageSkipped: case st.State == model.StageFailed:
n.State = "skipped" n.State = "failed"
default: failedBefore = true
n.State = "pending" 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) nodes = append(nodes, n)
} }
@@ -130,6 +148,53 @@ func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode {
return nodes 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, // 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 // 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 // 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 var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(n.State)) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(n.State))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -322,7 +387,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(n.Name) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(n.Name)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -335,7 +400,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(stageDuration(n)) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(stageDuration(n))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+55 -3
View File
@@ -36,9 +36,10 @@ func seedStages() []model.Stage {
func TestBuildPipeline_NoRun(t *testing.T) { func TestBuildPipeline_NoRun(t *testing.T) {
nodes := BuildPipeline(nil, nil) nodes := BuildPipeline(nil, nil)
if len(nodes) != len(preStageOrder)+1 { // Ghost pipeline: 3 pre-stages + 9 stage ghosts + 1 terminal = 13
// No stage rows = just pre-stages + Completed. // nodes, all pending.
t.Fatalf("len = %d, want %d", len(nodes), len(preStageOrder)+1) if len(nodes) != 13 {
t.Fatalf("len = %d, want 13", len(nodes))
} }
for i, n := range nodes { for i, n := range nodes {
if n.State != "pending" { 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) { func TestBuildPipeline_Running(t *testing.T) {
run := &model.Run{State: model.StateSMART} run := &model.Run{State: model.StateSMART}
stages := seedStages() stages := seedStages()