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:
@@ -109,6 +109,27 @@ func main() {
|
|||||||
PublicURL: cfg.Server.PublicURL,
|
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{
|
agentAPI := &api.Agent{
|
||||||
Hosts: hostStore,
|
Hosts: hostStore,
|
||||||
Runs: runStore,
|
Runs: runStore,
|
||||||
|
|||||||
@@ -692,14 +692,10 @@ func (a *Agent) Hold(w http.ResponseWriter, r *http.Request) {
|
|||||||
URL: a.runLinkURL(runID),
|
URL: a.runLinkURL(runID),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Refresh the tile so the operator sees the ssh command.
|
// Refresh the tile + all detail-page fragments so the operator
|
||||||
host, _ := a.Hosts.Get(r.Context(), mustHostID(a, r, runID))
|
// sees the ssh command and the hold banner without reloading.
|
||||||
if host != nil {
|
if id := mustHostID(a, r, runID); id != 0 && a.Runner != nil {
|
||||||
latest, _ := a.Runs.Get(r.Context(), runID)
|
a.Runner.PublishTileUpdate(r.Context(), id)
|
||||||
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})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, HoldResponse{AuthorizedKey: kp.AuthorizedKey, RunID: runID})
|
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)
|
log.Printf("reporting: mark completed: %v", err)
|
||||||
}
|
}
|
||||||
a.appendLog(runID, "info", "Reporting: wrote "+path+"; run completed.")
|
a.appendLog(runID, "info", "Reporting: wrote "+path+"; run completed.")
|
||||||
// Publish a final tile update so the dashboard flips to pass mood.
|
// Publish a final tile + detail update so the dashboard flips to
|
||||||
if host != nil && orchestrator.TileRenderer != nil {
|
// pass mood and the detail page's summary/actions update without
|
||||||
latest, _ := a.Runs.Get(ctx, runID)
|
// the operator reloading.
|
||||||
payload := orchestrator.TileRenderer(ctx, *host, latest)
|
if host != nil && a.Runner != nil {
|
||||||
a.EventHub.Publish(events.Event{Name: fmt.Sprintf("tile-%d", host.ID), Payload: payload})
|
a.Runner.PublishTileUpdate(ctx, host.ID)
|
||||||
}
|
}
|
||||||
hostName := "host"
|
hostName := "host"
|
||||||
if host != nil {
|
if host != nil {
|
||||||
|
|||||||
+25
-10
@@ -118,7 +118,7 @@ func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "bad host id", http.StatusBadRequest)
|
http.Error(w, "bad host id", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
host, err := u.Hosts.Get(r.Context(), id)
|
data, err := u.LoadHostDetailData(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
http.NotFound(w, r)
|
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)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
return templates.HostDetailData{}, err
|
||||||
return
|
}
|
||||||
|
latest, err := u.Runs.LatestForHost(ctx, hostID)
|
||||||
|
if err != nil {
|
||||||
|
return templates.HostDetailData{}, err
|
||||||
}
|
}
|
||||||
var stages []model.Stage
|
var stages []model.Stage
|
||||||
var diffs []model.SpecDiff
|
var diffs []model.SpecDiff
|
||||||
if latest != nil {
|
if latest != nil {
|
||||||
if u.Stages != nil {
|
if u.Stages != nil {
|
||||||
stages, _ = u.Stages.ListForRun(r.Context(), latest.ID)
|
stages, _ = u.Stages.ListForRun(ctx, latest.ID)
|
||||||
}
|
}
|
||||||
if u.SpecDiffs != nil {
|
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 := ""
|
replay := ""
|
||||||
if latest != nil && u.Logs != nil {
|
if latest != nil && u.Logs != nil {
|
||||||
replay = u.Logs.Replay(latest.ID)
|
replay = u.Logs.Replay(latest.ID)
|
||||||
}
|
}
|
||||||
data := templates.HostDetailData{
|
return templates.HostDetailData{
|
||||||
Tile: t,
|
Tile: t,
|
||||||
Stages: stages,
|
Stages: stages,
|
||||||
SpecDiffs: diffs,
|
SpecDiffs: diffs,
|
||||||
LogReplay: replay,
|
LogReplay: replay,
|
||||||
}
|
}, nil
|
||||||
_ = templates.HostDetail(data).Render(r.Context(), w)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartRun creates a new Run for the host, issues an agent token, and
|
// StartRun creates a new Run for the host, issues an agent token, and
|
||||||
|
|||||||
@@ -72,6 +72,42 @@ func (r *Runner) PublishTileUpdate(ctx context.Context, hostID int64) {
|
|||||||
r.publishTileUpdate(ctx, hostID)
|
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) {
|
func (r *Runner) publishTileUpdate(ctx context.Context, hostID int64) {
|
||||||
host, err := r.Hosts.Get(ctx, hostID)
|
host, err := r.Hosts.Get(ctx, hostID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -93,11 +129,17 @@ func (r *Runner) publishTileUpdate(ctx context.Context, hostID int64) {
|
|||||||
stages, err := r.Stages.ListForRun(ctx, latest.ID)
|
stages, err := r.Stages.ListForRun(ctx, latest.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("publishTileUpdate: list stages run %d: %v", latest.ID, err)
|
log.Printf("publishTileUpdate: list stages run %d: %v", latest.ID, err)
|
||||||
return
|
} else {
|
||||||
}
|
|
||||||
pipePayload := PipelineRenderer(latest, stages)
|
pipePayload := PipelineRenderer(latest, stages)
|
||||||
r.EventHub.Publish(events.Event{Name: fmt.Sprintf("pipeline-%d", latest.ID), Payload: pipePayload})
|
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
|
// 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.
|
// orchestrator stays free of template imports.
|
||||||
var PipelineRenderer func(run *model.Run, stages []model.Stage) string
|
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 {
|
func renderTileSSE(ctx context.Context, host model.Host, latest *model.Run) string {
|
||||||
if TileRenderer == nil {
|
if TileRenderer == nil {
|
||||||
return fmt.Sprintf(`<article id="host-%d">state change</article>`, host.ID)
|
return fmt.Sprintf(`<article id="host-%d">state change</article>`, host.ID)
|
||||||
|
|||||||
@@ -33,15 +33,30 @@ func setupRunner(t *testing.T) (*orchestrator.Runner, *store.Hosts, *store.Runs,
|
|||||||
// grep the published fragments without parsing HTML.
|
// grep the published fragments without parsing HTML.
|
||||||
prevTile := orchestrator.TileRenderer
|
prevTile := orchestrator.TileRenderer
|
||||||
prevPipe := orchestrator.PipelineRenderer
|
prevPipe := orchestrator.PipelineRenderer
|
||||||
|
prevDetail := orchestrator.HostDetailRenderer
|
||||||
orchestrator.TileRenderer = func(_ context.Context, host model.Host, _ *model.Run) string {
|
orchestrator.TileRenderer = func(_ context.Context, host model.Host, _ *model.Run) string {
|
||||||
return fmt.Sprintf(`<article id="host-%d">tile</article>`, host.ID)
|
return fmt.Sprintf(`<article id="host-%d">tile</article>`, host.ID)
|
||||||
}
|
}
|
||||||
orchestrator.PipelineRenderer = func(run *model.Run, _ []model.Stage) string {
|
orchestrator.PipelineRenderer = func(run *model.Run, _ []model.Stage) string {
|
||||||
return fmt.Sprintf(`<section id="pipeline-%d">pipeline</section>`, run.ID)
|
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() {
|
cleanup := func() {
|
||||||
orchestrator.TileRenderer = prevTile
|
orchestrator.TileRenderer = prevTile
|
||||||
orchestrator.PipelineRenderer = prevPipe
|
orchestrator.PipelineRenderer = prevPipe
|
||||||
|
orchestrator.HostDetailRenderer = prevDetail
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
}
|
}
|
||||||
return runner, hosts, runs, hub, cleanup
|
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
|
// TestCompleteStagePublishesPipeline covers the stage-completion path
|
||||||
// that used to go direct-to-Stages, bypassing the SSE refresh. The
|
// that used to go direct-to-Stages, bypassing the SSE refresh. The
|
||||||
// Runner.CompleteStage wrapper exists so stage-dot advancements show up
|
// Runner.CompleteStage wrapper exists so stage-dot advancements show up
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"vetting/internal/model"
|
"vetting/internal/model"
|
||||||
@@ -29,7 +31,63 @@ templ HostDetail(d HostDetailData) {
|
|||||||
<span>{ d.Tile.Host.Name }</span>
|
<span>{ d.Tile.Host.Name }</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<header class={ "detail-summary", "tile-" + tileMood(d.Tile.Latest) }>
|
@DetailSummary(d)
|
||||||
|
|
||||||
|
if d.Tile.Latest != nil {
|
||||||
|
<section
|
||||||
|
id={ fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID) }
|
||||||
|
class="detail-section"
|
||||||
|
sse-swap={ fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID) }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<h2>Pipeline</h2>
|
||||||
|
@Pipeline(BuildPipeline(d.Tile.Latest, d.Stages))
|
||||||
|
</section>
|
||||||
|
} else {
|
||||||
|
<section class="detail-section">
|
||||||
|
<h2>Pipeline</h2>
|
||||||
|
@Pipeline(BuildPipeline(nil, nil))
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@DetailHold(d)
|
||||||
|
@DetailActions(d)
|
||||||
|
@DetailSpecDiffs(d)
|
||||||
|
|
||||||
|
if d.Tile.Latest != nil {
|
||||||
|
@LogTabs(d.Tile.Latest.ID, d.LogReplay)
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="detail-section detail-host-meta">
|
||||||
|
<details>
|
||||||
|
<summary><h2>Host details</h2></summary>
|
||||||
|
if d.Tile.Host.Notes != "" {
|
||||||
|
<div class="detail-notes">
|
||||||
|
<h3>Notes</h3>
|
||||||
|
<p>{ d.Tile.Host.Notes }</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="detail-spec">
|
||||||
|
<h3>Expected spec</h3>
|
||||||
|
<pre class="detail-spec-yaml">{ d.Tile.Host.ExpectedSpecYAML }</pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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">
|
<div class="detail-summary-head">
|
||||||
<h1 class="detail-name">{ d.Tile.Host.Name }</h1>
|
<h1 class="detail-name">{ d.Tile.Host.Name }</h1>
|
||||||
<div class="detail-status-row">
|
<div class="detail-status-row">
|
||||||
@@ -60,32 +118,20 @@ templ HostDetail(d HostDetailData) {
|
|||||||
}
|
}
|
||||||
</dl>
|
</dl>
|
||||||
</header>
|
</header>
|
||||||
|
}
|
||||||
|
|
||||||
if d.Tile.Latest != nil {
|
// 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
|
<section
|
||||||
id={ fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID) }
|
id={ fmt.Sprintf("detail-actions-%d", d.Tile.Host.ID) }
|
||||||
class="detail-section"
|
class="detail-section detail-actions"
|
||||||
sse-swap={ fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID) }
|
sse-swap={ fmt.Sprintf("detail-actions-%d", d.Tile.Host.ID) }
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
<h2>Pipeline</h2>
|
|
||||||
@Pipeline(BuildPipeline(d.Tile.Latest, d.Stages))
|
|
||||||
</section>
|
|
||||||
} else {
|
|
||||||
<section class="detail-section">
|
|
||||||
<h2>Pipeline</h2>
|
|
||||||
@Pipeline(BuildPipeline(nil, nil))
|
|
||||||
</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>
|
<h2>Actions</h2>
|
||||||
<div class="detail-actions-row">
|
<div class="detail-actions-row">
|
||||||
if canStart(d.Tile) {
|
if canStart(d.Tile) {
|
||||||
@@ -119,9 +165,24 @@ templ HostDetail(d HostDetailData) {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 {
|
if len(d.SpecDiffs) > 0 {
|
||||||
<section class="detail-section detail-diffs">
|
|
||||||
<details open?={ hasCriticalDiff(d.SpecDiffs) }>
|
<details open?={ hasCriticalDiff(d.SpecDiffs) }>
|
||||||
<summary><h2>Spec diffs ({ fmt.Sprintf("%d", len(d.SpecDiffs)) })</h2></summary>
|
<summary><h2>Spec diffs ({ fmt.Sprintf("%d", len(d.SpecDiffs)) })</h2></summary>
|
||||||
<ul class="diff-list">
|
<ul class="diff-list">
|
||||||
@@ -134,30 +195,58 @@ templ HostDetail(d HostDetailData) {
|
|||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
|
}
|
||||||
</section>
|
</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 {
|
if d.Tile.Latest != nil {
|
||||||
@LogTabs(d.Tile.Latest.ID, d.LogReplay)
|
<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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<section class="detail-section detail-host-meta">
|
// RenderDetailSummaryString, RenderDetailActionsString,
|
||||||
<details>
|
// RenderDetailSpecDiffsString, RenderDetailHoldString each render one
|
||||||
<summary><h2>Host details</h2></summary>
|
// component to a string so the orchestrator can publish SSE fragments
|
||||||
if d.Tile.Host.Notes != "" {
|
// without importing the HTTP layer. Matches the RenderTileString /
|
||||||
<div class="detail-notes">
|
// RenderPipelineString pattern.
|
||||||
<h3>Notes</h3>
|
func RenderDetailSummaryString(d HostDetailData) string {
|
||||||
<p>{ d.Tile.Host.Notes }</p>
|
var buf bytes.Buffer
|
||||||
</div>
|
_ = DetailSummary(d).Render(context.Background(), &buf)
|
||||||
}
|
return buf.String()
|
||||||
<div class="detail-spec">
|
}
|
||||||
<h3>Expected spec</h3>
|
|
||||||
<pre class="detail-spec-yaml">{ d.Tile.Host.ExpectedSpecYAML }</pre>
|
func RenderDetailActionsString(d HostDetailData) string {
|
||||||
</div>
|
var buf bytes.Buffer
|
||||||
</details>
|
_ = DetailActions(d).Render(context.Background(), &buf)
|
||||||
</section>
|
return buf.String()
|
||||||
</section>
|
}
|
||||||
}
|
|
||||||
|
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
|
// hasCriticalDiff opens the spec-diff <details> by default when any
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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