Files
Vetting/internal/web/templates/run_detail.templ
T
josh 017c3c38fe
CI / Lint + build + test (push) Successful in 1m43s
Release / detect (push) Successful in 6s
Release / build-live-image (push) Has been skipped
Release / bundle (push) Successful in 52s
feat(ui): 15-point UX overhaul — affordances, feedback, and navigation
Address friction points identified in a full interface audit:
- Re-add status badge to dashboard tiles so run state is visible at a glance
- Add active nav indicator and SSE connection health monitor (live/stale)
- Show manual registration form by default instead of hiding behind <details>
- Add copy-to-clipboard buttons on SSH hold command and quick-register one-liner
- Replace tooltip-only profile descriptions with inline visible text
- Clarify non-destructive toggle with explicit stage impact description
- Replace disabled "Start vetting" button with actionable offline guidance
- Swap browser confirm() dialogs for styled inline confirmations
- Add colored badge to spec diffs summary visible when collapsed
- Add distinct "cancelled" mood for cancelled runs (vs idle)
- Add match count to log search and aria-label for accessibility
- Add styled 404 page rendered inside the app shell

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 20:08:07 -04:00

208 lines
7.6 KiB
Plaintext

package templates
import (
"bytes"
"context"
"fmt"
"time"
"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-profile-chip", "run-profile-" + profileChipValue(d.Run.Profile) }>{ profileChipValue(d.Run.Profile) }</span>
<span
class="run-duration"
if !d.Run.State.IsTerminal() && !d.Run.StartedAt.IsZero() {
data-started-at={ d.Run.StartedAt.UTC().Format(time.RFC3339Nano) }
}
>{ 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) {
if d.Run.State == model.StateFailedHolding {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Host.ID)) } class="inline" data-confirm="Cancel held run? The host will reboot to local disk.">
<button type="submit" class="btn-danger">Cancel &amp; reboot</button>
</form>
} else {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Host.ID)) } class="inline" data-confirm="Cancel run? Destructive stages may leave the host mid-operation.">
<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>
<div class="copyable-wrap">
<code class="hold-ssh">{ sshInvocation(d.HoldKeyPath, d.Run.HoldIP) }</code>
<button type="button" class="copy-btn" data-copy-target="previousSibling">Copy</button>
</div>
</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</h2>
<span class={ "diff-badge", diffBadgeClass(d.SpecDiffs) }>{ fmt.Sprintf("%d", len(d.SpecDiffs)) }</span>
</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()
}