Host detail v2: full pipeline + per-stage logs + WoL diagnostics
CI / Lint + build + test (push) Has been cancelled
CI / Lint + build + test (push) Has been cancelled
Pipeline now always renders all 13 nodes (3 pre-stage + 9 stage +
Completed), synthesising ghosts from run state when stage rows
aren't seeded yet. Makes a WaitingWoL host show the full timeline
ahead of it instead of just 4 dots.
Agent tags each log line with its stage; logs.Hub fans out to both
log-{runID} and log-{runID}-{stage} SSE events so the detail page
can show per-stage tabs with a pure-CSS radio-sibling switch. Flat
run log prepends [stage] so grep still works.
Dispatcher writes picked/sent-WoL/heartbeat lines into the per-run
log — the operator opens the detail page, sees WaitingWoL stuck,
and reads exactly what the dispatcher did and why nothing's
progressing, instead of having to tail journalctl on the LXC.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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)})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user