ui: split /hosts/{id} into host page + /runs/{runID} run page
CI / Lint + build + test (push) Successful in 1m35s
Release / release (push) Successful in 23m47s

Host page owns host metadata, full runs table with per-row stage strip,
in-flight banner, and empty-state CTA. Run page owns pipeline, active
step, logs, sub-steps, spec diffs, and hold banner with a breadcrumb
back to the host. Dashboard tile reverts to host-only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 20:37:57 -04:00
parent 5c6bfa5ffa
commit 19608bef1b
23 changed files with 3173 additions and 2827 deletions
+365
View File
@@ -0,0 +1,365 @@
package templates
import (
"bytes"
"context"
"fmt"
"time"
"vetting/internal/model"
"vetting/internal/store"
)
// HostPageData is the payload HostPage renders. Host + LastSeenAt drive
// the summary drawer; Runs is the full newest-first run list for this
// host; ActiveRun is the non-terminal run (if any) that fills the sticky
// in-flight banner and highlights one row in the runs table; RunStages
// maps runID → stage rows so each row can paint its own 9-dot strip
// without a per-render query ladder in the template.
type HostPageData struct {
Host model.Host
LastSeenAt *time.Time
Runs []model.Run
ActiveRun *model.Run
RunStages map[int64][]model.Stage
}
// HostPage is the host-focused URL: summary + actions + in-flight banner
// + runs table. Everything run-specific (pipeline, logs, sub-steps, spec
// diffs, hold banner) lives on /runs/{runID} instead. SSE targets are
// scoped per region so live tile refreshes don't reflow the whole page.
templ HostPage(d HostPageData) {
@Layout(d.Host.Name) {
<section class="host-page" hx-ext="sse" sse-connect="/events">
<nav class="breadcrumb">
<a href="/">Dashboard</a>
<span class="breadcrumb-sep">/</span>
<span>{ d.Host.Name }</span>
</nav>
@HostSummary(d)
@HostActions(d)
@InFlightBanner(d)
if len(d.Runs) == 0 {
@HostEmptyState(d)
} else {
@RunsTable(d)
}
</section>
}
}
// HostSummary is the compact meta card at the top of the host page:
// hostname, last-seen chip, MAC, WoL target, expected spec (collapsed).
// SSE-swap target so an operator edit / heartbeat arriving mid-view
// updates the card without a reload.
templ HostSummary(d HostPageData) {
<section
id={ fmt.Sprintf("detail-summary-%d", d.Host.ID) }
class="host-summary"
sse-swap={ fmt.Sprintf("detail-summary-%d", d.Host.ID) }
hx-swap="outerHTML"
>
<div class="host-summary-head">
<h1 class="host-summary-name">{ d.Host.Name }</h1>
<span class={ "tile-last-seen", lastSeenClass(d.LastSeenAt) }>{ lastSeenLabel(d.LastSeenAt) }</span>
</div>
<dl class="host-summary-meta">
<div>
<dt>MAC</dt>
<dd>{ d.Host.MAC }</dd>
</div>
<div>
<dt>WoL</dt>
<dd>{ fmt.Sprintf("%s:%d", d.Host.WoLBroadcastIP, d.Host.WoLPort) }</dd>
</div>
</dl>
if d.Host.Notes != "" {
<div class="host-summary-notes">
<h3>Notes</h3>
<p>{ d.Host.Notes }</p>
</div>
}
<details class="host-summary-spec">
<summary>Expected spec</summary>
<pre class="host-summary-spec-yaml">{ d.Host.ExpectedSpecYAML }</pre>
</details>
</section>
}
// HostActions is the primary-action row: Start vetting (enabled only when
// no active run AND host is heartbeating), Delete host. Run-level actions
// (Cancel / Override / View report) live on the run page — the host page
// only exposes things scoped to the host itself.
templ HostActions(d HostPageData) {
<section
id={ fmt.Sprintf("detail-actions-%d", d.Host.ID) }
class="host-actions"
sse-swap={ fmt.Sprintf("detail-actions-%d", d.Host.ID) }
hx-swap="outerHTML"
>
<div class="host-actions-row">
if hostCanStart(d) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)) } class="inline host-start-form">
<label class="host-nd-toggle">
<input type="checkbox" name="non_destructive" value="1"/>
Non-destructive (skip wipe-probe + disk writes)
</label>
<button type="submit" class="btn-primary">Start vetting</button>
</form>
} else if hostCanStartIfOnline(d) {
<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>
}
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Host.ID)) } class="inline" onsubmit="return confirm('Delete host and all its runs?');">
<button type="submit" class="btn-danger">Delete host</button>
</form>
</div>
</section>
}
// InFlightBanner is the sticky "Run #N in progress — open →" strip that
// shows only when an active (non-terminal) run exists. SSE target so a
// run starting or ending flips the banner live.
templ InFlightBanner(d HostPageData) {
<section
id={ fmt.Sprintf("detail-inflight-%d", d.Host.ID) }
class="in-flight-banner-wrap"
sse-swap={ fmt.Sprintf("detail-inflight-%d", d.Host.ID) }
hx-swap="outerHTML"
>
if d.ActiveRun != nil {
<a class="in-flight-banner" href={ templ.SafeURL(fmt.Sprintf("/runs/%d", d.ActiveRun.ID)) }>
<span class="in-flight-label">Run #{ fmt.Sprintf("%d", d.ActiveRun.ID) } in progress —</span>
<span class="in-flight-state">{ tileStatus(d.ActiveRun) }</span>
<span class="in-flight-open">open →</span>
</a>
}
</section>
}
// HostEmptyState replaces the runs table with a big call-to-action when
// this host has never had a run. Only renders when the host is both
// reachable AND has no runs — the standard "Run in flight"-ish disabled
// button from HostActions handles the other corners.
templ HostEmptyState(d HostPageData) {
<section class="host-empty-state">
<p class="host-empty-title">No runs yet.</p>
<p class="host-empty-sub">Kick off the first vetting run whenever the host is heartbeating.</p>
if hostCanStart(d) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)) } class="inline">
<button type="submit" class="btn-primary big">Start vetting</button>
</form>
} else {
<button type="button" class="btn-primary big" disabled title="host is not heartbeating — install the reporter via /register/quick.sh on the target host">Start vetting</button>
}
</section>
}
// RunsTable is one row per run, newest first. Each row carries its own
// SSE-swap target so live state changes (a running row flipping to
// passed) update one <tr> without re-rendering the whole table.
templ RunsTable(d HostPageData) {
<section class="host-runs">
<h2>Runs</h2>
<table class="runs-table">
<thead>
<tr>
<th>Run</th>
<th>State</th>
<th>Started</th>
<th>Duration</th>
<th>Stages</th>
<th></th>
</tr>
</thead>
<tbody>
for _, r := range d.Runs {
@RunRow(RunRowData{
Run: r,
Stages: d.RunStages[r.ID],
Live: d.ActiveRun != nil && d.ActiveRun.ID == r.ID,
})
}
</tbody>
</table>
</section>
}
// RunRowData is a single row's payload. Live is true for the currently
// non-terminal run so CSS can highlight it at the top of the table.
type RunRowData struct {
Run model.Run
Stages []model.Stage
Live bool
}
// RunRow renders one <tr> keyed by runrow-{runID}. State changes fire
// runrow-{runID} from the orchestrator so the single row re-renders with
// its updated state + stage-strip without reloading the host page.
templ RunRow(d RunRowData) {
<tr
id={ fmt.Sprintf("runrow-%d", d.Run.ID) }
class={ "runs-row", "runs-row-" + tileMood(&d.Run), runRowLiveClass(d.Live) }
sse-swap={ fmt.Sprintf("runrow-%d", d.Run.ID) }
hx-swap="outerHTML"
>
<td class="runs-col-id">
<a href={ templ.SafeURL(fmt.Sprintf("/runs/%d", d.Run.ID)) }>{ fmt.Sprintf("#%d", d.Run.ID) }</a>
</td>
<td class="runs-col-state">
<span class={ "run-status-badge", "run-status-" + tileMood(&d.Run) }>{ tileStatus(&d.Run) }</span>
</td>
<td class="runs-col-started">{ relativeTime(d.Run.StartedAt) }</td>
<td class="runs-col-duration">{ runDuration(&d.Run) }</td>
<td class="runs-col-strip">
<div class="stage-strip">
for _, name := range store.DefaultStageOrder {
{{ st := stageForName(d.Stages, name) }}
<span class={ "stage-dot", "stage-dot-sm", "stage-dot-" + string(st.State) } title={ name }></span>
}
</div>
</td>
<td class="runs-col-open">
<a class="runs-open-link" href={ templ.SafeURL(fmt.Sprintf("/runs/%d", d.Run.ID)) }>open →</a>
</td>
</tr>
}
// runRowLiveClass tags the currently non-terminal run so CSS can
// highlight it. Empty string for every other row.
func runRowLiveClass(live bool) string {
if live {
return "runs-row-live"
}
return ""
}
// hostCanStart is the host-page analogue of canStart. Guards the Start
// button on two things: there's no active run, AND the host is currently
// heartbeating. Mirrors the StartRun handler's preflight so the button
// never offers a click the server rejects.
func hostCanStart(d HostPageData) bool {
if !hostCanStartIfOnline(d) {
return false
}
if d.LastSeenAt == nil {
return false
}
return time.Since(*d.LastSeenAt) <= 60*time.Second
}
// hostCanStartIfOnline is the run-state half of hostCanStart, split out
// so HostActions can distinguish "run in flight" (no button) from "run
// is done / no run yet but host is offline" (disabled button).
func hostCanStartIfOnline(d HostPageData) bool {
return d.ActiveRun == nil
}
// runDuration formats the elapsed time for a run using the same buckets
// as stageDuration. In-flight runs clock from StartedAt to now so the
// run-page header + runs-table row keep ticking on each SSE push.
func runDuration(r *model.Run) string {
if r == nil || r.StartedAt.IsZero() {
return ""
}
end := time.Now()
if r.CompletedAt != nil {
end = *r.CompletedAt
}
d := end.Sub(r.StartedAt)
if d < 0 {
d = 0
}
switch {
case d < time.Second:
return fmt.Sprintf("%dms", int(d/time.Millisecond))
case d < 10*time.Second:
return fmt.Sprintf("%.1fs", d.Seconds())
case d < time.Minute:
return fmt.Sprintf("%ds", int(d/time.Second))
case d < time.Hour:
return fmt.Sprintf("%dm %ds", int(d/time.Minute), int((d%time.Minute)/time.Second))
default:
return fmt.Sprintf("%dh %dm", int(d/time.Hour), int((d%time.Hour)/time.Minute))
}
}
// stageForName returns the persisted Stage row for a given name, or a
// synthetic pending-state stub when no row has been seeded yet (e.g.
// a run still in a pre-stage). Keeps the template free of nil checks —
// the caller always gets a concrete Stage.
func stageForName(stages []model.Stage, name string) model.Stage {
for _, s := range stages {
if s.Name == name {
return s
}
}
return model.Stage{Name: name, State: model.StagePending}
}
// 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 {
for _, d := range diffs {
if d.Severity == "critical" && !d.Ignored {
return true
}
}
return false
}
// relativeTime renders a past time as "2m ago" / "1h ago" / "3d ago".
// Future times (clock skew) render as "now" so the runs table never
// shows nonsense when a host's clock is ahead of the orchestrator.
func relativeTime(t time.Time) string {
if t.IsZero() {
return ""
}
d := time.Since(t)
if d < 0 {
return "now"
}
if d < time.Minute {
return "just now"
}
if d < time.Hour {
return fmt.Sprintf("%dm ago", int(d/time.Minute))
}
if d < 24*time.Hour {
return fmt.Sprintf("%dh ago", int(d/time.Hour))
}
return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
}
// RenderHostSummaryString, RenderHostActionsString, and
// RenderInFlightBannerString render one region to a string for the
// orchestrator's SSE publish path. Matches the RenderTileString pattern.
func RenderHostSummaryString(d HostPageData) string {
var buf bytes.Buffer
_ = HostSummary(d).Render(context.Background(), &buf)
return buf.String()
}
func RenderHostActionsString(d HostPageData) string {
var buf bytes.Buffer
_ = HostActions(d).Render(context.Background(), &buf)
return buf.String()
}
func RenderInFlightBannerString(d HostPageData) string {
var buf bytes.Buffer
_ = InFlightBanner(d).Render(context.Background(), &buf)
return buf.String()
}
// RenderRunRowString renders one row for the runs table over SSE when
// a run's state changes. The orchestrator fires runrow-{runID} at every
// site that already fires tile-{hostID} + pipeline-{runID}.
func RenderRunRowString(d RunRowData) string {
var buf bytes.Buffer
_ = RunRow(d).Render(context.Background(), &buf)
return buf.String()
}