17ec55cb85
Remove ~126 lines of orphaned CSS from tile slim-down and old detail layout. Consolidate 4 duplicate duration formatters into shared elapsed()/fmtElapsed() helpers. Break 160-line Result handler into focused sub-functions. Implement real Hub.Shutdown() (was a no-op). Standardize agent error responses to JSON. Replace panic() in router init with error return. Extract magic numbers as named constants. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
326 lines
10 KiB
Plaintext
326 lines
10 KiB
Plaintext
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 <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">{ stageDisplayName(n.Name) }</div>
|
||
<div class="stage-duration">{ stageDuration(n) }</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
// PipelineSection wraps Pipeline in the same <section id=pipeline-N
|
||
// sse-swap=pipeline-N hx-swap=outerHTML> 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
|
||
// <div class=pipeline>, wiping out the sse-swap attribute and freezing
|
||
// the pipeline until full page reload.
|
||
templ PipelineSection(run *model.Run, nodes []PipelineNode) {
|
||
<section
|
||
id={ fmt.Sprintf("pipeline-%d", run.ID) }
|
||
class="detail-section"
|
||
sse-swap={ fmt.Sprintf("pipeline-%d", run.ID) }
|
||
hx-swap="outerHTML"
|
||
>
|
||
<h2>Pipeline</h2>
|
||
@Pipeline(nodes)
|
||
</section>
|
||
}
|
||
|
||
// 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()
|
||
}
|