ui: split /hosts/{id} into host page + /runs/{runID} run page
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:
@@ -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 {
|
||||
|
||||
@@ -33,30 +33,38 @@ 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
|
||||
prevHost := orchestrator.HostPageRenderer
|
||||
prevRun := orchestrator.RunPageRenderer
|
||||
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
|
||||
orchestrator.HostPageRenderer = func(_ context.Context, hostID int64) (orchestrator.HostPageFragments, bool) {
|
||||
rows := map[int64]string{}
|
||||
if latest, err := runs.LatestForHost(context.Background(), hostID); err == nil && latest != nil {
|
||||
runID = latest.ID
|
||||
rows[latest.ID] = fmt.Sprintf(`<tr id="runrow-%d">row</tr>`, 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,
|
||||
return orchestrator.HostPageFragments{
|
||||
Summary: fmt.Sprintf(`<header id="detail-summary-%d">summary</header>`, hostID),
|
||||
Actions: fmt.Sprintf(`<section id="detail-actions-%d">actions</section>`, hostID),
|
||||
InFlightBanner: fmt.Sprintf(`<section id="detail-inflight-%d">inflight</section>`, hostID),
|
||||
RunRows: rows,
|
||||
}, true
|
||||
}
|
||||
orchestrator.RunPageRenderer = func(_ context.Context, runID int64) (orchestrator.RunPageFragments, bool) {
|
||||
return orchestrator.RunPageFragments{
|
||||
Header: fmt.Sprintf(`<header id="run-header-%d">header</header>`, runID),
|
||||
Hold: fmt.Sprintf(`<section id="detail-hold-%d">hold</section>`, runID),
|
||||
SpecDiffs: fmt.Sprintf(`<section id="detail-specdiffs-%d">diffs</section>`, runID),
|
||||
}, true
|
||||
}
|
||||
cleanup := func() {
|
||||
orchestrator.TileRenderer = prevTile
|
||||
orchestrator.PipelineRenderer = prevPipe
|
||||
orchestrator.HostDetailRenderer = prevDetail
|
||||
orchestrator.HostPageRenderer = prevHost
|
||||
orchestrator.RunPageRenderer = prevRun
|
||||
_ = conn.Close()
|
||||
}
|
||||
return runner, hosts, runs, hub, cleanup
|
||||
@@ -128,11 +136,12 @@ 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) {
|
||||
// TestPublishesHostPageAndRunPageFragments asserts that every state-
|
||||
// change publish site emits the full set of host-page SSE events
|
||||
// (summary, actions, in-flight banner, runrow) *and* the run-page
|
||||
// events (header, hold, specdiffs). Without this, neither /hosts/{id}
|
||||
// nor /runs/{runID} update live.
|
||||
func TestPublishesHostPageAndRunPageFragments(t *testing.T) {
|
||||
runner, hosts, runs, hub, cleanup := setupRunner(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
@@ -160,10 +169,13 @@ func TestPublishesHostDetailFragments(t *testing.T) {
|
||||
}
|
||||
|
||||
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,
|
||||
fmt.Sprintf("detail-summary-%d", hostID): false,
|
||||
fmt.Sprintf("detail-actions-%d", hostID): false,
|
||||
fmt.Sprintf("detail-inflight-%d", hostID): false,
|
||||
fmt.Sprintf("runrow-%d", runID): false,
|
||||
fmt.Sprintf("run-header-%d", runID): false,
|
||||
fmt.Sprintf("detail-hold-%d", runID): false,
|
||||
fmt.Sprintf("detail-specdiffs-%d", runID): false,
|
||||
}
|
||||
deadline := time.After(500 * time.Millisecond)
|
||||
for {
|
||||
|
||||
Reference in New Issue
Block a user