c545028903
Adds a 1s client-side ticker that rewrites .run-duration text from a
data-started-at attribute, so the header timer on /runs/{id}
increments every second while the run is active. When an SSE swap
lands a fresh header the new server-rendered value seamlessly takes
over; when the run goes terminal the template drops the attribute
and the ticker silently skips the node, leaving the final elapsed in
place.
Other templ_*.go churn is cosmetic — regenerator versions differ
between CI and local and only the filename field in templ.Error
callsites changed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
196 lines
7.1 KiB
Plaintext
196 lines
7.1 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) {
|
|
<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()
|
|
}
|