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:
@@ -0,0 +1,117 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vetting/internal/model"
|
||||
)
|
||||
|
||||
// TestDetailSummary_RootAttrs asserts the root <header> carries the
|
||||
// stable id and sse-swap target. Successive SSE swaps replace the
|
||||
// outer element, so without these attributes the second swap would
|
||||
// have nothing to target.
|
||||
func TestDetailSummary_RootAttrs(t *testing.T) {
|
||||
d := HostDetailData{
|
||||
Tile: TileData{
|
||||
Host: model.Host{ID: 7, Name: "alpha", MAC: "aa:bb:cc:dd:ee:ff"},
|
||||
},
|
||||
}
|
||||
html := RenderDetailSummaryString(d)
|
||||
for _, want := range []string{
|
||||
`id="detail-summary-7"`,
|
||||
`sse-swap="detail-summary-7"`,
|
||||
`hx-swap="outerHTML"`,
|
||||
} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("DetailSummary missing %q in:\n%s", want, html)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailActions_RootAttrs(t *testing.T) {
|
||||
d := HostDetailData{
|
||||
Tile: TileData{
|
||||
Host: model.Host{ID: 7, Name: "alpha", MAC: "aa:bb:cc:dd:ee:ff"},
|
||||
},
|
||||
}
|
||||
html := RenderDetailActionsString(d)
|
||||
for _, want := range []string{
|
||||
`id="detail-actions-7"`,
|
||||
`sse-swap="detail-actions-7"`,
|
||||
`hx-swap="outerHTML"`,
|
||||
} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("DetailActions missing %q in:\n%s", want, html)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetailSpecDiffs_EmptyWrapper: when a run exists but has no diffs,
|
||||
// the <section> wrapper still renders so a later SSE push has a target.
|
||||
// Without this, the very first SpecValidate diff write would have no
|
||||
// DOM element to swap into.
|
||||
func TestDetailSpecDiffs_EmptyWrapper(t *testing.T) {
|
||||
d := HostDetailData{
|
||||
Tile: TileData{
|
||||
Host: model.Host{ID: 7},
|
||||
Latest: &model.Run{ID: 42},
|
||||
},
|
||||
}
|
||||
html := RenderDetailSpecDiffsString(d)
|
||||
for _, want := range []string{
|
||||
`id="detail-specdiffs-42"`,
|
||||
`sse-swap="detail-specdiffs-42"`,
|
||||
} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("DetailSpecDiffs missing %q in empty state:\n%s", want, html)
|
||||
}
|
||||
}
|
||||
if strings.Contains(html, "<details") {
|
||||
t.Errorf("DetailSpecDiffs empty state must not render <details>:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetailHold_EmptyWrapper: same rationale as specdiffs — the
|
||||
// section wrapper is always present when a run exists so the first
|
||||
// hold event has a target.
|
||||
func TestDetailHold_EmptyWrapper(t *testing.T) {
|
||||
d := HostDetailData{
|
||||
Tile: TileData{
|
||||
Host: model.Host{ID: 7},
|
||||
Latest: &model.Run{ID: 42, State: model.StateInventoryCheck},
|
||||
},
|
||||
}
|
||||
html := RenderDetailHoldString(d)
|
||||
for _, want := range []string{
|
||||
`id="detail-hold-42"`,
|
||||
`sse-swap="detail-hold-42"`,
|
||||
} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("DetailHold missing %q in empty state:\n%s", want, html)
|
||||
}
|
||||
}
|
||||
if strings.Contains(html, "SSH available") {
|
||||
t.Errorf("DetailHold non-holding state must not render SSH block:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetailHold_HoldingRendersSSH: once the run enters FailedHolding
|
||||
// with an IP, the wrapper renders the ssh invocation.
|
||||
func TestDetailHold_HoldingRendersSSH(t *testing.T) {
|
||||
d := HostDetailData{
|
||||
Tile: TileData{
|
||||
Host: model.Host{ID: 7},
|
||||
HoldKeyPath: "/tmp/hold.key",
|
||||
Latest: &model.Run{
|
||||
ID: 42,
|
||||
State: model.StateFailedHolding,
|
||||
HoldIP: "10.0.0.7",
|
||||
},
|
||||
},
|
||||
}
|
||||
html := RenderDetailHoldString(d)
|
||||
if !strings.Contains(html, "ssh -i /tmp/hold.key root@10.0.0.7") {
|
||||
t.Errorf("DetailHold missing ssh invocation:\n%s", html)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user