ui: stream host-detail fragments over SSE so the page updates live
CI / Lint + build + test (push) Successful in 1m29s
Release / release (push) Has been cancelled

The detail page was only partly live: Pipeline + LogTabs subscribed to
SSE, but the summary header, actions row, spec-diffs list and hold-key
block all froze at page-load and required a manual refresh to catch up
with state changes.

Extract each of those four regions into its own named templ component
with a stable id and sse-swap target, add Render*String helpers so the
orchestrator can publish pre-rendered fragments, and register a
HostDetailRenderer alongside the existing Tile/Pipeline renderers.
PublishHostDetail is folded into publishTileUpdate so every call site
that already refreshes a tile now also refreshes the detail page —
keeps the fan-out honest without scattering new publish calls.

The empty-state wrappers for spec-diffs and hold are load-bearing:
without the <section id=... sse-swap=...> present at initial GET, the
first live event after SpecValidate or Hold writes would have no DOM
node to swap into.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 16:36:13 -04:00
parent 5e9ad7f569
commit 0db790ae3e
8 changed files with 1250 additions and 580 deletions
+80
View File
@@ -33,15 +33,30 @@ func setupRunner(t *testing.T) (*orchestrator.Runner, *store.Hosts, *store.Runs,
// grep the published fragments without parsing HTML.
prevTile := orchestrator.TileRenderer
prevPipe := orchestrator.PipelineRenderer
prevDetail := orchestrator.HostDetailRenderer
orchestrator.TileRenderer = func(_ context.Context, host model.Host, _ *model.Run) string {
return fmt.Sprintf(`<article id="host-%d">tile</article>`, host.ID)
}
orchestrator.PipelineRenderer = func(run *model.Run, _ []model.Stage) string {
return fmt.Sprintf(`<section id="pipeline-%d">pipeline</section>`, run.ID)
}
orchestrator.HostDetailRenderer = func(_ context.Context, hostID int64) (orchestrator.HostDetailFragments, bool) {
var runID int64
if latest, err := runs.LatestForHost(context.Background(), hostID); err == nil && latest != nil {
runID = latest.ID
}
return orchestrator.HostDetailFragments{
Summary: fmt.Sprintf(`<header id="detail-summary-%d">summary</header>`, hostID),
Actions: fmt.Sprintf(`<section id="detail-actions-%d">actions</section>`, hostID),
SpecDiffs: fmt.Sprintf(`<section id="detail-specdiffs-%d">diffs</section>`, runID),
Hold: fmt.Sprintf(`<section id="detail-hold-%d">hold</section>`, runID),
LatestRunID: runID,
}, true
}
cleanup := func() {
orchestrator.TileRenderer = prevTile
orchestrator.PipelineRenderer = prevPipe
orchestrator.HostDetailRenderer = prevDetail
_ = conn.Close()
}
return runner, hosts, runs, hub, cleanup
@@ -113,6 +128,71 @@ loop:
}
}
// TestPublishesHostDetailFragments asserts that every state-change
// publish site also emits the four detail-page SSE events (summary,
// actions, specdiffs, hold). Without this, the host detail page
// stays frozen on the state at page-load time.
func TestPublishesHostDetailFragments(t *testing.T) {
runner, hosts, runs, hub, cleanup := setupRunner(t)
defer cleanup()
ctx := context.Background()
hostID, err := hosts.Create(ctx, model.Host{
Name: "runner-detail",
MAC: "aa:bb:cc:dd:ee:42",
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, hostID, "deadbeef", false)
if err != nil {
t.Fatalf("create run: %v", err)
}
_, ch, cancel := hub.Subscribe()
defer cancel()
if _, err := runner.Transition(ctx, runID, orchestrator.TriggerDispatched); err != nil {
t.Fatalf("transition: %v", err)
}
want := map[string]bool{
fmt.Sprintf("detail-summary-%d", hostID): false,
fmt.Sprintf("detail-actions-%d", hostID): false,
fmt.Sprintf("detail-specdiffs-%d", runID): false,
fmt.Sprintf("detail-hold-%d", runID): false,
}
deadline := time.After(500 * time.Millisecond)
for {
allSeen := true
for _, seen := range want {
if !seen {
allSeen = false
break
}
}
if allSeen {
return
}
select {
case ev := <-ch:
if _, ok := want[ev.Name]; ok {
want[ev.Name] = true
}
case <-deadline:
for name, seen := range want {
if !seen {
t.Errorf("no %s event published", name)
}
}
return
}
}
}
// TestCompleteStagePublishesPipeline covers the stage-completion path
// that used to go direct-to-Stages, bypassing the SSE refresh. The
// Runner.CompleteStage wrapper exists so stage-dot advancements show up