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>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user