Files
Vetting/internal/web/templates/pipeline_templ.go
T
josh 1694c20b12
CI / Lint + build + test (push) Has been cancelled
Host detail v2: full pipeline + per-stage logs + WoL diagnostics
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>
2026-04-18 00:38:27 -04:00

432 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"bytes"
"context"
"fmt"
"time"
"vetting/internal/model"
"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.StateWaitingWoL,
model.StateBooting,
}
// runStateRank returns how far along the state machine a run is, using
// a linear ranking across pre-stages, stage states, and terminals. Used
// by BuildPipeline to decide which pre-stage nodes are "past" (passed),
// "current" (running), or "pending".
func runStateRank(s model.RunState) int {
order := []model.RunState{
model.StateRegistered,
model.StateQueued,
model.StateWaitingWoL,
model.StateBooting,
model.StateInventoryCheck,
model.StateSpecValidate,
model.StateSMART,
model.StateCPUStress,
model.StateStorage,
model.StateNetwork,
model.StateGPU,
model.StatePSU,
model.StateReporting,
model.StateCompleted,
}
for i, v := range order {
if v == s {
return i
}
}
return -1
}
// BuildPipeline projects (run, stages) into a linear slice of nodes
// covering the whole lifecycle: pre-stage → 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 <section id="pipeline-{runID}" sse-swap=...> so the runner can
// re-emit the fragment as stages progress.
func Pipeline(nodes []PipelineNode) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"pipeline\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for i, n := range nodes {
if i > 0 {
var templ_7745c5c3_Var2 = []any{"stage-connector", "stage-connector-" + nodes[i-1].State}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 = []any{"stage-node", "stage-node-" + n.State}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var4).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 = []any{"stage-dot", "stage-dot-" + n.State}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var6).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(n.State))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 275, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div><div class=\"stage-name\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(n.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 276, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div><div class=\"stage-duration\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(stageDuration(n))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 277, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// RenderPipelineString is the one-shot renderer the orchestrator
// registers at startup so it can publish pipeline fragments over SSE
// without pulling in the template package directly.
func RenderPipelineString(run *model.Run, stages []model.Stage) string {
var buf bytes.Buffer
_ = Pipeline(BuildPipeline(run, stages)).Render(context.Background(), &buf)
return buf.String()
}
var _ = templruntime.GeneratedTemplate