Host detail v2: full pipeline + per-stage logs + WoL diagnostics
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:
2026-04-18 00:38:27 -04:00
parent a3d5e2d0a4
commit 1694c20b12
16 changed files with 1053 additions and 162 deletions
+106 -17
View File
@@ -366,6 +366,79 @@ body.bare main { max-width: none; }
.detail-log .log-warn { color: var(--warn); }
.detail-log .log-error { color: var(--danger); }
/* ===== Log tabs (CSS-only radio switch) ===== */
/* Radios are visually hidden but still functional: checked state is read
by sibling selectors below to flip the active label + pane. */
.log-tabs {
display: flex;
flex-wrap: wrap;
gap: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #0b0d12;
overflow: hidden;
}
.log-tab-input {
position: absolute;
opacity: 0;
pointer-events: none;
width: 0;
height: 0;
}
.log-tab-label {
display: inline-flex;
align-items: center;
padding: 8px 14px;
font-size: 12px;
font-family: var(--mono);
color: var(--text-dim);
cursor: pointer;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
user-select: none;
background: transparent;
transition: background .12s ease, color .12s ease;
}
.log-tab-label:hover { background: rgba(255,255,255,.03); color: var(--text); }
/* Active tab: coloured bar + filled background. */
.log-tab-input:checked + .log-tab-label {
background: var(--bg-elev-2);
color: var(--text);
box-shadow: inset 0 -2px 0 0 var(--accent);
}
.log-pane {
display: none;
flex-basis: 100%;
flex-direction: column;
gap: 2px;
padding: 10px 12px;
font-family: var(--mono);
font-size: 12px;
color: var(--text-dim);
max-height: 500px;
overflow-y: auto;
order: 99; /* keep panes below the tab labels in flex order */
}
.log-pane .log-line { white-space: pre-wrap; }
.log-pane .log-warn { color: var(--warn); }
.log-pane .log-error { color: var(--danger); }
.log-pane .log-stage { color: var(--text-dim); opacity: .75; margin-right: 4px; }
.log-pane:empty::before { content: "(no log output yet)"; color: var(--text-dim); opacity: .5; }
/* Sibling-selector cascade: .log-tab-all:checked flips .log-pane-all,
.log-tab-Inventory:checked flips .log-pane-Inventory, etc. The radios
live as siblings of the panes inside .log-tabs, so ~ works. */
.log-tab-all:checked ~ .log-pane-all { display: flex; }
.log-tab-Inventory:checked ~ .log-pane-Inventory { display: flex; }
.log-tab-SpecValidate:checked ~ .log-pane-SpecValidate { display: flex; }
.log-tab-SMART:checked ~ .log-pane-SMART { display: flex; }
.log-tab-CPUStress:checked ~ .log-pane-CPUStress { display: flex; }
.log-tab-Storage:checked ~ .log-pane-Storage { display: flex; }
.log-tab-Network:checked ~ .log-pane-Network { display: flex; }
.log-tab-GPU:checked ~ .log-pane-GPU { display: flex; }
.log-tab-PSU:checked ~ .log-pane-PSU { display: flex; }
.log-tab-Reporting:checked ~ .log-pane-Reporting { display: flex; }
.diff-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.diff-row {
display: grid;
@@ -399,57 +472,73 @@ body.bare main { max-width: none; }
margin: 0;
}
/* ===== Pipeline timeline ===== */
/* ===== Pipeline timeline =====
13 nodes (3 pre-stage + 9 stage + Completed). flex:1 on every node
so they share the full width evenly; overflow-x:auto only kicks in
on very narrow viewports. */
.pipeline {
display: flex;
align-items: stretch;
gap: 0;
width: 100%;
overflow-x: auto;
padding: 12px 4px 6px;
padding: 28px 12px 16px;
background: #0b0d12;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.stage-node {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 82px;
padding: 0 6px;
flex-shrink: 0;
gap: 6px;
flex: 1 1 0;
min-width: 72px;
padding: 0 4px;
}
.stage-dot {
width: 22px;
height: 22px;
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-size: 14px;
font-weight: 700;
border: 2px solid var(--border);
background: var(--bg-elev-2);
color: var(--text-dim);
line-height: 1;
transition: transform .12s ease, box-shadow .12s ease;
}
.stage-node:hover .stage-dot { transform: scale(1.12); }
.stage-dot-passed { background: var(--success); border-color: var(--success); color: #0b0d12; }
.stage-dot-running { background: var(--accent-strong); border-color: var(--accent); color: #fff; animation: pulse 1.2s ease-in-out infinite; }
.stage-dot-failed { background: var(--danger); border-color: var(--danger); color: #fff; }
.stage-dot-skipped { background: transparent; border-color: var(--border); color: var(--text-dim); opacity: .45; }
.stage-dot-pending { background: transparent; border-color: var(--border); color: transparent; }
.stage-name { font-size: 11px; color: var(--text-dim); text-align: center; }
.stage-name {
font-size: 12px;
color: var(--text-dim);
text-align: center;
font-weight: 500;
line-height: 1.2;
}
.stage-node-passed .stage-name { color: var(--text); }
.stage-node-running .stage-name { color: var(--accent); }
.stage-node-failed .stage-name { color: var(--danger); }
.stage-node-running .stage-name { color: var(--accent); font-weight: 600; }
.stage-node-failed .stage-name { color: var(--danger); font-weight: 600; }
.stage-node-skipped .stage-name { opacity: .5; }
.stage-duration { font-size: 10px; color: var(--text-dim); font-family: var(--mono); min-height: 12px; }
.stage-connector {
flex: 1;
min-width: 12px;
height: 2px;
flex: 1 1 0;
min-width: 8px;
height: 3px;
align-self: center;
margin-top: -18px;
margin-top: -30px;
background: var(--border);
border-radius: 2px;
}
.stage-connector-passed { background: var(--success); }
.stage-connector-running { background: linear-gradient(90deg, var(--success), var(--accent)); }
@@ -458,5 +547,5 @@ body.bare main { max-width: none; }
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(60,130,246,.55); }
50% { box-shadow: 0 0 0 6px rgba(60,130,246,0); }
50% { box-shadow: 0 0 0 8px rgba(60,130,246,0); }
}
+41 -10
View File
@@ -4,16 +4,20 @@ import (
"fmt"
"vetting/internal/model"
"vetting/internal/store"
)
// HostDetailData is the full payload the detail handler hands to the
// HostDetail template. Tile carries host + latest-run enrichment (same
// shape the dashboard tile uses), Stages/SpecDiffs drive the pipeline
// and diff list.
// and diff list. LogReplay is the pre-rendered history fragment
// produced by logs.Hub.Replay on the initial page render so the operator
// sees prior output without waiting for a fresh SSE event.
type HostDetailData struct {
Tile TileData
Stages []model.Stage
SpecDiffs []model.SpecDiff
LogReplay string
}
templ HostDetail(d HostDetailData) {
@@ -123,15 +127,7 @@ templ HostDetail(d HostDetailData) {
}
if d.Tile.Latest != nil {
<section class="detail-section">
<h2>Log</h2>
<div
class="detail-log"
id={ fmt.Sprintf("log-%d", d.Tile.Latest.ID) }
sse-swap={ fmt.Sprintf("log-%d", d.Tile.Latest.ID) }
hx-swap="beforeend"
></div>
</section>
@LogTabs(d.Tile.Latest.ID, d.LogReplay)
}
<section class="detail-section detail-host-meta">
@@ -163,3 +159,38 @@ func hasCriticalDiff(diffs []model.SpecDiff) bool {
}
return false
}
// LogTabs renders an "All" tab plus one tab per stage in DefaultStageOrder.
// Switching is pure CSS: hidden radio inputs drive sibling-selector
// visibility on the panes. Each pane carries its own sse-swap target so
// live events append only to the relevant pane. The All pane is seeded
// with replay HTML so reload on an in-flight run still shows history.
templ LogTabs(runID int64, replay string) {
<section class="detail-section log-section">
<h2>Log</h2>
<div class="log-tabs">
<input type="radio" name={ fmt.Sprintf("log-tab-%d", runID) } id={ fmt.Sprintf("log-tab-%d-all", runID) } class="log-tab-input log-tab-all" checked/>
<label for={ fmt.Sprintf("log-tab-%d-all", runID) } class="log-tab-label">All</label>
for _, s := range store.DefaultStageOrder {
<input type="radio" name={ fmt.Sprintf("log-tab-%d", runID) } id={ fmt.Sprintf("log-tab-%d-%s", runID, s) } class={ "log-tab-input", "log-tab-" + s }/>
<label for={ fmt.Sprintf("log-tab-%d-%s", runID, s) } class="log-tab-label">{ s }</label>
}
<div
class="log-pane log-pane-all"
id={ fmt.Sprintf("log-%d", runID) }
sse-swap={ fmt.Sprintf("log-%d", runID) }
hx-swap="beforeend show:bottom"
>
@templ.Raw(replay)
</div>
for _, s := range store.DefaultStageOrder {
<div
class={ "log-pane", "log-pane-" + s }
id={ fmt.Sprintf("log-%d-%s", runID, s) }
sse-swap={ fmt.Sprintf("log-%d-%s", runID, s) }
hx-swap="beforeend show:bottom"
></div>
}
</div>
</section>
}
+286 -67
View File
@@ -12,16 +12,20 @@ import (
"fmt"
"vetting/internal/model"
"vetting/internal/store"
)
// HostDetailData is the full payload the detail handler hands to the
// HostDetail template. Tile carries host + latest-run enrichment (same
// shape the dashboard tile uses), Stages/SpecDiffs drive the pipeline
// and diff list.
// and diff list. LogReplay is the pre-rendered history fragment
// produced by logs.Hub.Replay on the initial page render so the operator
// sees prior output without waiting for a fresh SSE event.
type HostDetailData struct {
Tile TileData
Stages []model.Stage
SpecDiffs []model.SpecDiff
LogReplay string
}
func HostDetail(d HostDetailData) templ.Component {
@@ -64,7 +68,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 25, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 29, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -99,7 +103,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 30, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 34, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -134,7 +138,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(d.Tile.LastSeenAt))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 32, Col: 107}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 36, Col: 107}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -147,7 +151,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(d.Tile.Latest))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 33, Col: 59}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 37, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -160,7 +164,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.MAC)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 39, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 43, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -173,7 +177,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 43, Col: 81}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 47, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -191,7 +195,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Latest.FailedStage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 48, Col: 50}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 52, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -210,7 +214,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 54, Col: 76}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 58, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -233,7 +237,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 62, Col: 54}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 66, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -246,7 +250,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 64, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 68, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
@@ -286,7 +290,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 80, Col: 85}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 84, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
@@ -309,7 +313,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var18 templ.SafeURL
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Tile.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 88, Col: 96}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 92, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -333,7 +337,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var19 templ.SafeURL
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Tile.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 95, Col: 104}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 99, Col: 104}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -352,7 +356,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var20 templ.SafeURL
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", d.Tile.Latest.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 100, Col: 95}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 104, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
@@ -370,7 +374,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var21 templ.SafeURL
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Tile.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 102, Col: 96}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 106, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
@@ -398,7 +402,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 111, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 115, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
@@ -434,7 +438,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 115, Col: 45}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 119, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
@@ -447,7 +451,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 116, Col: 67}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 120, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
@@ -460,7 +464,7 @@ func HostDetail(d HostDetailData) templ.Component {
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 117, Col: 61}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 121, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
@@ -477,74 +481,48 @@ func HostDetail(d HostDetailData) templ.Component {
}
}
if d.Tile.Latest != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<section class=\"detail-section\"><h2>Log</h2><div class=\"detail-log\" id=\"")
templ_7745c5c3_Err = LogTabs(d.Tile.Latest.ID, d.LogReplay).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<section class=\"detail-section detail-host-meta\"><details><summary><h2>Host details</h2></summary> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Tile.Host.Notes != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<div class=\"detail-notes\"><h3>Notes</h3><p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d", d.Tile.Latest.ID))
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Notes)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 130, Col: 50}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 139, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" sse-swap=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d", d.Tile.Latest.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 131, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" hx-swap=\"beforeend\"></div></section>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<section class=\"detail-section detail-host-meta\"><details><summary><h2>Host details</h2></summary> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<div class=\"detail-spec\"><h3>Expected spec</h3><pre class=\"detail-spec-yaml\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Tile.Host.Notes != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"detail-notes\"><h3>Notes</h3><p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Notes)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 143, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.ExpectedSpecYAML)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 144, Col: 66}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div class=\"detail-spec\"><h3>Expected spec</h3><pre class=\"detail-spec-yaml\">")
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.ExpectedSpecYAML)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 148, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</pre></div></details></section></section>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</pre></div></details></section></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -569,4 +547,245 @@ func hasCriticalDiff(diffs []model.SpecDiff) bool {
return false
}
// LogTabs renders an "All" tab plus one tab per stage in DefaultStageOrder.
// Switching is pure CSS: hidden radio inputs drive sibling-selector
// visibility on the panes. Each pane carries its own sse-swap target so
// live events append only to the relevant pane. The All pane is seeded
// with replay HTML so reload on an in-flight run still shows history.
func LogTabs(runID int64, replay string) 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_Var30 := templ.GetChildren(ctx)
if templ_7745c5c3_Var30 == nil {
templ_7745c5c3_Var30 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<section class=\"detail-section log-section\"><h2>Log</h2><div class=\"log-tabs\"><input type=\"radio\" name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d", runID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 172, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d-all", runID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 172, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" class=\"log-tab-input log-tab-all\" checked> <label for=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d-all", runID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 173, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" class=\"log-tab-label\">All</label> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, s := range store.DefaultStageOrder {
var templ_7745c5c3_Var34 = []any{"log-tab-input", "log-tab-" + s}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var34...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<input type=\"radio\" name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d", runID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 175, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d-%s", runID, s))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 175, Col: 109}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var34).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\"> <label for=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d-%s", runID, s))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 176, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" class=\"log-tab-label\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(s)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 176, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</label>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<div class=\"log-pane log-pane-all\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d", runID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 180, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" sse-swap=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d", runID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 181, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" hx-swap=\"beforeend show:bottom\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(replay).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, s := range store.DefaultStageOrder {
var templ_7745c5c3_Var42 = []any{"log-pane", "log-pane-" + s}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var42...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var42).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", runID, s))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 189, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" sse-swap=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", runID, s))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 190, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" hx-swap=\"beforeend show:bottom\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</div></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+88 -23
View File
@@ -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
+91 -26
View File
@@ -15,6 +15,7 @@ import (
"time"
"vetting/internal/model"
"vetting/internal/store"
)
// PipelineNode is one dot on the detail-page timeline. The template
@@ -66,12 +67,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 {
@@ -93,28 +98,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)
}
@@ -130,6 +148,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
@@ -309,7 +374,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
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}
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 {
@@ -322,7 +387,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
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}
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 {
@@ -335,7 +400,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
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}
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 {
+55 -3
View File
@@ -36,9 +36,10 @@ func seedStages() []model.Stage {
func TestBuildPipeline_NoRun(t *testing.T) {
nodes := BuildPipeline(nil, nil)
if len(nodes) != len(preStageOrder)+1 {
// No stage rows = just pre-stages + Completed.
t.Fatalf("len = %d, want %d", len(nodes), len(preStageOrder)+1)
// Ghost pipeline: 3 pre-stages + 9 stage ghosts + 1 terminal = 13
// nodes, all pending.
if len(nodes) != 13 {
t.Fatalf("len = %d, want 13", len(nodes))
}
for i, n := range nodes {
if n.State != "pending" {
@@ -47,6 +48,57 @@ func TestBuildPipeline_NoRun(t *testing.T) {
}
}
// TestBuildPipeline_GhostStagesBeforeClaim models the real WaitingWoL
// case: the run exists but agent hasn't called /claim yet, so there are
// no stage rows. Pipeline must still render all 9 stage nodes as ghosts
// so the operator sees the full timeline ahead of them.
func TestBuildPipeline_GhostStagesBeforeClaim(t *testing.T) {
run := &model.Run{State: model.StateWaitingWoL}
nodes := BuildPipeline(run, nil)
if len(nodes) != 13 {
t.Fatalf("len = %d, want 13", len(nodes))
}
if nodes[idxQueued].State != "passed" {
t.Errorf("Queued = %q, want passed", nodes[idxQueued].State)
}
if nodes[idxWaitingWoL].State != "running" {
t.Errorf("WaitingWoL = %q, want running", nodes[idxWaitingWoL].State)
}
// All 9 stage ghosts must be pending — nothing has started yet.
for i := idxInventory; i <= idxReporting; i++ {
if nodes[i].State != "pending" {
t.Errorf("%s (ghost) = %q, want pending", nodes[i].Name, nodes[i].State)
}
}
if nodes[idxCompleted].State != "pending" {
t.Errorf("Completed = %q, want pending", nodes[idxCompleted].State)
}
}
// TestBuildPipeline_GhostStagesDuringStage models the in-flight case
// with only some stage rows seeded: later stages must still appear as
// pending ghosts rather than silently disappearing.
func TestBuildPipeline_GhostStagesDuringStage(t *testing.T) {
run := &model.Run{State: model.StateSMART}
// Only Inventory + SpecValidate seeded; SMART onwards are ghosts.
stages := []model.Stage{
{Name: "Inventory", Ordinal: 0, State: model.StagePassed},
{Name: "SpecValidate", Ordinal: 1, State: model.StagePassed},
}
nodes := BuildPipeline(run, stages)
if len(nodes) != 13 {
t.Fatalf("len = %d, want 13", len(nodes))
}
if nodes[idxSMART].State != "running" {
t.Errorf("SMART (ghost) = %q, want running", nodes[idxSMART].State)
}
for _, i := range []int{idxCPUStress, idxStorage, idxNetwork, idxGPU, idxPSU, idxReporting} {
if nodes[i].State != "pending" {
t.Errorf("%s (ghost) = %q, want pending", nodes[i].Name, nodes[i].State)
}
}
}
func TestBuildPipeline_Running(t *testing.T) {
run := &model.Run{State: model.StateSMART}
stages := seedStages()