From 0db790ae3ed1bd9ba95d9a6cc0bb20675fa66dfe Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 18 Apr 2026 16:36:13 -0400 Subject: [PATCH] ui: stream host-detail fragments over SSE so the page updates live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
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 --- cmd/vetting/main.go | 21 + internal/api/agent_handlers.go | 22 +- internal/api/ui_handlers.go | 35 +- internal/orchestrator/runner.go | 67 +- internal/orchestrator/runner_test.go | 80 ++ internal/web/templates/host_detail.templ | 267 ++-- internal/web/templates/host_detail_templ.go | 1221 ++++++++++++------- internal/web/templates/host_detail_test.go | 117 ++ 8 files changed, 1250 insertions(+), 580 deletions(-) create mode 100644 internal/web/templates/host_detail_test.go diff --git a/cmd/vetting/main.go b/cmd/vetting/main.go index c51914a..326c4c2 100644 --- a/cmd/vetting/main.go +++ b/cmd/vetting/main.go @@ -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, diff --git a/internal/api/agent_handlers.go b/internal/api/agent_handlers.go index e3a66be..e57969a 100644 --- a/internal/api/agent_handlers.go +++ b/internal/api/agent_handlers.go @@ -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 { diff --git a/internal/api/ui_handlers.go b/internal/api/ui_handlers.go index df43c33..4718ee0 100644 --- a/internal/api/ui_handlers.go +++ b/internal/api/ui_handlers.go @@ -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 diff --git a/internal/orchestrator/runner.go b/internal/orchestrator/runner.go index 7e0613f..5024625 100644 --- a/internal/orchestrator/runner.go +++ b/internal/orchestrator/runner.go @@ -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(`
state change
`, host.ID) diff --git a/internal/orchestrator/runner_test.go b/internal/orchestrator/runner_test.go index 3e28f2b..cd07efc 100644 --- a/internal/orchestrator/runner_test.go +++ b/internal/orchestrator/runner_test.go @@ -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(`
tile
`, host.ID) } orchestrator.PipelineRenderer = func(run *model.Run, _ []model.Stage) string { return fmt.Sprintf(`
pipeline
`, 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(`
summary
`, hostID), + Actions: fmt.Sprintf(`
actions
`, hostID), + SpecDiffs: fmt.Sprintf(`
diffs
`, runID), + Hold: fmt.Sprintf(`
hold
`, 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 diff --git a/internal/web/templates/host_detail.templ b/internal/web/templates/host_detail.templ index c30743b..2398c1f 100644 --- a/internal/web/templates/host_detail.templ +++ b/internal/web/templates/host_detail.templ @@ -1,6 +1,8 @@ package templates import ( + "bytes" + "context" "fmt" "vetting/internal/model" @@ -29,37 +31,7 @@ templ HostDetail(d HostDetailData) { { d.Tile.Host.Name } -
-
-

{ d.Tile.Host.Name }

-
- { lastSeenLabel(d.Tile.LastSeenAt) } - { tileStatus(d.Tile.Latest) } -
-
-
-
-
MAC
-
{ d.Tile.Host.MAC }
-
-
-
WoL
-
{ fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort) }
-
- if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" { -
-
Failed at
-
{ d.Tile.Latest.FailedStage }
-
- } - if d.Tile.SpecDiffCritical > 0 { -
-
Spec diffs
-
{ fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical) }
-
- } -
-
+ @DetailSummary(d) if d.Tile.Latest != nil {
} - if d.Tile.Latest != nil && d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" { -
-

Host is holding — SSH available

- { sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP) } -
- } - -
-

Actions

-
- if canStart(d.Tile) { -
- - -
- } else if canStartIfOnline(d.Tile.Latest) { - - } else { - - } - if canCancel(d.Tile.Latest) { -
- -
- } - if canOverrideWipe(d.Tile.Latest) { -
- -
- } - if hasReport(d.Tile.Latest) { - View report - } -
- -
-
-
- - if len(d.SpecDiffs) > 0 { -
-
-

Spec diffs ({ fmt.Sprintf("%d", len(d.SpecDiffs)) })

-
    - for _, diff := range d.SpecDiffs { -
  • -
    { diff.Field }
    -
    expected: { diff.Expected }
    -
    actual: { diff.Actual }
    -
  • - } -
-
-
- } + @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) { +
+
+

{ d.Tile.Host.Name }

+
+ { lastSeenLabel(d.Tile.LastSeenAt) } + { tileStatus(d.Tile.Latest) } +
+
+
+
+
MAC
+
{ d.Tile.Host.MAC }
+
+
+
WoL
+
{ fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort) }
+
+ if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" { +
+
Failed at
+
{ d.Tile.Latest.FailedStage }
+
+ } + if d.Tile.SpecDiffCritical > 0 { +
+
Spec diffs
+
{ fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical) }
+
+ } +
+
+} + +// 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) { +
+

