package templates import ( "bytes" "context" "fmt" "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.StateSpecValidate, model.StateSMART, model.StateCPUStress, model.StateStorage, model.StateNetwork, model.StateGPU, model.StatePSU, model.StateReporting, model.StateCompleted, } for i, v := range order { if v == s { return i } } return -1 } // BuildPipeline projects (run, stages) into a linear slice of nodes // covering the whole lifecycle: pre-stage → 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, "SpecValidate": model.StateSpecValidate, "SMART": model.StateSMART, "CPUStress": model.StateCPUStress, "Storage": model.StateStorage, "Network": model.StateNetwork, "GPU": model.StateGPU, "PSU": model.StatePSU, "Reporting": model.StateReporting, } s, ok := m[name] return s, ok } // stageDuration renders node timing as "1.2s" / "12s" / "4m". Empty // string when the node hasn't started or hasn't finished. func stageDuration(n PipelineNode) string { if n.StartedAt == nil { return "" } end := time.Now() if n.CompletedAt != nil { end = *n.CompletedAt } d := end.Sub(*n.StartedAt) if d < 0 { d = 0 } switch { case d < time.Second: return fmt.Sprintf("%dms", int(d/time.Millisecond)) case d < 10*time.Second: return fmt.Sprintf("%.1fs", d.Seconds()) case d < time.Minute: return fmt.Sprintf("%ds", int(d/time.Second)) case d < time.Hour: return fmt.Sprintf("%dm", int(d/time.Minute)) default: return fmt.Sprintf("%dh", int(d/time.Hour)) } } // stageMarker returns the single-char glyph shown in the node's dot. // Dots stay colored-via-class; the glyph is redundant-but-helpful. func stageMarker(state string) string { switch state { case "passed": return "✓" case "failed": return "!" case "running": return "●" case "skipped": return "–" } return "" } // Pipeline renders the ordered dot-and-line timeline. The caller wraps // it in a
so the runner can // re-emit the fragment as stages progress. templ Pipeline(nodes []PipelineNode) {
for i, n := range nodes { if i > 0 {
}
{ stageMarker(n.State) }
{ n.Name }
{ stageDuration(n) }
}
} // RenderPipelineString is the one-shot renderer the orchestrator // registers at startup so it can publish pipeline fragments over SSE // without pulling in the template package directly. func RenderPipelineString(run *model.Run, stages []model.Stage) string { var buf bytes.Buffer _ = Pipeline(BuildPipeline(run, stages)).Render(context.Background(), &buf) return buf.String() }