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
+32 -20
View File
@@ -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 {