Actions

+
+ if canStart(d.Tile) { +
+ + +
+ } else if canStartIfOnline(d.Tile.Latest) { + + } else { + + } + if canCancel(d.Tile.Latest) { +
+ +
+ } + if canOverrideWipe(d.Tile.Latest) { +
+ +
+ } + if hasReport(d.Tile.Latest) { + View report + } +
+ +
+
+
+} + +// 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 { +
+ if len(d.SpecDiffs) > 0 { +
+

Spec diffs ({ fmt.Sprintf("%d", len(d.SpecDiffs)) })

+
    + for _, diff := range d.SpecDiffs { +
  • +
    { diff.Field }
    +
    expected: { diff.Expected }
    +
    actual: { diff.Actual }
    +
  • + } +
+
+ } +
+ } +} + +// 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.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" { +

Host is holding — SSH available

+ { sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP) } + } +
+ } +} + +// 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
by default when any // diff is critical — operator shouldn't have to click to see the blocker. func hasCriticalDiff(diffs []model.SpecDiff) bool { diff --git a/internal/web/templates/host_detail_templ.go b/internal/web/templates/host_detail_templ.go index a3dad87..6bcf4ee 100644 --- a/internal/web/templates/host_detail_templ.go +++ b/internal/web/templates/host_detail_templ.go @@ -9,6 +9,8 @@ import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" import ( + "bytes" + "context" "fmt" "vetting/internal/model" @@ -68,7 +70,7 @@ func HostDetail(d HostDetailData) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 29, Col: 28} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 31, Col: 28} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -78,185 +80,38 @@ func HostDetail(d HostDetailData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var4 = []any{"detail-summary", "tile-" + tileMood(d.Tile.Latest)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 34, Col: 47} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var7 = []any{"tile-last-seen", lastSeenClass(d.Tile.LastSeenAt)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(d.Tile.LastSeenAt)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 36, Col: 107} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(d.Tile.Latest)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 37, Col: 59} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
MAC
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.MAC) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 43, Col: 27} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
WoL
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 47, Col: 81} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
Failed at
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Latest.FailedStage) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 52, Col: 50} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if d.Tile.SpecDiffCritical > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
Spec diffs
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 58, Col: 76} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + templ_7745c5c3_Err = DetailSummary(d).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if d.Tile.Latest != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

Pipeline

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" hx-swap=\"outerHTML\">

Pipeline

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -264,12 +119,12 @@ func HostDetail(d HostDetailData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "

Pipeline

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

Pipeline

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -277,276 +132,66 @@ func HostDetail(d HostDetailData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if d.Tile.Latest != nil && d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "

Host is holding — SSH available

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 84, Col: 85} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

Actions

") + templ_7745c5c3_Err = DetailHold(d).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if canStart(d.Tile) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else if canStartIfOnline(d.Tile.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if canCancel(d.Tile.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if canOverrideWipe(d.Tile.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if hasReport(d.Tile.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "View report") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if len(d.SpecDiffs) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "

Spec diffs (") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var23 string - templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs))) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 126, Col: 68} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ")

    ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - for _, diff := range d.SpecDiffs { - var templ_7745c5c3_Var24 = []any{"diff-row", "diff-" + diff.Severity} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var24...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
  • ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var26 string - templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 130, Col: 45} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
    expected: ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var27 string - templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 131, Col: 67} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
    actual: ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var28 string - templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 132, Col: 61} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
  • ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } if d.Tile.Latest != nil { templ_7745c5c3_Err = LogTabs(d.Tile.Latest.ID, d.LogReplay).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "

Host details

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

Host details

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if d.Tile.Host.Notes != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "

Notes

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

Notes

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var29 string - templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Notes) + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Notes) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 150, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 67, Col: 29} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "

