bb658a8435
CI / Lint + build + test (push) Has been cancelled
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>
226 lines
6.3 KiB
Plaintext
226 lines
6.3 KiB
Plaintext
package templates
|
||
|
||
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.
|
||
templ Pipeline(nodes []PipelineNode) {
|
||
<div class="pipeline">
|
||
for i, n := range nodes {
|
||
if i > 0 {
|
||
<div class={ "stage-connector", "stage-connector-" + nodes[i-1].State }></div>
|
||
}
|
||
<div class={ "stage-node", "stage-node-" + n.State }>
|
||
<div class={ "stage-dot", "stage-dot-" + n.State }>{ stageMarker(n.State) }</div>
|
||
<div class="stage-name">{ n.Name }</div>
|
||
<div class="stage-duration">{ stageDuration(n) }</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
// 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()
|
||
}
|