f79fe0f0db
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>
424 lines
15 KiB
Plaintext
424 lines
15 KiB
Plaintext
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 "●"
|
||
}
|