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
+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>
}