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:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user