ui: split /hosts/{id} into host page + /runs/{runID} run page
CI / Lint + build + test (push) Successful in 1m35s
Release / release (push) Successful in 23m47s

Host page owns host metadata, full runs table with per-row stage strip,
in-flight banner, and empty-state CTA. Run page owns pipeline, active
step, logs, sub-steps, spec diffs, and hold banner with a breadcrumb
back to the host. Dashboard tile reverts to host-only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 20:37:57 -04:00
parent 5c6bfa5ffa
commit 19608bef1b
23 changed files with 3173 additions and 2827 deletions
+91 -36
View File
@@ -72,19 +72,21 @@ 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.
// PublishHostPage broadcasts fresh HTML fragments for every host-keyed
// region on /hosts/{id}: summary card, primary-actions row, and the
// in-flight banner. It also fires a runrow-{runID} swap for every run
// whose row is affected by this state change (the active one plus any
// run that just completed). Callers should invoke this alongside
// PublishTileUpdate at every site that mutates state visible on the
// host page or its runs table.
//
// 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 {
func (r *Runner) PublishHostPage(ctx context.Context, hostID int64) {
if HostPageRenderer == nil || r.EventHub == nil {
return
}
f, ok := HostDetailRenderer(ctx, hostID)
f, ok := HostPageRenderer(ctx, hostID)
if !ok {
return
}
@@ -96,18 +98,48 @@ func (r *Runner) PublishHostDetail(ctx context.Context, hostID int64) {
Name: fmt.Sprintf("detail-actions-%d", hostID),
Payload: f.Actions,
})
if f.LatestRunID != 0 {
r.EventHub.Publish(events.Event{
Name: fmt.Sprintf("detail-inflight-%d", hostID),
Payload: f.InFlightBanner,
})
for runID, payload := range f.RunRows {
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,
Name: fmt.Sprintf("runrow-%d", runID),
Payload: payload,
})
}
}
// PublishRunPage broadcasts fresh HTML fragments for every run-keyed
// region on /runs/{runID}: header (with Cancel / Start-new-run /
// View-report), hold banner, and spec diffs. The pipeline is already
// fired separately from publishTileUpdate. Caller is any site that
// transitions run state or writes a spec-diff / hold row.
//
// Safe to call when no renderer has been registered or the run has
// been deleted; the call is silently dropped.
func (r *Runner) PublishRunPage(ctx context.Context, runID int64) {
if RunPageRenderer == nil || r.EventHub == nil {
return
}
f, ok := RunPageRenderer(ctx, runID)
if !ok {
return
}
r.EventHub.Publish(events.Event{
Name: fmt.Sprintf("run-header-%d", runID),
Payload: f.Header,
})
r.EventHub.Publish(events.Event{
Name: fmt.Sprintf("detail-hold-%d", runID),
Payload: f.Hold,
})
r.EventHub.Publish(events.Event{
Name: fmt.Sprintf("detail-specdiffs-%d", runID),
Payload: f.SpecDiffs,
})
}
func (r *Runner) publishTileUpdate(ctx context.Context, hostID int64) {
host, err := r.Hosts.Get(ctx, hostID)
if err != nil {
@@ -135,11 +167,19 @@ func (r *Runner) publishTileUpdate(ctx context.Context, hostID int64) {
}
}
// 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)
// Host-page fragments — everything on /hosts/{id} that isn't the
// pipeline or the log pane: summary card, primary actions, in-flight
// banner, and per-run row swaps in the runs table. Co-located here
// so every tile-refresh site also refreshes the host page without
// the caller having to remember a second call.
r.PublishHostPage(ctx, hostID)
// Run-page fragments — header (cancel button visibility), hold
// banner, spec diffs. Fires alongside the tile + pipeline refreshes
// so any state-change site covers both /hosts/{id} and /runs/{runID}.
if latest != nil {
r.PublishRunPage(ctx, latest.ID)
}
}
// TileRenderer renders a single tile fragment. Registered at startup
@@ -173,24 +213,39 @@ func (r *Runner) PublishSubStepUpdate(ctx context.Context, ss model.SubStep) {
})
}
// 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
// HostPageFragments is the pre-rendered bundle a single PublishHostPage
// call broadcasts over SSE. Summary, Actions, and InFlightBanner are
// always set. RunRows is a runID → pre-rendered <tr> map so every row
// whose state just changed refreshes atomically (typically only the
// active run, plus whichever run just became terminal).
type HostPageFragments struct {
Summary string
Actions string
InFlightBanner string
RunRows map[int64]string
}
// 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)
// HostPageRenderer produces the 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 HostPageRenderer func(ctx context.Context, hostID int64) (HostPageFragments, bool)
// RunPageFragments is the pre-rendered bundle a single PublishRunPage
// call broadcasts over SSE. Header is always set; Hold and SpecDiffs
// are always set too (they emit an empty placeholder when no hold /
// diffs exist, so the first real event has a DOM target).
type RunPageFragments struct {
Header string
Hold string
SpecDiffs string
}
// RunPageRenderer produces the fragments for a given run. Registered at
// startup by main so the orchestrator doesn't import the template or
// store-enrichment layers. Returns ok=false when the run cannot be
// loaded.
var RunPageRenderer func(ctx context.Context, runID int64) (RunPageFragments, bool)
func renderTileSSE(ctx context.Context, host model.Host, latest *model.Run) string {
if TileRenderer == nil {