ui: stream host-detail fragments over SSE so the page updates live
CI / Lint + build + test (push) Successful in 1m29s
Release / release (push) Has been cancelled

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:
2026-04-18 16:36:13 -04:00
parent 5e9ad7f569
commit 0db790ae3e
8 changed files with 1250 additions and 580 deletions
+21
View File
@@ -109,6 +109,27 @@ func main() {
PublicURL: cfg.Server.PublicURL,
}
// Inject the host-detail fragment renderer. The closure reuses
// LoadHostDetailData so the SSE-pushed HTML matches an identical
// reload-rendered page byte-for-byte, then hands each region to
// its Render*String helper.
orchestrator.HostDetailRenderer = func(ctx context.Context, hostID int64) (orchestrator.HostDetailFragments, bool) {
d, err := ui.LoadHostDetailData(ctx, hostID)
if err != nil {
return orchestrator.HostDetailFragments{}, false
}
f := orchestrator.HostDetailFragments{
Summary: templates.RenderDetailSummaryString(d),
Actions: templates.RenderDetailActionsString(d),
SpecDiffs: templates.RenderDetailSpecDiffsString(d),
Hold: templates.RenderDetailHoldString(d),
}
if d.Tile.Latest != nil {
f.LatestRunID = d.Tile.Latest.ID
}
return f, true
}
agentAPI := &api.Agent{
Hosts: hostStore,
Runs: runStore,
+9 -13
View File
@@ -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
View File
@@ -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
+64 -3
View File
@@ -72,6 +72,42 @@ 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.
//
// 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 {
return
}
f, ok := HostDetailRenderer(ctx, hostID)
if !ok {
return
}
r.EventHub.Publish(events.Event{
Name: fmt.Sprintf("detail-summary-%d", hostID),
Payload: f.Summary,
})
r.EventHub.Publish(events.Event{
Name: fmt.Sprintf("detail-actions-%d", hostID),
Payload: f.Actions,
})
if f.LatestRunID != 0 {
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,
})
}
}
func (r *Runner) publishTileUpdate(ctx context.Context, hostID int64) {
host, err := r.Hosts.Get(ctx, hostID)
if err != nil {
@@ -93,11 +129,17 @@ func (r *Runner) publishTileUpdate(ctx context.Context, hostID int64) {
stages, err := r.Stages.ListForRun(ctx, latest.ID)
if err != nil {
log.Printf("publishTileUpdate: list stages run %d: %v", latest.ID, err)
return
} else {
pipePayload := PipelineRenderer(latest, stages)
r.EventHub.Publish(events.Event{Name: fmt.Sprintf("pipeline-%d", latest.ID), Payload: pipePayload})
}
pipePayload := PipelineRenderer(latest, stages)
r.EventHub.Publish(events.Event{Name: fmt.Sprintf("pipeline-%d", latest.ID), Payload: pipePayload})
}
// 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)
}
// TileRenderer renders a single tile fragment. Registered at startup
@@ -112,6 +154,25 @@ var TileRenderer func(ctx context.Context, host model.Host, latest *model.Run) s
// orchestrator stays free of template imports.
var PipelineRenderer func(run *model.Run, stages []model.Stage) string
// 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
}
// 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)
func renderTileSSE(ctx context.Context, host model.Host, latest *model.Run) string {
if TileRenderer == nil {
return fmt.Sprintf(`<article id="host-%d">state change</article>`, host.ID)
+80
View File
@@ -33,15 +33,30 @@ 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
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
if latest, err := runs.LatestForHost(context.Background(), hostID); err == nil && latest != nil {
runID = 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,
}, true
}
cleanup := func() {
orchestrator.TileRenderer = prevTile
orchestrator.PipelineRenderer = prevPipe
orchestrator.HostDetailRenderer = prevDetail
_ = conn.Close()
}
return runner, hosts, runs, hub, cleanup
@@ -113,6 +128,71 @@ 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) {
runner, hosts, runs, hub, cleanup := setupRunner(t)
defer cleanup()
ctx := context.Background()
hostID, err := hosts.Create(ctx, model.Host{
Name: "runner-detail",
MAC: "aa:bb:cc:dd:ee:42",
WoLBroadcastIP: "10.0.0.255",
WoLPort: 9,
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
})
if err != nil {
t.Fatalf("create host: %v", err)
}
runID, err := runs.Create(ctx, hostID, "deadbeef", false)
if err != nil {
t.Fatalf("create run: %v", err)
}
_, ch, cancel := hub.Subscribe()
defer cancel()
if _, err := runner.Transition(ctx, runID, orchestrator.TriggerDispatched); err != nil {
t.Fatalf("transition: %v", err)
}
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,
}
deadline := time.After(500 * time.Millisecond)
for {
allSeen := true
for _, seen := range want {
if !seen {
allSeen = false
break
}
}
if allSeen {
return
}
select {
case ev := <-ch:
if _, ok := want[ev.Name]; ok {
want[ev.Name] = true
}
case <-deadline:
for name, seen := range want {
if !seen {
t.Errorf("no %s event published", name)
}
}
return
}
}
}
// TestCompleteStagePublishesPipeline covers the stage-completion path
// that used to go direct-to-Stages, bypassing the SSE refresh. The
// Runner.CompleteStage wrapper exists so stage-dot advancements show up
+178 -89
View File
@@ -1,6 +1,8 @@
package templates
import (
"bytes"
"context"
"fmt"
"vetting/internal/model"
@@ -29,37 +31,7 @@ templ HostDetail(d HostDetailData) {
<span>{ d.Tile.Host.Name }</span>
</nav>
<header class={ "detail-summary", "tile-" + tileMood(d.Tile.Latest) }>
<div class="detail-summary-head">
<h1 class="detail-name">{ d.Tile.Host.Name }</h1>
<div class="detail-status-row">
<span class={ "tile-last-seen", lastSeenClass(d.Tile.LastSeenAt) }>{ lastSeenLabel(d.Tile.LastSeenAt) }</span>
<span class="tile-status">{ tileStatus(d.Tile.Latest) }</span>
</div>
</div>
<dl class="detail-meta">
<div>
<dt>MAC</dt>
<dd>{ d.Tile.Host.MAC }</dd>
</div>
<div>
<dt>WoL</dt>
<dd>{ fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort) }</dd>
</div>
if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" {
<div>
<dt>Failed at</dt>
<dd class="bad">{ d.Tile.Latest.FailedStage }</dd>
</div>
}
if d.Tile.SpecDiffCritical > 0 {
<div>
<dt>Spec diffs</dt>
<dd class="bad">{ fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical) }</dd>
</div>
}
</dl>
</header>
@DetailSummary(d)
if d.Tile.Latest != nil {
<section
@@ -78,64 +50,9 @@ templ HostDetail(d HostDetailData) {
</section>
}
if d.Tile.Latest != nil && d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" {
<section class="detail-section detail-hold">
<h2>Host is holding — SSH available</h2>
<code class="hold-ssh">{ sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP) }</code>
</section>
}
<section class="detail-section detail-actions">
<h2>Actions</h2>
<div class="detail-actions-row">
if canStart(d.Tile) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Tile.Host.ID)) } class="inline detail-start-form">
<label class="detail-nd-toggle">
<input type="checkbox" name="non_destructive" value="1"/>
Non-destructive (skip wipe-probe + disk writes)
</label>
<button type="submit">Start vetting</button>
</form>
} else if canStartIfOnline(d.Tile.Latest) {
<button type="button" disabled title="host is not heartbeating — install the reporter via /register/quick.sh on the target host">Start vetting</button>
} else {
<button type="button" disabled>Run in flight</button>
}
if canCancel(d.Tile.Latest) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Tile.Host.ID)) } class="inline" onsubmit="return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');">
<button type="submit" class="danger">Cancel run</button>
</form>
}
if canOverrideWipe(d.Tile.Latest) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Tile.Host.ID)) } class="inline">
<button type="submit" class="danger">Override wipe-probe</button>
</form>
}
if hasReport(d.Tile.Latest) {
<a class="button-like" href={ templ.SafeURL(fmt.Sprintf("/reports/%d", d.Tile.Latest.ID)) } target="_blank" rel="noopener">View report</a>
}
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Tile.Host.ID)) } class="inline">
<button type="submit" class="danger">Delete host</button>
</form>
</div>
</section>
if len(d.SpecDiffs) > 0 {
<section class="detail-section detail-diffs">
<details open?={ hasCriticalDiff(d.SpecDiffs) }>
<summary><h2>Spec diffs ({ fmt.Sprintf("%d", len(d.SpecDiffs)) })</h2></summary>
<ul class="diff-list">
for _, diff := range d.SpecDiffs {
<li class={ "diff-row", "diff-" + diff.Severity }>
<div class="diff-field">{ diff.Field }</div>
<div class="diff-expected">expected: <code>{ diff.Expected }</code></div>
<div class="diff-actual">actual: <code>{ diff.Actual }</code></div>
</li>
}
</ul>
</details>
</section>
}
@DetailHold(d)
@DetailActions(d)
@DetailSpecDiffs(d)
if d.Tile.Latest != nil {
@LogTabs(d.Tile.Latest.ID, d.LogReplay)
@@ -160,6 +77,178 @@ templ HostDetail(d HostDetailData) {
}
}
// DetailSummary is the status header at the top of the detail page:
// name, last-seen badge, run status, MAC/WoL/failed-stage/spec-diffs
// meta grid. Keyed on host ID so the SSE event name is stable across
// run turnover.
templ DetailSummary(d HostDetailData) {
<header
id={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
class={ "detail-summary", "tile-" + tileMood(d.Tile.Latest) }
sse-swap={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
hx-swap="outerHTML"
>
<div class="detail-summary-head">
<h1 class="detail-name">{ d.Tile.Host.Name }</h1>
<div class="detail-status-row">
<span class={ "tile-last-seen", lastSeenClass(d.Tile.LastSeenAt) }>{ lastSeenLabel(d.Tile.LastSeenAt) }</span>
<span class="tile-status">{ tileStatus(d.Tile.Latest) }</span>
</div>
</div>
<dl class="detail-meta">
<div>
<dt>MAC</dt>
<dd>{ d.Tile.Host.MAC }</dd>
</div>
<div>
<dt>WoL</dt>
<dd>{ fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort) }</dd>
</div>
if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" {
<div>
<dt>Failed at</dt>
<dd class="bad">{ d.Tile.Latest.FailedStage }</dd>
</div>
}
if d.Tile.SpecDiffCritical > 0 {
<div>
<dt>Spec diffs</dt>
<dd class="bad">{ fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical) }</dd>
</div>
}
</dl>
</header>
}
// DetailActions is the button row (Start / Cancel / Override / View
// report / Delete). Enabled/disabled state depends on the latest run's
// state and host heartbeat; both change live, so this section re-renders
// on every state change. Keyed on host ID — the actions exist even
// without a run.
templ DetailActions(d HostDetailData) {
<section
id={ fmt.Sprintf("detail-actions-%d", d.Tile.Host.ID) }
class="detail-section detail-actions"
sse-swap={ fmt.Sprintf("detail-actions-%d", d.Tile.Host.ID) }
hx-swap="outerHTML"
>
<h2>Actions</h2>
<div class="detail-actions-row">
if canStart(d.Tile) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Tile.Host.ID)) } class="inline detail-start-form">
<label class="detail-nd-toggle">
<input type="checkbox" name="non_destructive" value="1"/>
Non-destructive (skip wipe-probe + disk writes)
</label>
<button type="submit">Start vetting</button>
</form>
} else if canStartIfOnline(d.Tile.Latest) {
<button type="button" disabled title="host is not heartbeating — install the reporter via /register/quick.sh on the target host">Start vetting</button>
} else {
<button type="button" disabled>Run in flight</button>
}
if canCancel(d.Tile.Latest) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Tile.Host.ID)) } class="inline" onsubmit="return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');">
<button type="submit" class="danger">Cancel run</button>
</form>
}
if canOverrideWipe(d.Tile.Latest) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Tile.Host.ID)) } class="inline">
<button type="submit" class="danger">Override wipe-probe</button>
</form>
}
if hasReport(d.Tile.Latest) {
<a class="button-like" href={ templ.SafeURL(fmt.Sprintf("/reports/%d", d.Tile.Latest.ID)) } target="_blank" rel="noopener">View report</a>
}
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Tile.Host.ID)) } class="inline">
<button type="submit" class="danger">Delete host</button>
</form>
</div>
</section>
}
// DetailSpecDiffs renders the "Spec diffs (N)" collapsible when a run
// exists; otherwise it emits a bare empty wrapper so a later SSE push
// after SpecValidate writes has a target to swap into. The wrapper is
// keyed on run ID because the diffs belong to a specific run — a new
// run publishes to a new event name, and the detail page navigates to
// the new target via outerHTML swap only when the whole DetailSpecDiffs
// section is re-rendered by a page reload.
templ DetailSpecDiffs(d HostDetailData) {
if d.Tile.Latest != nil {
<section
id={ fmt.Sprintf("detail-specdiffs-%d", d.Tile.Latest.ID) }
class="detail-section detail-diffs"
sse-swap={ fmt.Sprintf("detail-specdiffs-%d", d.Tile.Latest.ID) }
hx-swap="outerHTML"
>
if len(d.SpecDiffs) > 0 {
<details open?={ hasCriticalDiff(d.SpecDiffs) }>
<summary><h2>Spec diffs ({ fmt.Sprintf("%d", len(d.SpecDiffs)) })</h2></summary>
<ul class="diff-list">
for _, diff := range d.SpecDiffs {
<li class={ "diff-row", "diff-" + diff.Severity }>
<div class="diff-field">{ diff.Field }</div>
<div class="diff-expected">expected: <code>{ diff.Expected }</code></div>
<div class="diff-actual">actual: <code>{ diff.Actual }</code></div>
</li>
}
</ul>
</details>
}
</section>
}
}
// DetailHold renders the "Host is holding — SSH available" block while
// a run is in FailedHolding with an IP recorded. Otherwise it emits an
// empty wrapper so the first push when the hold actually fires has a
// target. Keyed on run ID for the same reason as DetailSpecDiffs.
templ DetailHold(d HostDetailData) {
if d.Tile.Latest != nil {
<section
id={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
class="detail-section detail-hold"
sse-swap={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
hx-swap="outerHTML"
>
if d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" {
<h2>Host is holding — SSH available</h2>
<code class="hold-ssh">{ sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP) }</code>
}
</section>
}
}
// RenderDetailSummaryString, RenderDetailActionsString,
// RenderDetailSpecDiffsString, RenderDetailHoldString each render one
// component to a string so the orchestrator can publish SSE fragments
// without importing the HTTP layer. Matches the RenderTileString /
// RenderPipelineString pattern.
func RenderDetailSummaryString(d HostDetailData) string {
var buf bytes.Buffer
_ = DetailSummary(d).Render(context.Background(), &buf)
return buf.String()
}
func RenderDetailActionsString(d HostDetailData) string {
var buf bytes.Buffer
_ = DetailActions(d).Render(context.Background(), &buf)
return buf.String()
}
func RenderDetailSpecDiffsString(d HostDetailData) string {
var buf bytes.Buffer
_ = DetailSpecDiffs(d).Render(context.Background(), &buf)
return buf.String()
}
func RenderDetailHoldString(d HostDetailData) string {
var buf bytes.Buffer
_ = DetailHold(d).Render(context.Background(), &buf)
return buf.String()
}
// hasCriticalDiff opens the spec-diff <details> by default when any
// diff is critical — operator shouldn't have to click to see the blocker.
func hasCriticalDiff(diffs []model.SpecDiff) bool {
File diff suppressed because it is too large Load Diff
+117
View File
@@ -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)
}
}