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:
@@ -1,6 +1,8 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"vetting/internal/model"
|
||||
@@ -29,37 +31,7 @@ templ HostDetail(d HostDetailData) {
|
||||
<span>{ d.Tile.Host.Name }</span>
|
||||
</nav>
|
||||
|
||||
<header class={ "detail-summary", "tile-" + tileMood(d.Tile.Latest) }>
|
||||
<div class="detail-summary-head">
|
||||
<h1 class="detail-name">{ d.Tile.Host.Name }</h1>
|
||||
<div class="detail-status-row">
|
||||
<span class={ "tile-last-seen", lastSeenClass(d.Tile.LastSeenAt) }>{ lastSeenLabel(d.Tile.LastSeenAt) }</span>
|
||||
<span class="tile-status">{ tileStatus(d.Tile.Latest) }</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="detail-meta">
|
||||
<div>
|
||||
<dt>MAC</dt>
|
||||
<dd>{ d.Tile.Host.MAC }</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>WoL</dt>
|
||||
<dd>{ fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort) }</dd>
|
||||
</div>
|
||||
if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" {
|
||||
<div>
|
||||
<dt>Failed at</dt>
|
||||
<dd class="bad">{ d.Tile.Latest.FailedStage }</dd>
|
||||
</div>
|
||||
}
|
||||
if d.Tile.SpecDiffCritical > 0 {
|
||||
<div>
|
||||
<dt>Spec diffs</dt>
|
||||
<dd class="bad">{ fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical) }</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</header>
|
||||
@DetailSummary(d)
|
||||
|
||||
if d.Tile.Latest != nil {
|
||||
<section
|
||||
@@ -78,64 +50,9 @@ templ HostDetail(d HostDetailData) {
|
||||
</section>
|
||||
}
|
||||
|
||||
if d.Tile.Latest != nil && d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" {
|
||||
<section class="detail-section detail-hold">
|
||||
<h2>Host is holding — SSH available</h2>
|
||||
<code class="hold-ssh">{ sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP) }</code>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="detail-section detail-actions">
|
||||
<h2>Actions</h2>
|
||||
<div class="detail-actions-row">
|
||||
if canStart(d.Tile) {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Tile.Host.ID)) } class="inline detail-start-form">
|
||||
<label class="detail-nd-toggle">
|
||||
<input type="checkbox" name="non_destructive" value="1"/>
|
||||
Non-destructive (skip wipe-probe + disk writes)
|
||||
</label>
|
||||
<button type="submit">Start vetting</button>
|
||||
</form>
|
||||
} else if canStartIfOnline(d.Tile.Latest) {
|
||||
<button type="button" disabled title="host is not heartbeating — install the reporter via /register/quick.sh on the target host">Start vetting</button>
|
||||
} else {
|
||||
<button type="button" disabled>Run in flight</button>
|
||||
}
|
||||
if canCancel(d.Tile.Latest) {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Tile.Host.ID)) } class="inline" onsubmit="return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');">
|
||||
<button type="submit" class="danger">Cancel run</button>
|
||||
</form>
|
||||
}
|
||||
if canOverrideWipe(d.Tile.Latest) {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Tile.Host.ID)) } class="inline">
|
||||
<button type="submit" class="danger">Override wipe-probe</button>
|
||||
</form>
|
||||
}
|
||||
if hasReport(d.Tile.Latest) {
|
||||
<a class="button-like" href={ templ.SafeURL(fmt.Sprintf("/reports/%d", d.Tile.Latest.ID)) } target="_blank" rel="noopener">View report</a>
|
||||
}
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Tile.Host.ID)) } class="inline">
|
||||
<button type="submit" class="danger">Delete host</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
if len(d.SpecDiffs) > 0 {
|
||||
<section class="detail-section detail-diffs">
|
||||
<details open?={ hasCriticalDiff(d.SpecDiffs) }>
|
||||
<summary><h2>Spec diffs ({ fmt.Sprintf("%d", len(d.SpecDiffs)) })</h2></summary>
|
||||
<ul class="diff-list">
|
||||
for _, diff := range d.SpecDiffs {
|
||||
<li class={ "diff-row", "diff-" + diff.Severity }>
|
||||
<div class="diff-field">{ diff.Field }</div>
|
||||
<div class="diff-expected">expected: <code>{ diff.Expected }</code></div>
|
||||
<div class="diff-actual">actual: <code>{ diff.Actual }</code></div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
}
|
||||
@DetailHold(d)
|
||||
@DetailActions(d)
|
||||
@DetailSpecDiffs(d)
|
||||
|
||||
if d.Tile.Latest != nil {
|
||||
@LogTabs(d.Tile.Latest.ID, d.LogReplay)
|
||||
@@ -160,6 +77,178 @@ templ HostDetail(d HostDetailData) {
|
||||
}
|
||||
}
|
||||
|
||||
// DetailSummary is the status header at the top of the detail page:
|
||||
// name, last-seen badge, run status, MAC/WoL/failed-stage/spec-diffs
|
||||
// meta grid. Keyed on host ID so the SSE event name is stable across
|
||||
// run turnover.
|
||||
templ DetailSummary(d HostDetailData) {
|
||||
<header
|
||||
id={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
|
||||
class={ "detail-summary", "tile-" + tileMood(d.Tile.Latest) }
|
||||
sse-swap={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<div class="detail-summary-head">
|
||||
<h1 class="detail-name">{ d.Tile.Host.Name }</h1>
|
||||
<div class="detail-status-row">
|
||||
<span class={ "tile-last-seen", lastSeenClass(d.Tile.LastSeenAt) }>{ lastSeenLabel(d.Tile.LastSeenAt) }</span>
|
||||
<span class="tile-status">{ tileStatus(d.Tile.Latest) }</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="detail-meta">
|
||||
<div>
|
||||
<dt>MAC</dt>
|
||||
<dd>{ d.Tile.Host.MAC }</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>WoL</dt>
|
||||
<dd>{ fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort) }</dd>
|
||||
</div>
|
||||
if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" {
|
||||
<div>
|
||||
<dt>Failed at</dt>
|
||||
<dd class="bad">{ d.Tile.Latest.FailedStage }</dd>
|
||||
</div>
|
||||
}
|
||||
if d.Tile.SpecDiffCritical > 0 {
|
||||
<div>
|
||||
<dt>Spec diffs</dt>
|
||||
<dd class="bad">{ fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical) }</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</header>
|
||||
}
|
||||
|
||||
// DetailActions is the button row (Start / Cancel / Override / View
|
||||
// report / Delete). Enabled/disabled state depends on the latest run's
|
||||
// state and host heartbeat; both change live, so this section re-renders
|
||||
// on every state change. Keyed on host ID — the actions exist even
|
||||
// without a run.
|
||||
templ DetailActions(d HostDetailData) {
|
||||
<section
|
||||
id={ fmt.Sprintf("detail-actions-%d", d.Tile.Host.ID) }
|
||||
class="detail-section detail-actions"
|
||||
sse-swap={ fmt.Sprintf("detail-actions-%d", d.Tile.Host.ID) }
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<h2>Actions</h2>
|
||||
<div class="detail-actions-row">
|
||||
if canStart(d.Tile) {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Tile.Host.ID)) } class="inline detail-start-form">
|
||||
<label class="detail-nd-toggle">
|
||||
<input type="checkbox" name="non_destructive" value="1"/>
|
||||
Non-destructive (skip wipe-probe + disk writes)
|
||||
</label>
|
||||
<button type="submit">Start vetting</button>
|
||||
</form>
|
||||
} else if canStartIfOnline(d.Tile.Latest) {
|
||||
<button type="button" disabled title="host is not heartbeating — install the reporter via /register/quick.sh on the target host">Start vetting</button>
|
||||
} else {
|
||||
<button type="button" disabled>Run in flight</button>
|
||||
}
|
||||
if canCancel(d.Tile.Latest) {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Tile.Host.ID)) } class="inline" onsubmit="return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');">
|
||||
<button type="submit" class="danger">Cancel run</button>
|
||||
</form>
|
||||
}
|
||||
if canOverrideWipe(d.Tile.Latest) {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Tile.Host.ID)) } class="inline">
|
||||
<button type="submit" class="danger">Override wipe-probe</button>
|
||||
</form>
|
||||
}
|
||||
if hasReport(d.Tile.Latest) {
|
||||
<a class="button-like" href={ templ.SafeURL(fmt.Sprintf("/reports/%d", d.Tile.Latest.ID)) } target="_blank" rel="noopener">View report</a>
|
||||
}
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Tile.Host.ID)) } class="inline">
|
||||
<button type="submit" class="danger">Delete host</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
// DetailSpecDiffs renders the "Spec diffs (N)" collapsible when a run
|
||||
// exists; otherwise it emits a bare empty wrapper so a later SSE push
|
||||
// after SpecValidate writes has a target to swap into. The wrapper is
|
||||
// keyed on run ID because the diffs belong to a specific run — a new
|
||||
// run publishes to a new event name, and the detail page navigates to
|
||||
// the new target via outerHTML swap only when the whole DetailSpecDiffs
|
||||
// section is re-rendered by a page reload.
|
||||
templ DetailSpecDiffs(d HostDetailData) {
|
||||
if d.Tile.Latest != nil {
|
||||
<section
|
||||
id={ fmt.Sprintf("detail-specdiffs-%d", d.Tile.Latest.ID) }
|
||||
class="detail-section detail-diffs"
|
||||
sse-swap={ fmt.Sprintf("detail-specdiffs-%d", d.Tile.Latest.ID) }
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
if len(d.SpecDiffs) > 0 {
|
||||
<details open?={ hasCriticalDiff(d.SpecDiffs) }>
|
||||
<summary><h2>Spec diffs ({ fmt.Sprintf("%d", len(d.SpecDiffs)) })</h2></summary>
|
||||
<ul class="diff-list">
|
||||
for _, diff := range d.SpecDiffs {
|
||||
<li class={ "diff-row", "diff-" + diff.Severity }>
|
||||
<div class="diff-field">{ diff.Field }</div>
|
||||
<div class="diff-expected">expected: <code>{ diff.Expected }</code></div>
|
||||
<div class="diff-actual">actual: <code>{ diff.Actual }</code></div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</details>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
// DetailHold renders the "Host is holding — SSH available" block while
|
||||
// a run is in FailedHolding with an IP recorded. Otherwise it emits an
|
||||
// empty wrapper so the first push when the hold actually fires has a
|
||||
// target. Keyed on run ID for the same reason as DetailSpecDiffs.
|
||||
templ DetailHold(d HostDetailData) {
|
||||
if d.Tile.Latest != nil {
|
||||
<section
|
||||
id={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
|
||||
class="detail-section detail-hold"
|
||||
sse-swap={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
if d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" {
|
||||
<h2>Host is holding — SSH available</h2>
|
||||
<code class="hold-ssh">{ sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP) }</code>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
// RenderDetailSummaryString, RenderDetailActionsString,
|
||||
// RenderDetailSpecDiffsString, RenderDetailHoldString each render one
|
||||
// component to a string so the orchestrator can publish SSE fragments
|
||||
// without importing the HTTP layer. Matches the RenderTileString /
|
||||
// RenderPipelineString pattern.
|
||||
func RenderDetailSummaryString(d HostDetailData) string {
|
||||
var buf bytes.Buffer
|
||||
_ = DetailSummary(d).Render(context.Background(), &buf)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func RenderDetailActionsString(d HostDetailData) string {
|
||||
var buf bytes.Buffer
|
||||
_ = DetailActions(d).Render(context.Background(), &buf)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func RenderDetailSpecDiffsString(d HostDetailData) string {
|
||||
var buf bytes.Buffer
|
||||
_ = DetailSpecDiffs(d).Render(context.Background(), &buf)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func RenderDetailHoldString(d HostDetailData) string {
|
||||
var buf bytes.Buffer
|
||||
_ = DetailHold(d).Render(context.Background(), &buf)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// hasCriticalDiff opens the spec-diff <details> by default when any
|
||||
// diff is critical — operator shouldn't have to click to see the blocker.
|
||||
func hasCriticalDiff(diffs []model.SpecDiff) bool {
|
||||
|
||||
Reference in New Issue
Block a user