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
+64 -3
View File
@@ -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)