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