Files
Vetting/internal/web/templates/run_detail.templ
T
josh 19608bef1b
CI / Lint + build + test (push) Successful in 1m35s
Release / release (push) Successful in 23m47s
ui: split /hosts/{id} into host page + /runs/{runID} run page
Host page owns host metadata, full runs table with per-row stage strip,
in-flight banner, and empty-state CTA. Run page owns pipeline, active
step, logs, sub-steps, spec diffs, and hold banner with a breadcrumb
back to the host. Dashboard tile reverts to host-only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 20:37:57 -04:00

189 lines
6.8 KiB
Plaintext

package templates
import (
"bytes"
"context"
"fmt"
"vetting/internal/model"
"vetting/internal/store"
)
// RunPageData is the full payload for /runs/{runID}. Host is resolved
// from Run.HostID so the breadcrumb + run actions (which post to
// /hosts/{hostID}/...) have the host context without a separate call.
// Stages/SubSteps/SpecDiffs drive the pipeline + active-step panels +
// diff list. DefaultStepStage is the stage name whose <details> opens
// on first render — running → failed → Reporting. HoldKeyPath is the
// on-disk path of the hold_key artifact, needed to print the ssh
// invocation in the hold banner. SpecDiffCritical is the count of
// unignored critical diffs shown in the header.
type RunPageData struct {
Host model.Host
Run model.Run
Stages []model.Stage
SubSteps []model.SubStep
SpecDiffs []model.SpecDiff
DefaultStepStage string
LogReplayByStage map[string]string
HoldKeyPath string
SpecDiffCritical int
}
// RunPage is the run-focused URL: pipeline + per-stage active-step panels
// + spec diffs + hold banner. Host metadata stays on /hosts/{id}; this
// page carries only run-scoped content so the operator can read one run
// without surrounding noise.
templ RunPage(d RunPageData) {
@Layout(fmt.Sprintf("%s — run #%d", d.Host.Name, d.Run.ID)) {
<section class="run-page" hx-ext="sse" sse-connect="/events">
<nav class="breadcrumb">
<a href="/">Dashboard</a>
<span class="breadcrumb-sep">/</span>
<a href={ templ.SafeURL(fmt.Sprintf("/hosts/%d", d.Host.ID)) }>{ d.Host.Name }</a>
<span class="breadcrumb-sep">/</span>
<span>{ fmt.Sprintf("run #%d", d.Run.ID) }</span>
</nav>
@RunHeader(d)
@HoldBanner(d)
@PipelineSection(&d.Run, BuildPipeline(&d.Run, d.Stages))
<div class="run-body">
<div class="active-step-pane">
for _, stageName := range store.DefaultStageOrder {
@ActiveStep(ActiveStepData{
RunID: d.Run.ID,
Stage: stageForName(d.Stages, stageName),
SubSteps: SubStepsForStage(d.SubSteps, stageName),
LogReplay: d.LogReplayByStage[stageName],
Open: stageName == d.DefaultStepStage,
})
}
</div>
</div>
@RunSpecDiffs(d)
</section>
}
}
// RunHeader is the run-page header: run id, state badge, elapsed, and
// the primary action on the right (Cancel during a non-terminal run;
// Start-new-run + View-report after). Keyed on run ID so SSE updates
// don't collide with a newer run's header. Rendered as a section rather
// than a bare header so it composes with the breadcrumb strip above.
templ RunHeader(d RunPageData) {
<header
id={ fmt.Sprintf("run-header-%d", d.Run.ID) }
class={ "run-header", "tile-" + tileMood(&d.Run) }
sse-swap={ fmt.Sprintf("run-header-%d", d.Run.ID) }
hx-swap="outerHTML"
>
<div class="run-header-left">
<h1 class="run-header-name">{ fmt.Sprintf("Run #%d", d.Run.ID) }</h1>
<span class={ "run-status-badge", "run-status-" + tileMood(&d.Run) }>{ tileStatus(&d.Run) }</span>
<span class="run-duration">{ runDuration(&d.Run) }</span>
if d.Run.FailedStage != "" {
<span class="run-failed-stage">failed at <strong>{ d.Run.FailedStage }</strong></span>
}
if d.SpecDiffCritical > 0 {
<span class="run-diffs bad">{ fmt.Sprintf("%d critical diff", d.SpecDiffCritical) }</span>
}
</div>
<div class="run-header-right">
if canCancel(&d.Run) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.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.Run) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Host.ID)) } class="inline">
<button type="submit" class="btn-danger">Override wipe-probe</button>
</form>
}
if hasReport(&d.Run) {
<a class="button-like" href={ templ.SafeURL(fmt.Sprintf("/reports/%d", d.Run.ID)) } target="_blank" rel="noopener">View report</a>
}
if d.Run.State.IsTerminal() {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)) } class="inline">
<button type="submit" class="btn-primary">Start new run</button>
</form>
}
</div>
</header>
}
// HoldBanner is the "Host is holding — SSH available" strip when a run
// is FailedHolding with an IP recorded. Emits an empty placeholder
// otherwise so the first SSE push when a hold actually fires has a
// target to swap into.
templ HoldBanner(d RunPageData) {
if d.Run.State == model.StateFailedHolding && d.Run.HoldIP != "" {
<section
id={ fmt.Sprintf("detail-hold-%d", d.Run.ID) }
class="hold-banner"
sse-swap={ fmt.Sprintf("detail-hold-%d", d.Run.ID) }
hx-swap="outerHTML"
>
<span class="hold-banner-label">Host is holding — SSH available:</span>
<code class="hold-ssh">{ sshInvocation(d.HoldKeyPath, d.Run.HoldIP) }</code>
</section>
} else {
<section
id={ fmt.Sprintf("detail-hold-%d", d.Run.ID) }
class="detail-hold-placeholder"
sse-swap={ fmt.Sprintf("detail-hold-%d", d.Run.ID) }
hx-swap="outerHTML"
></section>
}
}
// RunSpecDiffs renders the "Spec diffs (N)" collapsible. The wrapper is
// always emitted (even when empty) so SpecValidate-time SSE pushes have
// a target; the <details> body only renders when diffs exist.
templ RunSpecDiffs(d RunPageData) {
<section
id={ fmt.Sprintf("detail-specdiffs-%d", d.Run.ID) }
class="detail-section detail-diffs"
sse-swap={ fmt.Sprintf("detail-specdiffs-%d", d.Run.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>
}
// RenderRunHeaderString, RenderHoldBannerString, and
// RenderRunSpecDiffsString render each region to a string for the
// orchestrator's SSE publish path. Matches the RenderTileString pattern.
func RenderRunHeaderString(d RunPageData) string {
var buf bytes.Buffer
_ = RunHeader(d).Render(context.Background(), &buf)
return buf.String()
}
func RenderHoldBannerString(d RunPageData) string {
var buf bytes.Buffer
_ = HoldBanner(d).Render(context.Background(), &buf)
return buf.String()
}
func RenderRunSpecDiffsString(d RunPageData) string {
var buf bytes.Buffer
_ = RunSpecDiffs(d).Render(context.Background(), &buf)
return buf.String()
}