Expected spec

")
+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

Expected spec

")
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
-			var templ_7745c5c3_Var30 string
-			templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.ExpectedSpecYAML)
+			var templ_7745c5c3_Var7 string
+			templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.ExpectedSpecYAML)
 			if templ_7745c5c3_Err != nil {
-				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 155, Col: 66}
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 72, Col: 66}
 			}
-			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
-			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -560,6 +205,652 @@ func HostDetail(d HostDetailData) templ.Component { }) } +// 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. +func DetailSummary(d HostDetailData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var8 := templ.GetChildren(ctx) + if templ_7745c5c3_Var8 == nil { + templ_7745c5c3_Var8 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + var templ_7745c5c3_Var9 = []any{"detail-summary", "tile-" + tileMood(d.Tile.Latest)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 92, Col: 45} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 = []any{"tile-last-seen", lastSeenClass(d.Tile.LastSeenAt)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(d.Tile.LastSeenAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 94, Col: 105} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(d.Tile.Latest)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 95, Col: 57} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
MAC
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.MAC) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 101, Col: 25} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
WoL
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 105, Col: 79} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
Failed at
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Latest.FailedStage) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 110, Col: 48} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if d.Tile.SpecDiffCritical > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
Spec diffs
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 116, Col: 74} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return 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. +func DetailActions(d HostDetailData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var22 := templ.GetChildren(ctx) + if templ_7745c5c3_Var22 == nil { + templ_7745c5c3_Var22 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "

Actions

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if canStart(d.Tile) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if canStartIfOnline(d.Tile.Latest) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if canCancel(d.Tile.Latest) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if canOverrideWipe(d.Tile.Latest) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if hasReport(d.Tile.Latest) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "View report") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// 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. +func DetailSpecDiffs(d HostDetailData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var30 := templ.GetChildren(ctx) + if templ_7745c5c3_Var30 == nil { + templ_7745c5c3_Var30 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if d.Tile.Latest != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(d.SpecDiffs) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "

Spec diffs (") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var33 string + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 187, Col: 67} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ")

    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, diff := range d.SpecDiffs { + var templ_7745c5c3_Var34 = []any{"diff-row", "diff-" + diff.Severity} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var34...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var36 string + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 191, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
    expected: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var37 string + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 192, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
    actual: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var38 string + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 193, Col: 60} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +// 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. +func DetailHold(d HostDetailData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var39 := templ.GetChildren(ctx) + if templ_7745c5c3_Var39 == nil { + templ_7745c5c3_Var39 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if d.Tile.Latest != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "

Host is holding — SSH available

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var42 string + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 217, Col: 84} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +// 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
by default when any // diff is critical — operator shouldn't have to click to see the blocker. func hasCriticalDiff(diffs []model.SpecDiff) bool { @@ -592,157 +883,157 @@ func LogTabs(runID int64, replay string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var31 := templ.GetChildren(ctx) - if templ_7745c5c3_Var31 == nil { - templ_7745c5c3_Var31 = templ.NopComponent + templ_7745c5c3_Var43 := templ.GetChildren(ctx) + if templ_7745c5c3_Var43 == nil { + templ_7745c5c3_Var43 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "

Log

Log

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, s := range store.DefaultStageOrder { - var templ_7745c5c3_Var35 = []any{"log-tab-input", "log-tab-" + s} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var35...) + var templ_7745c5c3_Var47 = []any{"log-tab-input", "log-tab-" + s} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var47...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\" hx-swap=\"beforeend show:bottom\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -750,61 +1041,61 @@ func LogTabs(runID int64, replay string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, s := range store.DefaultStageOrder { - var templ_7745c5c3_Var43 = []any{"log-pane", "log-pane-" + s} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var43...) + var templ_7745c5c3_Var55 = []any{"log-pane", "log-pane-" + s} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var55...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" hx-swap=\"beforeend show:bottom\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/internal/web/templates/host_detail_test.go b/internal/web/templates/host_detail_test.go new file mode 100644 index 0000000..f1691fb --- /dev/null +++ b/internal/web/templates/host_detail_test.go @@ -0,0 +1,117 @@ +package templates + +import ( + "strings" + "testing" + + "vetting/internal/model" +) + +// TestDetailSummary_RootAttrs asserts the root
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
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, ":\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) + } +}