diff --git a/agent/client.go b/agent/client.go
index dd9ea6b..9b6b7c4 100644
--- a/agent/client.go
+++ b/agent/client.go
@@ -134,6 +134,7 @@ type HeartbeatResponse struct {
type LogLine struct {
TS string `json:"ts,omitempty"`
Level string `json:"level,omitempty"`
+ Stage string `json:"stage,omitempty"`
Text string `json:"text"`
}
diff --git a/agent/runner.go b/agent/runner.go
index feb6ed3..821fac5 100644
--- a/agent/runner.go
+++ b/agent/runner.go
@@ -120,6 +120,8 @@ func Run(ctx context.Context, p *bootstate.Params) error {
// (the orchestrator persists it as an artifact). Every other stage
// returns a tests.Outcome which postResult marshals generically.
func runStage(ctx context.Context, stage string, claim *ClaimResponse, fwd *logForwarder, c *Client, ovr overrideFlags) stageOutcome {
+ fwd.SetStage(stage)
+ defer fwd.ClearStage()
deps := newDeps(ctx, c, fwd, ovr, claim)
switch stage {
case "Inventory":
@@ -436,6 +438,7 @@ type logForwarder struct {
c *Client
mu sync.Mutex
buf []LogLine
+ stage string // set via SetStage; empties via ClearStage
wg sync.WaitGroup
cancel context.CancelFunc
}
@@ -467,7 +470,7 @@ func (f *logForwarder) push(level, text string) {
stamp := time.Now().UTC().Format(time.RFC3339Nano)
log.Printf("[%s] %s", level, text)
f.mu.Lock()
- f.buf = append(f.buf, LogLine{TS: stamp, Level: level, Text: text})
+ f.buf = append(f.buf, LogLine{TS: stamp, Level: level, Stage: f.stage, Text: text})
f.mu.Unlock()
}
@@ -475,6 +478,23 @@ func (f *logForwarder) info(s string) { f.push("info", s) }
func (f *logForwarder) warn(s string) { f.push("warn", s) }
func (f *logForwarder) error(s string) { f.push("error", s) }
+// SetStage tags subsequent log lines with a stage name so the orchestrator
+// can fan them out on a per-stage SSE event. Safe to call concurrently
+// with push — we take the same mutex.
+func (f *logForwarder) SetStage(stage string) {
+ f.mu.Lock()
+ f.stage = stage
+ f.mu.Unlock()
+}
+
+// ClearStage reverts to untagged (framing-level) logging. Defer this
+// on entry to runStage so hold/override paths don't leak stage context.
+func (f *logForwarder) ClearStage() {
+ f.mu.Lock()
+ f.stage = ""
+ f.mu.Unlock()
+}
+
func (f *logForwarder) flush() {
f.mu.Lock()
if len(f.buf) == 0 {
diff --git a/cmd/vetting/main.go b/cmd/vetting/main.go
index 1b15372..dde27c6 100644
--- a/cmd/vetting/main.go
+++ b/cmd/vetting/main.go
@@ -103,6 +103,7 @@ func main() {
SpecDiffs: specDiffStore,
Artifacts: artifactStore,
EventHub: hub,
+ Logs: logHub,
Runner: runner,
Tiles: tiles,
PublicURL: cfg.Server.PublicURL,
@@ -126,7 +127,7 @@ func main() {
}
agentAPI.LiveKernelURL, agentAPI.LiveInitrdURL = pxe.BuildLiveURLs(cfg.PXE.OrchestratorURL)
- dispatcher := orchestrator.NewDispatcher(cfg.Dispatcher.MaxConcurrentRuns, runStore, hostStore, runner)
+ dispatcher := orchestrator.NewDispatcher(cfg.Dispatcher.MaxConcurrentRuns, runStore, hostStore, runner, logHub)
iperfSup := orchestrator.NewIperfSupervisor(cfg.Network.IperfPort)
janitorSvc := janitor.New(janitor.Config{
diff --git a/internal/api/agent_handlers.go b/internal/api/agent_handlers.go
index 0239138..841980d 100644
--- a/internal/api/agent_handlers.go
+++ b/internal/api/agent_handlers.go
@@ -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)})
}
diff --git a/internal/api/host_detail_test.go b/internal/api/host_detail_test.go
index 9001eaa..d22b4d7 100644
--- a/internal/api/host_detail_test.go
+++ b/internal/api/host_detail_test.go
@@ -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()
diff --git a/internal/api/ui_handlers.go b/internal/api/ui_handlers.go
index ceeb02a..2c95f3c 100644
--- a/internal/api/ui_handlers.go
+++ b/internal/api/ui_handlers.go
@@ -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)
}
diff --git a/internal/logs/logs.go b/internal/logs/logs.go
index 6f13971..32a1638 100644
--- a/internal/logs/logs.go
+++ b/internal/logs/logs.go
@@ -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
+//
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: "
"
+ // 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 : each event appends one
-//
to it.
+// renderLogSSE returns an HTMX-compatible fragment. The detail-page
+// panes contain
: each event
+// appends one
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(`
[%s] `, html.EscapeString(l.Stage))
+ }
return fmt.Sprintf(
- `
%s %s
`,
+ `
%s %s%s
`,
html.EscapeString(level),
html.EscapeString(l.TS.Format("15:04:05")),
+ stagePrefix,
html.EscapeString(l.Text),
)
}
diff --git a/internal/logs/logs_test.go b/internal/logs/logs_test.go
index 5678747..15c413f 100644
--- a/internal/logs/logs_test.go
+++ b/internal/logs/logs_test.go
@@ -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-
) AND the stage-pane
+// event (log--) 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.
diff --git a/internal/orchestrator/dispatcher.go b/internal/orchestrator/dispatcher.go
index 38c4951..4b1e2e8 100644
--- a/internal/orchestrator/dispatcher.go
+++ b/internal/orchestrator/dispatcher.go
@@ -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})
+}
diff --git a/internal/orchestrator/dispatcher_test.go b/internal/orchestrator/dispatcher_test.go
new file mode 100644
index 0000000..4dea228
--- /dev/null
+++ b/internal/orchestrator/dispatcher_test.go
@@ -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")
+}
diff --git a/internal/web/static/app.css b/internal/web/static/app.css
index 5dfbb50..0f284e7 100644
--- a/internal/web/static/app.css
+++ b/internal/web/static/app.css
@@ -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); }
}
diff --git a/internal/web/templates/host_detail.templ b/internal/web/templates/host_detail.templ
index 42d5f3b..3415cac 100644
--- a/internal/web/templates/host_detail.templ
+++ b/internal/web/templates/host_detail.templ
@@ -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 {
-
+ @LogTabs(d.Tile.Latest.ID, d.LogReplay)
}