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
+7 -1
View File
@@ -181,6 +181,11 @@ func (a *Agent) Claim(w http.ResponseWriter, r *http.Request) {
}
log.Printf("agent claimed: run=%d agent_ip=%s", runID, agentIP)
if a.Logs != nil {
if w, err := a.Logs.WriterFor(runID); err == nil {
w.Append(logs.Line{Level: "info", Text: fmt.Sprintf("agent claimed from %s — entering Inventory", agentIP)})
}
}
// Stage-driven agent needs a bit of per-run config: the device
// allowlist (serial + expected size) for Storage, and the iperf3
@@ -331,6 +336,7 @@ type LogBatch struct {
type LogLine struct {
TS string `json:"ts,omitempty"` // RFC3339Nano; server clock used if empty
Level string `json:"level,omitempty"` // info|warn|error|debug
Stage string `json:"stage,omitempty"` // optional stage tag for per-stage log fan-out
Text string `json:"text"`
}
@@ -356,7 +362,7 @@ func (a *Agent) Log(w http.ResponseWriter, r *http.Request) {
}
for _, l := range batch.Lines {
ts, _ := time.Parse(time.RFC3339Nano, l.TS)
writer.Append(logs.Line{TS: ts, Level: l.Level, Text: l.Text})
writer.Append(logs.Line{TS: ts, Level: l.Level, Stage: l.Stage, Text: l.Text})
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "written": len(batch.Lines)})
}
+47
View File
@@ -121,6 +121,53 @@ func TestHostDetail_NeverRun(t *testing.T) {
}
}
// TestHostDetail_LogTabsRendered: when a run exists, the detail page
// emits the log-tabs scaffold with one radio per stage + an "All" tab
// checked by default. CSS sibling selectors drive visibility — no JS.
func TestHostDetail_LogTabsRendered(t *testing.T) {
ui, hosts, runs := setupDetail(t)
ctx := context.Background()
id, err := hosts.Create(ctx, model.Host{
Name: "tabs-host",
MAC: "aa:bb:cc:dd:ee:40",
WoLBroadcastIP: "10.0.0.255",
WoLPort: 9,
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
})
if err != nil {
t.Fatalf("create host: %v", err)
}
runID, err := runs.Create(ctx, id, "cafef00d")
if err != nil {
t.Fatalf("create run: %v", err)
}
rr := httptest.NewRecorder()
ui.HostDetail(rr, detailReq(id))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
body := rr.Body.String()
// All tab: the default-checked radio, plus its pane.
wantAllID := fmt.Sprintf(`id="log-tab-%d-all"`, runID)
if !strings.Contains(body, wantAllID) {
t.Fatalf("body missing All tab radio %s", wantAllID)
}
// Per-stage tabs: every entry in DefaultStageOrder must have its own
// radio + pane so tabs switch purely via sibling CSS.
for _, s := range store.DefaultStageOrder {
wantRadio := fmt.Sprintf(`id="log-tab-%d-%s"`, runID, s)
if !strings.Contains(body, wantRadio) {
t.Fatalf("body missing stage tab radio %s", wantRadio)
}
wantPane := fmt.Sprintf(`id="log-%d-%s"`, runID, s)
if !strings.Contains(body, wantPane) {
t.Fatalf("body missing stage pane %s", wantPane)
}
}
}
func TestHostDetail_UnknownID(t *testing.T) {
ui, _, _ := setupDetail(t)
rr := httptest.NewRecorder()
+7
View File
@@ -16,6 +16,7 @@ import (
"gopkg.in/yaml.v3"
"vetting/internal/events"
"vetting/internal/logs"
"vetting/internal/model"
"vetting/internal/orchestrator"
"vetting/internal/store"
@@ -30,6 +31,7 @@ type UI struct {
SpecDiffs *store.SpecDiffs
Artifacts *store.Artifacts
EventHub *events.Hub
Logs *logs.Hub
Runner *orchestrator.Runner
Tiles *TileEnricher
PublicURL string // user-visible base URL baked into the quick-register one-liner
@@ -113,10 +115,15 @@ func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
}
}
t := u.Tiles.Build(r.Context(), *host, latest)
replay := ""
if latest != nil && u.Logs != nil {
replay = u.Logs.Replay(latest.ID)
}
data := templates.HostDetailData{
Tile: t,
Stages: stages,
SpecDiffs: diffs,
LogReplay: replay,
}
_ = templates.HostDetail(data).Render(r.Context(), w)
}
+73 -6
View File
@@ -21,6 +21,7 @@ import (
type Line struct {
TS time.Time
Level string // info|warn|error|debug
Stage string // optional — one of store.DefaultStageOrder; empty = orchestrator/agent framing
Text string
}
@@ -85,6 +86,54 @@ func (h *Hub) PathFor(runID int64) string {
return filepath.Join(h.dir, fmt.Sprintf("run-%d.log", runID))
}
// Replay reads the on-disk log for a run and returns one
// <div class="log-line"> fragment per line, suitable for inlining into
// the "All" log pane on initial page load. Missing file → empty string;
// the pane just stays empty until live events arrive. Does not subscribe
// to the SSE hub — callers are expected to pair this with a live
// sse-swap target on the same element.
func (h *Hub) Replay(runID int64) string {
path := h.PathFor(runID)
b, err := os.ReadFile(path)
if err != nil {
return ""
}
var out strings.Builder
for _, raw := range strings.Split(string(b), "\n") {
if raw == "" {
continue
}
// Format from Append: "<RFC3339Nano> <LEVEL> <text>"
// where LEVEL is right-padded to width 5 (e.g. " INFO",
// "ERROR"). TrimLeft the pad before splitting off the level.
tsEnd := strings.IndexByte(raw, ' ')
if tsEnd < 0 {
continue
}
ts, err := time.Parse(time.RFC3339Nano, raw[:tsEnd])
if err != nil {
continue
}
rest := strings.TrimLeft(raw[tsEnd+1:], " ")
lvEnd := strings.IndexByte(rest, ' ')
if lvEnd < 0 {
continue
}
level := strings.ToLower(rest[:lvEnd])
text := rest[lvEnd+1:]
// Disk format prepends "[stage] " to text when stage was set.
stage := ""
if strings.HasPrefix(text, "[") {
if end := strings.Index(text, "] "); end > 1 {
stage = text[1:end]
text = text[end+2:]
}
}
out.WriteString(renderLogSSE(Line{TS: ts, Level: level, Stage: stage, Text: text}))
}
return out.String()
}
// Append writes a line to disk and publishes an SSE event. Failures
// on disk log but don't block the SSE fan-out — the operator can still
// see the live tail even if disk IO is degraded.
@@ -97,15 +146,26 @@ func (w *Writer) Append(line Line) {
if line.Level == "" {
line.Level = "info"
}
stamped := fmt.Sprintf("%s %5s %s\n", line.TS.Format(time.RFC3339Nano), strings.ToUpper(line.Level), line.Text)
diskText := line.Text
if line.Stage != "" {
diskText = "[" + line.Stage + "] " + diskText
}
stamped := fmt.Sprintf("%s %5s %s\n", line.TS.Format(time.RFC3339Nano), strings.ToUpper(line.Level), diskText)
if _, err := w.f.WriteString(stamped); err != nil {
log.Printf("logs: write run-%d: %v", w.runID, err)
}
if w.hub != nil {
payload := renderLogSSE(line)
w.hub.Publish(events.Event{
Name: fmt.Sprintf("log-%d", w.runID),
Payload: renderLogSSE(line),
Payload: payload,
})
if line.Stage != "" {
w.hub.Publish(events.Event{
Name: fmt.Sprintf("log-%d-%s", w.runID, line.Stage),
Payload: payload,
})
}
}
}
@@ -120,15 +180,22 @@ func (w *Writer) Close() error {
return err
}
// renderLogSSE returns an HTMX-compatible fragment. The tile contains
// a <div id="log-N" hx-swap-oob="beforeend">: each event appends one
// <div class="log-line log-LEVEL"> to it.
// renderLogSSE returns an HTMX-compatible fragment. The detail-page
// panes contain <div id="log-N-..." hx-swap="beforeend">: each event
// appends one <div class="log-line log-LEVEL"> to them. Stage, if set,
// is rendered as a dim prefix so the "All" pane stays disambiguable
// even with multiple stages interleaved.
func renderLogSSE(l Line) string {
level := strings.ToLower(l.Level)
stagePrefix := ""
if l.Stage != "" {
stagePrefix = fmt.Sprintf(`<span class="log-stage">[%s]</span> `, html.EscapeString(l.Stage))
}
return fmt.Sprintf(
`<div class="log-line log-%s">%s %s</div>`,
`<div class="log-line log-%s">%s %s%s</div>`,
html.EscapeString(level),
html.EscapeString(l.TS.Format("15:04:05")),
stagePrefix,
html.EscapeString(l.Text),
)
}
+82
View File
@@ -76,6 +76,88 @@ func TestAppendFansOutToSSE(t *testing.T) {
}
}
// TestAppendStagePublishesBothEvents: a line tagged with a stage must
// fan out to BOTH the all-pane event (log-<runID>) AND the stage-pane
// event (log-<runID>-<stage>) so the detail page's per-stage tabs see
// their own slice. Disk format prepends "[stage] " so the flat log
// remains greppable.
func TestAppendStagePublishesBothEvents(t *testing.T) {
dir := t.TempDir()
hub := events.NewHub()
lh, err := logs.NewHub(dir, hub)
if err != nil {
t.Fatalf("NewHub: %v", err)
}
defer lh.Close()
_, ch, cancel := hub.Subscribe()
defer cancel()
w, err := lh.WriterFor(42)
if err != nil {
t.Fatalf("WriterFor: %v", err)
}
w.Append(logs.Line{Level: "info", Stage: "SMART", Text: "reading attributes"})
got := collect(ch, 4, 500*time.Millisecond)
names := map[string]int{}
for _, ev := range got {
if strings.HasPrefix(ev.Name, "log-") {
names[ev.Name]++
}
}
if names["log-42"] != 1 {
t.Fatalf("expected 1 event on log-42, got %d (names=%+v)", names["log-42"], names)
}
if names["log-42-SMART"] != 1 {
t.Fatalf("expected 1 event on log-42-SMART, got %d (names=%+v)", names["log-42-SMART"], names)
}
// Disk: stage prepended so flat log is still useful.
body, err := os.ReadFile(filepath.Join(dir, "run-42.log"))
if err != nil {
t.Fatalf("read log file: %v", err)
}
if !strings.Contains(string(body), "[SMART] reading attributes") {
t.Fatalf("disk log missing stage prefix: %q", body)
}
}
// TestReplay re-parses a file written by Append and emits the same SSE
// fragments — detail-page uses this to seed the All pane on reload of
// an in-flight run.
func TestReplay(t *testing.T) {
dir := t.TempDir()
hub := events.NewHub()
lh, err := logs.NewHub(dir, hub)
if err != nil {
t.Fatalf("NewHub: %v", err)
}
defer lh.Close()
w, err := lh.WriterFor(99)
if err != nil {
t.Fatalf("WriterFor: %v", err)
}
w.Append(logs.Line{Level: "info", Text: "dispatcher: picked"})
w.Append(logs.Line{Level: "info", Stage: "SMART", Text: "smartctl /dev/sda"})
replay := lh.Replay(99)
if !strings.Contains(replay, "dispatcher: picked") {
t.Fatalf("replay missing untagged line: %q", replay)
}
if !strings.Contains(replay, "smartctl /dev/sda") {
t.Fatalf("replay missing tagged line: %q", replay)
}
if !strings.Contains(replay, `class="log-stage"`) {
t.Fatalf("replay should render stage badge for tagged line: %q", replay)
}
// Missing file → empty string, no panic.
if got := lh.Replay(12345); got != "" {
t.Fatalf("replay of unknown run = %q, want empty", got)
}
}
// TestWriterForIsCached verifies a second call returns the same Writer
// — otherwise parallel /log POSTs would race on file opens and possibly
// stomp on in-flight writes.
+99 -7
View File
@@ -2,9 +2,12 @@ package orchestrator
import (
"context"
"fmt"
"log"
"sync"
"time"
"vetting/internal/logs"
"vetting/internal/model"
"vetting/internal/store"
)
@@ -12,6 +15,10 @@ import (
// Dispatcher picks Queued runs off the DB and drives them through
// WaitingWoL (sending a WoL packet). Concurrency is capped at Max.
//
// Pre-stage log lines (picked, WoL-sent, heartbeat, agent-claimed)
// are written into the per-run log via Logs so the detail page's
// log pane can show what's happening before the agent is alive.
//
// For Phase 2 the dispatcher's job ends at WaitingWoL; further
// transitions are driven by iPXE and agent callbacks. Phase 4+ will
// return here and shepherd each run through stage execution.
@@ -20,22 +27,32 @@ type Dispatcher struct {
Runs *store.Runs
Hosts *store.Hosts
Runner *Runner
Logs *logs.Hub
active chan struct{}
stop chan struct{}
// heartbeat tracks the last time we emitted a "still waiting"
// line for a given run, so the ticker doesn't spam the log.
hbMu sync.Mutex
lastBeat map[int64]time.Time
beatEvery time.Duration
}
func NewDispatcher(max int, runs *store.Runs, hosts *store.Hosts, runner *Runner) *Dispatcher {
func NewDispatcher(max int, runs *store.Runs, hosts *store.Hosts, runner *Runner, logHub *logs.Hub) *Dispatcher {
if max < 1 {
max = 1
}
return &Dispatcher{
Max: max,
Runs: runs,
Hosts: hosts,
Runner: runner,
active: make(chan struct{}, max),
stop: make(chan struct{}),
Max: max,
Runs: runs,
Hosts: hosts,
Runner: runner,
Logs: logHub,
active: make(chan struct{}, max),
stop: make(chan struct{}),
lastBeat: map[int64]time.Time{},
beatEvery: 30 * time.Second,
}
}
@@ -58,6 +75,7 @@ func (d *Dispatcher) loop(ctx context.Context) {
return
case <-t.C:
d.pickNext(ctx)
d.heartbeatWaiting(ctx)
}
}
}
@@ -106,19 +124,93 @@ func (d *Dispatcher) pickNext(ctx context.Context) {
log.Printf("dispatcher: get host %d: %v", queued.HostID, err)
return
}
d.runLog(queued.ID, "info", fmt.Sprintf("dispatcher: picked run for host %s (mac=%s wol=%s:%d)",
host.Name, host.MAC, host.WoLBroadcastIP, host.WoLPort))
if _, err := d.Runner.Transition(ctx, queued.ID, TriggerDispatched); err != nil {
log.Printf("dispatcher: transition run %d: %v", queued.ID, err)
d.runLog(queued.ID, "error", fmt.Sprintf("dispatcher: transition to WaitingWoL failed: %v", err))
return
}
if err := SendWoL(host.MAC, host.WoLBroadcastIP, host.WoLPort); err != nil {
log.Printf("dispatcher: WoL run %d host %s: %v", queued.ID, host.Name, err)
d.runLog(queued.ID, "error", fmt.Sprintf("dispatcher: WoL send failed: %v — check broadcast %s:%d is reachable",
err, host.WoLBroadcastIP, host.WoLPort))
// Stay in WaitingWoL; operator can retry or investigate.
return
}
log.Printf("dispatcher: WoL sent for run %d (host=%s mac=%s)", queued.ID, host.Name, host.MAC)
d.runLog(queued.ID, "info", fmt.Sprintf("dispatcher: sent WoL packet to %s via %s:%d — waiting for agent claim",
host.MAC, host.WoLBroadcastIP, host.WoLPort))
// Prime the heartbeat so the first "still waiting" fires 30s after
// dispatch, not immediately.
d.hbMu.Lock()
d.lastBeat[queued.ID] = time.Now()
d.hbMu.Unlock()
// Slot stays reserved until the run leaves active (Phase 4+).
// Phase 2 lets the loop observe inFlight via DB state.
released = true
<-d.active
}
// heartbeatWaiting emits a "still waiting" log line every beatEvery for
// each run still sitting in WaitingWoL. Helps the operator spot hangs
// without having to tail journalctl on the LXC.
func (d *Dispatcher) heartbeatWaiting(ctx context.Context) {
if d.Logs == nil {
return
}
runs, err := d.Runs.Active(ctx)
if err != nil {
return
}
now := time.Now()
d.hbMu.Lock()
defer d.hbMu.Unlock()
seen := map[int64]bool{}
for i := range runs {
r := &runs[i]
seen[r.ID] = true
if r.State != model.StateWaitingWoL {
continue
}
last, ok := d.lastBeat[r.ID]
if !ok {
// Run already in WaitingWoL from a previous process lifetime
// — prime so we don't spam immediately.
d.lastBeat[r.ID] = now
continue
}
if now.Sub(last) < d.beatEvery {
continue
}
elapsed := now.Sub(r.StartedAt).Truncate(time.Second)
d.runLog(r.ID, "info", fmt.Sprintf(
"still waiting for agent claim (%s) — check BIOS WoL, pxe.enabled, and live-image presence",
elapsed))
d.lastBeat[r.ID] = now
}
// Garbage-collect entries for runs that have left WaitingWoL.
for id := range d.lastBeat {
if !seen[id] {
delete(d.lastBeat, id)
}
}
}
// runLog writes a single line into the per-run log. Safe to call with a
// nil hub (tests construct Dispatcher directly) — it degrades to a
// stderr log line so nothing silently disappears.
func (d *Dispatcher) runLog(runID int64, level, text string) {
if d.Logs == nil {
log.Printf("run-%d %s: %s", runID, level, text)
return
}
w, err := d.Logs.WriterFor(runID)
if err != nil {
log.Printf("dispatcher: open log for run %d: %v", runID, err)
return
}
w.Append(logs.Line{Level: level, Text: text})
}
+47
View File
@@ -0,0 +1,47 @@
package orchestrator
import (
"os"
"path/filepath"
"strings"
"testing"
"vetting/internal/events"
"vetting/internal/logs"
)
// TestDispatcher_RunLogWritesToHub verifies the plumbing between the
// dispatcher and the per-run log hub: runLog must persist to the on-disk
// file so the detail page's replay + SSE fan-out see the same
// pre-stage diagnostics (picked / sent WoL / heartbeat).
func TestDispatcher_RunLogWritesToHub(t *testing.T) {
dir := t.TempDir()
ev := events.NewHub()
lh, err := logs.NewHub(dir, ev)
if err != nil {
t.Fatalf("NewHub: %v", err)
}
defer lh.Close()
d := &Dispatcher{Logs: lh}
d.runLog(7, "info", "dispatcher: sent WoL packet to aa:bb:cc:dd:ee:ff via 10.0.0.255:9")
body, err := os.ReadFile(filepath.Join(dir, "run-7.log"))
if err != nil {
t.Fatalf("read run log: %v", err)
}
if !strings.Contains(string(body), "dispatcher: sent WoL packet") {
t.Fatalf("run log missing dispatcher line: %q", body)
}
if !strings.Contains(string(body), "INFO") {
t.Fatalf("run log missing level: %q", body)
}
}
// TestDispatcher_RunLogNilHubDoesNotPanic: tests construct Dispatcher
// directly without a hub. runLog must degrade to stderr rather than
// panicking so the dispatcher loop stays alive.
func TestDispatcher_RunLogNilHubDoesNotPanic(t *testing.T) {
d := &Dispatcher{}
d.runLog(1, "info", "fallback path")
}
+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()