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) {
@HostSummary(d) @HostActions(d) @InFlightBanner(d) if len(d.Runs) == 0 { @HostEmptyState(d) } else { @RunsTable(d) }
} } // 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) {

{ d.Host.Name }

{ lastSeenLabel(d.LastSeenAt) }
MAC
{ d.Host.MAC }
WoL
{ fmt.Sprintf("%s:%d", d.Host.WoLBroadcastIP, d.Host.WoLPort) }
if d.Host.Notes != "" {

Notes

{ d.Host.Notes }

}
Expected spec
{ d.Host.ExpectedSpecYAML }
} // 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) {
if hostCanStart(d) {
Profile
} else if hostCanStartIfOnline(d) { } else { }
} // 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) {
if d.ActiveRun != nil { Run #{ fmt.Sprintf("%d", d.ActiveRun.ID) } in progress — { tileStatus(d.ActiveRun) } open → }
} // 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) {

No runs yet.

Kick off the first vetting run whenever the host is heartbeating.

if hostCanStart(d) {
} else { }
} // 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 without re-rendering the whole table. templ RunsTable(d HostPageData) {

Runs

for _, r := range d.Runs { @RunRow(RunRowData{ Run: r, Stages: d.RunStages[r.ID], Live: d.ActiveRun != nil && d.ActiveRun.ID == r.ID, }) }
Run State Started Duration Stages
} // 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 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) { { fmt.Sprintf("#%d", d.Run.ID) } { tileStatus(&d.Run) } { relativeTime(d.Run.StartedAt) } { runDuration(&d.Run) }
for _, name := range store.DefaultStageOrder { {{ st := stageForName(d.Stages, name) }} }
open → } // 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 } // profileChipValue normalizes a Run.Profile string for display on the // run page chip. Older runs with an empty column predate Phase 1 — show // them as "quick" (the prior implicit default). func profileChipValue(p string) string { if p == "" { return "quick" } return p } // 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
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() }