Click a tile to open /hosts/{id} — the canonical control surface per
host. Timeline renders every pre-stage, stage, and terminal node in
order, with the current one pulsing, failed ones flagged, and
downstream ones dimmed as skipped. Detail page shows summary, hold
card (when holding), all action buttons, spec diffs, a full-height
log pane, and a collapsed expected-spec YAML.
Tile slims to name, last-seen, status, and one primary action; a
CSS-overlay <a> makes the whole card clickable while buttons stay
receptive via z-index.
Runner.publishTileUpdate now also emits pipeline-{runID} fragments,
and CompleteStage wraps Stages.CompleteByName so stage completions
advance the timeline live — without this the dots only moved on
state transitions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// 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 → stage rows → Completed.
|
||||
//
|
||||
// When run == nil we emit a ghost timeline (everything pending) so a
|
||||
// never-run host still shows what's coming.
|
||||
func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode {
|
||||
nodes := make([]PipelineNode, 0, len(preStageOrder)+len(stages)+1)
|
||||
|
||||
// --- 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 (from stage rows) ---
|
||||
failedBefore := false
|
||||
for _, st := range stages {
|
||||
n := PipelineNode{
|
||||
Name: st.Name,
|
||||
StartedAt: st.StartedAt,
|
||||
CompletedAt: st.CompletedAt,
|
||||
}
|
||||
switch {
|
||||
case failedBefore:
|
||||
n.State = "skipped"
|
||||
case st.State == model.StagePassed:
|
||||
n.State = "passed"
|
||||
case st.State == model.StageRunning:
|
||||
n.State = "running"
|
||||
case st.State == model.StageFailed:
|
||||
n.State = "failed"
|
||||
failedBefore = true
|
||||
case st.State == model.StageSkipped:
|
||||
n.State = "skipped"
|
||||
default:
|
||||
n.State = "pending"
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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: 210, 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: 211, 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: 212, 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
|
||||
Reference in New Issue
Block a user