Host detail v2: full pipeline + per-stage logs + WoL diagnostics
CI / Lint + build + test (push) Has been cancelled
CI / Lint + build + test (push) Has been cancelled
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>
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
"vetting/internal/store"
|
||||
)
|
||||
|
||||
// PipelineNode is one dot on the detail-page timeline. The template
|
||||
@@ -58,12 +59,16 @@ func runStateRank(s model.RunState) int {
|
||||
}
|
||||
|
||||
// BuildPipeline projects (run, stages) into a linear slice of nodes
|
||||
// covering the whole lifecycle: pre-stage → stage rows → Completed.
|
||||
// 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(stages)+1)
|
||||
nodes := make([]PipelineNode, 0, len(preStageOrder)+len(store.DefaultStageOrder)+1)
|
||||
|
||||
// --- pre-stage nodes ---
|
||||
for _, ps := range preStageOrder {
|
||||
@@ -85,28 +90,41 @@ func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode {
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
|
||||
// --- stage nodes (from stage rows) ---
|
||||
failedBefore := false
|
||||
// --- 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 {
|
||||
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"
|
||||
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)
|
||||
}
|
||||
@@ -122,6 +140,53 @@ func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user