ui: GitHub-Actions-style detail page, sub-steps, mini-tile run-view
CI / Lint + build + test (push) Successful in 1m26s
Release / release (push) Successful in 6m47s

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>
This commit is contained in:
2026-04-18 19:00:11 -04:00
parent 5c00edd7b6
commit f79fe0f0db
38 changed files with 3972 additions and 936 deletions
+232 -104
View File
@@ -4,34 +4,56 @@ 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 + latest-run enrichment (same
// shape the dashboard tile uses), Stages/SpecDiffs drive the pipeline
// and diff list. 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.
// 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
LogReplay string
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" hx-ext="sse" sse-connect="/events">
<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))
@@ -42,51 +64,41 @@ templ HostDetail(d HostDetailData) {
</section>
}
@DetailHold(d)
@DetailActions(d)
@DetailSpecDiffs(d)
if d.Tile.Latest != nil {
@LogTabs(d.Tile.Latest.ID, d.LogReplay)
}
<section class="detail-section detail-host-meta">
<details>
<summary><h2>Host details</h2></summary>
if d.Tile.Host.Notes != "" {
<div class="detail-notes">
<h3>Notes</h3>
<p>{ d.Tile.Host.Notes }</p>
</div>
<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 class="detail-spec">
<h3>Expected spec</h3>
<pre class="detail-spec-yaml">{ d.Tile.Host.ExpectedSpecYAML }</pre>
</div>
</details>
</section>
</div>
@RunsSidebar(d)
</div>
@DetailSpecDiffs(d)
</section>
}
}
// 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>
// 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>
@@ -96,19 +108,48 @@ templ DetailSummary(d HostDetailData) {
<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 != "" {
<div>
<dt>Failed at</dt>
<dd class="bad">{ d.Tile.Latest.FailedStage }</dd>
</div>
<span class="run-failed-stage">failed at <strong>{ d.Tile.Latest.FailedStage }</strong></span>
}
if d.Tile.SpecDiffCritical > 0 {
<div>
<dt>Spec diffs</dt>
<dd class="bad">{ fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical) }</dd>
</div>
<span class="run-diffs bad">{ fmt.Sprintf("%d critical diff", d.Tile.SpecDiffCritical) }</span>
}
</dl>
</div>
</header>
}
@@ -124,7 +165,6 @@ templ DetailActions(d HostDetailData) {
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">
@@ -132,7 +172,7 @@ templ DetailActions(d HostDetailData) {
<input type="checkbox" name="non_destructive" value="1"/>
Non-destructive (skip wipe-probe + disk writes)
</label>
<button type="submit">Start vetting</button>
<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>
@@ -141,19 +181,19 @@ templ DetailActions(d HostDetailData) {
}
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>
<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="danger">Override wipe-probe</button>
<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">
<button type="submit" class="danger">Delete host</button>
<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>
@@ -192,20 +232,21 @@ templ DetailSpecDiffs(d HostDetailData) {
}
}
// 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.
// 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="detail-section detail-hold"
class="hold-banner"
sse-swap={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
hx-swap="outerHTML"
>
<h2>Host is holding — SSH available</h2>
<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 {
@@ -219,6 +260,32 @@ templ DetailHold(d HostDetailData) {
}
}
// 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
@@ -259,37 +326,98 @@ func hasCriticalDiff(diffs []model.SpecDiff) bool {
return false
}
// LogTabs renders an "All" tab plus one tab per stage in DefaultStageOrder.
// Switching is pure CSS: hidden radio inputs drive sibling-selector
// visibility on the panes. Each pane carries its own sse-swap target so
// live events append only to the relevant pane. The All pane is seeded
// with replay HTML so reload on an in-flight run still shows history.
templ LogTabs(runID int64, replay string) {
<section class="detail-section log-section">
<h2>Log</h2>
<div class="log-tabs">
<input type="radio" name={ fmt.Sprintf("log-tab-%d", runID) } id={ fmt.Sprintf("log-tab-%d-all", runID) } class="log-tab-input log-tab-all" checked/>
<label for={ fmt.Sprintf("log-tab-%d-all", runID) } class="log-tab-label">All</label>
for _, s := range store.DefaultStageOrder {
<input type="radio" name={ fmt.Sprintf("log-tab-%d", runID) } id={ fmt.Sprintf("log-tab-%d-%s", runID, s) } class={ "log-tab-input", "log-tab-" + s }/>
<label for={ fmt.Sprintf("log-tab-%d-%s", runID, s) } class="log-tab-label">{ s }</label>
}
<div
class="log-pane log-pane-all"
id={ fmt.Sprintf("log-%d", runID) }
sse-swap={ fmt.Sprintf("log-%d", runID) }
hx-swap="beforeend show:bottom"
>
@templ.Raw(replay)
</div>
for _, s := range store.DefaultStageOrder {
<div
class={ "log-pane", "log-pane-" + s }
id={ fmt.Sprintf("log-%d-%s", runID, s) }
sse-swap={ fmt.Sprintf("log-%d-%s", runID, s) }
hx-swap="beforeend show:bottom"
></div>
}
</div>
</section>
// 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 "●"
}