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>
This commit is contained in:
@@ -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 "●"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user