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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user