package templates import ( "bytes" "context" "fmt" "time" "vetting/internal/model" "vetting/internal/store" ) // HostDetailData is the full payload the detail handler hands to the // HostDetail template. Tile carries host + viewed-run enrichment (same // shape the dashboard tile uses), Stages/SpecDiffs/SubSteps drive the // pipeline, diff list, and expanded step panel. History backs the runs // sidebar (last 20, newest first). DefaultStepStage is the stage name // whose
opens by default on page load — running → failed → // Reporting. LogReplay is the pre-rendered history fragment produced // by logs.Hub.Replay on the initial page render so the operator sees // prior output without waiting for a fresh SSE event. type HostDetailData struct { Tile TileData Stages []model.Stage SpecDiffs []model.SpecDiff SubSteps []model.SubStep History []model.Run DefaultStepStage string LogReplay string // LogReplayByStage is the pre-rendered log HTML grouped by stage // name. Each ActiveStep panel picks its own bucket so the detail // page doesn't fire nine disk scans per reload. The "" key holds // orphan/framing lines (no stage set), surfaced under the "Run" // pseudo-step at the top of the page. LogReplayByStage map[string]string } // HostDetail is the GitHub-Actions-style run view. Layout is: meta // drawer (collapsed) → run header + actions → hold banner → horizontal // pipeline → two-column body (active-step pane + runs sidebar) → spec // diffs at the bottom. Each section keeps its own sse-swap target so // live updates don't trigger whole-page reflows. templ HostDetail(d HostDetailData) { @Layout(d.Tile.Host.Name) {
@HostMetaDrawer(d) @DetailSummary(d) @DetailActions(d) @DetailHold(d) if d.Tile.Latest != nil { @PipelineSection(d.Tile.Latest, BuildPipeline(d.Tile.Latest, d.Stages)) } else {

Pipeline

@Pipeline(BuildPipeline(nil, nil))
}
if d.Tile.Latest != nil { for _, stageName := range store.DefaultStageOrder { @ActiveStep(ActiveStepData{ RunID: d.Tile.Latest.ID, Stage: stageForName(d.Stages, stageName), SubSteps: SubStepsForStage(d.SubSteps, stageName), LogReplay: d.LogReplayByStage[stageName], Open: stageName == d.DefaultStepStage, }) } } else {

No run yet. Click Start vetting to begin.

}
@RunsSidebar(d)
@DetailSpecDiffs(d)
} } // HostMetaDrawer is the collapsed "host details" block at the top of the // page: MAC, WoL, last-seen, expected spec, and notes.
defaults // to closed so the run itself stays above the fold; operators open it // when they need the provisioning info. templ HostMetaDrawer(d HostDetailData) {
Host details { lastSeenLabel(d.Tile.LastSeenAt) } { d.Tile.Host.MAC }
MAC
{ d.Tile.Host.MAC }
WoL
{ fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort) }
if d.Tile.Host.Notes != "" {

Notes

{ d.Tile.Host.Notes }

}

Expected spec

{ d.Tile.Host.ExpectedSpecYAML }
} // DetailSummary is the run header: host name on the left, run number, // status icon, and elapsed/total duration. Keyed on host ID so the SSE // event name is stable across run turnover. templ DetailSummary(d HostDetailData) {

{ d.Tile.Host.Name }

if d.Tile.Latest != nil { { fmt.Sprintf("run #%d", d.Tile.Latest.ID) } } { tileStatus(d.Tile.Latest) } if d.Tile.Latest != nil { { runDuration(d.Tile.Latest) } }
if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" { failed at { d.Tile.Latest.FailedStage } } if d.Tile.SpecDiffCritical > 0 { { fmt.Sprintf("%d critical diff", 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) {
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" strip across // the top when a run is in FailedHolding with an IP recorded. Otherwise // it emits an empty wrapper so the first SSE 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) }
} else {
} } } // RunsSidebar is the right-rail history list: last 20 runs for this // host, newest first. Each entry links back to /hosts/{id}?run=N for // navigation into a past run. The row for the currently-viewed run is // flagged so CSS can highlight it. templ RunsSidebar(d HostDetailData) { } // 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 { for _, d := range diffs { if d.Severity == "critical" && !d.Ignored { return true } } return false } // 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. // the run is still in a pre-stage). Keeps the template free of nil // checks and ghost logic — ActiveStep 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} } // runSidebarActiveClass marks the row for the currently-viewed run so // CSS can highlight it. Empty string (no class added) when the row isn't // the active one. func runSidebarActiveClass(viewed *model.Run, rowID int64) string { if viewed != nil && viewed.ID == rowID { return "runs-sidebar-active" } return "" } // runDuration formats the elapsed time for a run using the same buckets // as stageDuration. In-flight runs clock from StartedAt to now so the // header duration keeps updating on each SSE tick. 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)) } } // relativeTime renders a past time as "2m ago" / "1h ago" / "3d ago" // for the runs-sidebar. Future times (clock skew on the host) render as // "now" so the sidebar never shows nonsense. 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))) } // runSidebarGlyph mirrors stageMarker for run-state: ✓ / ! / ● / –. // Used inside the sidebar dot so the color + glyph carry redundant // meaning. func runSidebarGlyph(r *model.Run) string { if r == nil { return "" } switch r.State { case model.StateCompleted: return "✓" case model.StateFailed, model.StateFailedHolding: return "!" case model.StateReleased, model.StateCancelled: return "–" } if r.State.IsTerminal() { return "" } return "●" }