Files
Vetting/internal/web/templates/host_detail.templ
T
josh f79fe0f0db
CI / Lint + build + test (push) Successful in 1m26s
Release / release (push) Successful in 6m47s
ui: GitHub-Actions-style detail page, sub-steps, mini-tile run-view
Reshapes the detail page into a run-view: hybrid horizontal pipeline
+ expanded active-step pane with sub-steps, a per-step log pane with
line-numbered permalinks and client-side search, and a runs-history
sidebar that navigates via ?run=N. Default step is server-picked
(running → failed → Reporting) so the operator lands on the thing
that's moving.

Adds a sub_steps table + SSE topic (substep-{run}-{stage}-{ordinal})
so per-disk and per-pass work (SMART, CPUStress CPU/RAM, Storage,
GPU) is visible in the UI instead of buried in stage summary JSON.
Agent emits sub-step reports from existing per-iteration loops.

Dashboard tiles become a mini run-view with a 9-dot step strip so
the operator reads run health across the whole grid at a glance.
Register page gets the same card shell + button styling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 19:00:11 -04:00

424 lines
15 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <details> 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) {
<section class="detail detail-v2" hx-ext="sse" sse-connect="/events">
<nav class="breadcrumb">
<a href="/">Dashboard</a>
<span class="breadcrumb-sep">/</span>
<span>{ d.Tile.Host.Name }</span>
</nav>
@HostMetaDrawer(d)
@DetailSummary(d)
@DetailActions(d)
@DetailHold(d)
if d.Tile.Latest != nil {
@PipelineSection(d.Tile.Latest, BuildPipeline(d.Tile.Latest, d.Stages))
} else {
<section class="detail-section">
<h2>Pipeline</h2>
@Pipeline(BuildPipeline(nil, nil))
</section>
}
<div class="detail-body">
<div class="active-step-pane">
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 {
<p class="detail-empty">No run yet. Click <strong>Start vetting</strong> to begin.</p>
}
</div>
@RunsSidebar(d)
</div>
@DetailSpecDiffs(d)
</section>
}
}
// HostMetaDrawer is the collapsed "host details" block at the top of the
// page: MAC, WoL, last-seen, expected spec, and notes. <details> defaults
// to closed so the run itself stays above the fold; operators open it
// when they need the provisioning info.
templ HostMetaDrawer(d HostDetailData) {
<details class="host-meta-drawer">
<summary>
<span class="meta-summary-label">Host details</span>
<span class={ "tile-last-seen", lastSeenClass(d.Tile.LastSeenAt) }>{ lastSeenLabel(d.Tile.LastSeenAt) }</span>
<span class="meta-summary-mac">{ d.Tile.Host.MAC }</span>
</summary>
<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>
</dl>
if d.Tile.Host.Notes != "" {
<div class="detail-notes">
<h3>Notes</h3>
<p>{ d.Tile.Host.Notes }</p>
</div>
}
<div class="detail-spec">
<h3>Expected spec</h3>
<pre class="detail-spec-yaml">{ d.Tile.Host.ExpectedSpecYAML }</pre>
</div>
</details>
}
// 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) {
<header
id={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
class={ "run-header", "tile-" + tileMood(d.Tile.Latest) }
sse-swap={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
hx-swap="outerHTML"
>
<div class="run-header-left">
<h1 class="detail-name">{ d.Tile.Host.Name }</h1>
if d.Tile.Latest != nil {
<span class="run-number">{ fmt.Sprintf("run #%d", d.Tile.Latest.ID) }</span>
}
<span class={ "run-status-badge", "run-status-" + tileMood(d.Tile.Latest) }>{ tileStatus(d.Tile.Latest) }</span>
if d.Tile.Latest != nil {
<span class="run-duration">{ runDuration(d.Tile.Latest) }</span>
}
</div>
<div class="run-header-right">
if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" {
<span class="run-failed-stage">failed at <strong>{ d.Tile.Latest.FailedStage }</strong></span>
}
if d.Tile.SpecDiffCritical > 0 {
<span class="run-diffs bad">{ fmt.Sprintf("%d critical diff", d.Tile.SpecDiffCritical) }</span>
}
</div>
</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"
>
<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" class="btn-primary">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="btn-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="btn-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" onsubmit="return confirm('Delete host and all its runs?');">
<button type="submit" class="btn-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" 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 != "" {
<section
id={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
class="hold-banner"
sse-swap={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
hx-swap="outerHTML"
>
<span class="hold-banner-label">Host is holding — SSH available:</span>
<code class="hold-ssh">{ sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP) }</code>
</section>
} else {
<section
id={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
class="detail-hold-placeholder"
sse-swap={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
hx-swap="outerHTML"
></section>
}
}
}
// 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) {
<aside class="runs-sidebar">
<h2 class="runs-sidebar-heading">History</h2>
if len(d.History) == 0 {
<p class="runs-sidebar-empty">No runs yet.</p>
} else {
<ul class="runs-sidebar-list">
for _, r := range d.History {
<li class={ "runs-sidebar-item", "runs-sidebar-" + tileMood(&r), runSidebarActiveClass(d.Tile.Latest, r.ID) }>
<a href={ templ.SafeURL(fmt.Sprintf("/hosts/%d?run=%d", d.Tile.Host.ID, r.ID)) }>
<span class={ "runs-sidebar-dot", "runs-sidebar-dot-" + tileMood(&r) }>{ runSidebarGlyph(&r) }</span>
<span class="runs-sidebar-id">{ fmt.Sprintf("#%d", r.ID) }</span>
<span class="runs-sidebar-started">{ relativeTime(r.StartedAt) }</span>
<span class="runs-sidebar-duration">{ runDuration(&r) }</span>
</a>
</li>
}
</ul>
}
</aside>
}
// 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 {
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 "●"
}