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
+9 -13
View File
@@ -692,14 +692,10 @@ func (a *Agent) Hold(w http.ResponseWriter, r *http.Request) {
URL: a.runLinkURL(runID),
})
}
// Refresh the tile so the operator sees the ssh command.
host, _ := a.Hosts.Get(r.Context(), mustHostID(a, r, runID))
if host != nil {
latest, _ := a.Runs.Get(r.Context(), runID)
if orchestrator.TileRenderer != nil {
payload := orchestrator.TileRenderer(r.Context(), *host, latest)
a.EventHub.Publish(events.Event{Name: fmt.Sprintf("tile-%d", host.ID), Payload: payload})
}
// Refresh the tile + all detail-page fragments so the operator
// sees the ssh command and the hold banner without reloading.
if id := mustHostID(a, r, runID); id != 0 && a.Runner != nil {
a.Runner.PublishTileUpdate(r.Context(), id)
}
writeJSON(w, http.StatusOK, HoldResponse{AuthorizedKey: kp.AuthorizedKey, RunID: runID})
}
@@ -907,11 +903,11 @@ func (a *Agent) resolveReporting(r *http.Request, runID int64) {
log.Printf("reporting: mark completed: %v", err)
}
a.appendLog(runID, "info", "Reporting: wrote "+path+"; run completed.")
// Publish a final tile update so the dashboard flips to pass mood.
if host != nil && orchestrator.TileRenderer != nil {
latest, _ := a.Runs.Get(ctx, runID)
payload := orchestrator.TileRenderer(ctx, *host, latest)
a.EventHub.Publish(events.Event{Name: fmt.Sprintf("tile-%d", host.ID), Payload: payload})
// Publish a final tile + detail update so the dashboard flips to
// pass mood and the detail page's summary/actions update without
// the operator reloading.
if host != nil && a.Runner != nil {
a.Runner.PublishTileUpdate(ctx, host.ID)
}
hostName := "host"
if host != nil {
+25 -10
View File
@@ -118,7 +118,7 @@ func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad host id", http.StatusBadRequest)
return
}
host, err := u.Hosts.Get(r.Context(), id)
data, err := u.LoadHostDetailData(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
@@ -127,33 +127,48 @@ func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
latest, err := u.Runs.LatestForHost(r.Context(), id)
_ = templates.HostDetail(data).Render(r.Context(), w)
}
// LoadHostDetailData assembles the HostDetailData payload for hostID —
// the same bundle the initial GET renders. Also used by the orchestrator's
// PublishHostDetail path so the live SSE fragments render from identical
// inputs as the initial page, avoiding drift between reload-rendered and
// pushed HTML. Returns store.ErrNotFound if the host doesn't exist; all
// other store errors are surfaced to the caller. Sub-queries for stages,
// diffs, replay, and tile enrichment are fail-soft (empty on error) —
// mirrors the original inline behaviour so a transient DB hiccup on one
// relation doesn't blank the whole page.
func (u *UI) LoadHostDetailData(ctx context.Context, hostID int64) (templates.HostDetailData, error) {
host, err := u.Hosts.Get(ctx, hostID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
return templates.HostDetailData{}, err
}
latest, err := u.Runs.LatestForHost(ctx, hostID)
if err != nil {
return templates.HostDetailData{}, err
}
var stages []model.Stage
var diffs []model.SpecDiff
if latest != nil {
if u.Stages != nil {
stages, _ = u.Stages.ListForRun(r.Context(), latest.ID)
stages, _ = u.Stages.ListForRun(ctx, latest.ID)
}
if u.SpecDiffs != nil {
diffs, _ = u.SpecDiffs.ListForRun(r.Context(), latest.ID)
diffs, _ = u.SpecDiffs.ListForRun(ctx, latest.ID)
}
}
t := u.Tiles.Build(r.Context(), *host, latest)
t := u.Tiles.Build(ctx, *host, latest)
replay := ""
if latest != nil && u.Logs != nil {
replay = u.Logs.Replay(latest.ID)
}
data := templates.HostDetailData{
return templates.HostDetailData{
Tile: t,
Stages: stages,
SpecDiffs: diffs,
LogReplay: replay,
}
_ = templates.HostDetail(data).Render(r.Context(), w)
}, nil
}
// StartRun creates a new Run for the host, issues an agent token, and