ui: stream host-detail fragments over SSE so the page updates live
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:
@@ -72,6 +72,42 @@ func (r *Runner) PublishTileUpdate(ctx context.Context, hostID int64) {
|
||||
r.publishTileUpdate(ctx, hostID)
|
||||
}
|
||||
|
||||
// PublishHostDetail broadcasts fresh HTML fragments for every non-log,
|
||||
// non-pipeline region of the host detail page: summary header, actions
|
||||
// row, spec-diffs list, and the hold-key SSH block. Callers should
|
||||
// invoke this alongside PublishTileUpdate from any site that mutates
|
||||
// state visible on the detail page.
|
||||
//
|
||||
// Safe to call when no renderer has been registered or the host has
|
||||
// been deleted; the call is silently dropped.
|
||||
func (r *Runner) PublishHostDetail(ctx context.Context, hostID int64) {
|
||||
if HostDetailRenderer == nil || r.EventHub == nil {
|
||||
return
|
||||
}
|
||||
f, ok := HostDetailRenderer(ctx, hostID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r.EventHub.Publish(events.Event{
|
||||
Name: fmt.Sprintf("detail-summary-%d", hostID),
|
||||
Payload: f.Summary,
|
||||
})
|
||||
r.EventHub.Publish(events.Event{
|
||||
Name: fmt.Sprintf("detail-actions-%d", hostID),
|
||||
Payload: f.Actions,
|
||||
})
|
||||
if f.LatestRunID != 0 {
|
||||
r.EventHub.Publish(events.Event{
|
||||
Name: fmt.Sprintf("detail-specdiffs-%d", f.LatestRunID),
|
||||
Payload: f.SpecDiffs,
|
||||
})
|
||||
r.EventHub.Publish(events.Event{
|
||||
Name: fmt.Sprintf("detail-hold-%d", f.LatestRunID),
|
||||
Payload: f.Hold,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) publishTileUpdate(ctx context.Context, hostID int64) {
|
||||
host, err := r.Hosts.Get(ctx, hostID)
|
||||
if err != nil {
|
||||
@@ -93,11 +129,17 @@ func (r *Runner) publishTileUpdate(ctx context.Context, hostID int64) {
|
||||
stages, err := r.Stages.ListForRun(ctx, latest.ID)
|
||||
if err != nil {
|
||||
log.Printf("publishTileUpdate: list stages run %d: %v", latest.ID, err)
|
||||
return
|
||||
} else {
|
||||
pipePayload := PipelineRenderer(latest, stages)
|
||||
r.EventHub.Publish(events.Event{Name: fmt.Sprintf("pipeline-%d", latest.ID), Payload: pipePayload})
|
||||
}
|
||||
pipePayload := PipelineRenderer(latest, stages)
|
||||
r.EventHub.Publish(events.Event{Name: fmt.Sprintf("pipeline-%d", latest.ID), Payload: pipePayload})
|
||||
}
|
||||
|
||||
// Detail-page fragments — everything on /hosts/{id} that isn't the
|
||||
// pipeline or the log pane. Co-located here so every site that
|
||||
// already publishes a tile refresh also refreshes the detail page
|
||||
// without the caller having to remember a second call.
|
||||
r.PublishHostDetail(ctx, hostID)
|
||||
}
|
||||
|
||||
// TileRenderer renders a single tile fragment. Registered at startup
|
||||
@@ -112,6 +154,25 @@ var TileRenderer func(ctx context.Context, host model.Host, latest *model.Run) s
|
||||
// orchestrator stays free of template imports.
|
||||
var PipelineRenderer func(run *model.Run, stages []model.Stage) string
|
||||
|
||||
// HostDetailFragments is the pre-rendered bundle of HTML fragments a
|
||||
// single PublishHostDetail call broadcasts over SSE. Summary and Actions
|
||||
// are always set; SpecDiffs and Hold are empty strings when there is no
|
||||
// latest run (the corresponding events are not published in that case).
|
||||
type HostDetailFragments struct {
|
||||
Summary string
|
||||
Actions string
|
||||
SpecDiffs string
|
||||
Hold string
|
||||
LatestRunID int64 // 0 when the host has no runs yet
|
||||
}
|
||||
|
||||
// HostDetailRenderer produces the four fragments for a given host.
|
||||
// Registered at startup by main so the orchestrator doesn't import the
|
||||
// template or store-enrichment layers. Returns ok=false when the host
|
||||
// cannot be loaded (deleted, DB error); caller skips publish in that
|
||||
// case.
|
||||
var HostDetailRenderer func(ctx context.Context, hostID int64) (HostDetailFragments, bool)
|
||||
|
||||
func renderTileSSE(ctx context.Context, host model.Host, latest *model.Run) string {
|
||||
if TileRenderer == nil {
|
||||
return fmt.Sprintf(`<article id="host-%d">state change</article>`, host.ID)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user