package templates import ( "bytes" "context" "fmt" "strings" "time" "vetting/internal/model" "vetting/internal/store" ) // PipelineNode is one dot on the detail-page timeline. The template // doesn't know stages from pre-stages — it just renders whatever the // BuildPipeline helper produces, in order. type PipelineNode struct { Name string State string // pending|running|passed|failed|skipped StartedAt *time.Time CompletedAt *time.Time } // preStageOrder are the nodes that show before the first real stage. // Derived from run.State rather than stage rows since we don't persist // pre-stage timestamps. var preStageOrder = []model.RunState{ model.StateQueued, model.StateWaitingReboot, model.StateBooting, } // runStateRank returns how far along the state machine a run is, using // a linear ranking across pre-stages, stage states, and terminals. Used // by BuildPipeline to decide which pre-stage nodes are "past" (passed), // "current" (running), or "pending". func runStateRank(s model.RunState) int { order := []model.RunState{ model.StateRegistered, model.StateQueued, model.StateWaitingReboot, model.StateBooting, model.StateInventoryCheck, model.StateFirmware, model.StateSpecValidate, model.StateSMART, model.StateCPUStress, model.StateStorage, model.StateNetwork, model.StateBurn, model.StateGPU, model.StatePSU, model.StateReporting, model.StateCompleted, } for i, v := range order { if v == s { return i } } return -1 } // BuildPipeline projects (run, stages) into a linear slice of nodes // covering the whole lifecycle: pre-stage → 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(store.DefaultStageOrder)+1) // --- pre-stage nodes --- for _, ps := range preStageOrder { n := PipelineNode{Name: string(ps), State: "pending"} if run != nil { switch { case run.State == model.StateFailedHolding || run.State == model.StateFailed: // If we failed before reaching a stage, a pre-stage may // still have been entered — keep the "past" rank logic. if runStateRank(ps) < runStateRank(firstStageState(run)) { n.State = "passed" } case run.State == ps: n.State = "running" case runStateRank(run.State) > runStateRank(ps): n.State = "passed" } } nodes = append(nodes, n) } // --- stage nodes --- // 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 { 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) } // --- terminal Completed node --- term := PipelineNode{Name: "Completed", State: "pending"} if run != nil && run.State == model.StateCompleted { term.State = "passed" term.CompletedAt = run.CompletedAt } nodes = append(nodes, term) return nodes } // 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 // "passed" even after the run failed further along. func firstStageState(run *model.Run) model.RunState { if run.FailedStage != "" { if s, ok := stageStateByName(run.FailedStage); ok { return s } } return run.State } // stageStateByName mirrors orchestrator.StateForStage without the // import (templates can't see orchestrator). func stageStateByName(name string) (model.RunState, bool) { m := map[string]model.RunState{ "Inventory": model.StateInventoryCheck, "Firmware": model.StateFirmware, "SpecValidate": model.StateSpecValidate, "SMART": model.StateSMART, "CPUStress": model.StateCPUStress, "Storage": model.StateStorage, "Network": model.StateNetwork, "Burn": model.StateBurn, "GPU": model.StateGPU, "PSU": model.StatePSU, "Reporting": model.StateReporting, } s, ok := m[name] return s, ok } func stageDuration(n PipelineNode) string { if d := elapsed(n.StartedAt, n.CompletedAt); d >= 0 { return fmtElapsed(d, false) } return "" } // stageDisplayName turns the internal single-word state/stage identifier // into a human-readable label by inserting spaces before interior capital // letters. "WaitingReboot" → "Waiting Reboot", "SpecValidate" → // "Spec Validate", "CPUStress" → "CPU Stress". The space lets the // pipeline node wrap the label onto two lines on narrow layouts instead // of forcing horizontal scroll — single-word names ("Inventory", // "Completed") pass through unchanged. func stageDisplayName(name string) string { var b strings.Builder b.Grow(len(name) + 2) for i, r := range name { if i > 0 && r >= 'A' && r <= 'Z' { prev := rune(name[i-1]) // Insert a space at every lower→upper boundary ("Spec|Validate") // and at upper→upper when the next char is lower ("CPU|Stress", // where we split before the S that starts a new word). This // keeps acronyms like "GPU"/"PSU" intact. if (prev >= 'a' && prev <= 'z') || (prev >= 'A' && prev <= 'Z' && i+1 < len(name) && name[i+1] >= 'a' && name[i+1] <= 'z') { b.WriteByte(' ') } } b.WriteRune(r) } return b.String() } // stageMarker returns the single-char glyph shown in the node's dot. // Dots stay colored-via-class; the glyph is redundant-but-helpful. func stageMarker(state string) string { switch state { case "passed": return "✓" case "failed": return "!" case "running": return "●" case "skipped": return "–" } return "" } // Pipeline renders the ordered dot-and-line timeline. The caller wraps // it in a
so the runner can // re-emit the fragment as stages progress. templ Pipeline(nodes []PipelineNode) {
for i, n := range nodes { if i > 0 {
}
{ stageMarker(n.State) }
{ stageDisplayName(n.Name) }
{ stageDuration(n) }
}
} // PipelineSection wraps Pipeline in the same
the runner targets. Used both // from the initial detail-page shell and from RenderPipelineString so // the wrapper is present on the wire and after every SSE swap — without // this, the first outerHTML swap would replace the section with a bare //
, wiping out the sse-swap attribute and freezing // the pipeline until full page reload. templ PipelineSection(run *model.Run, nodes []PipelineNode) {

Pipeline

@Pipeline(nodes)
} // RenderPipelineString is the one-shot renderer the orchestrator // registers at startup so it can publish pipeline fragments over SSE // without pulling in the template package directly. Returns the full // PipelineSection wrapper so repeat outerHTML swaps preserve the // sse-swap target. func RenderPipelineString(run *model.Run, stages []model.Stage) string { if run == nil { return "" } var buf bytes.Buffer _ = PipelineSection(run, BuildPipeline(run, stages)).Render(context.Background(), &buf) return buf.String() }