4524ab8dc0
Non-destructive pre-declares "don't touch the disks" on Start: the Storage stage skips wipe-probe, badblocks -w, and write-mode fio, and reports a read-only summary. Runs a new non_destructive column; threaded through Claim → agent tests.Deps → Storage stage. Cancel halts an in-flight run. The orchestrator transitions to a new StateCancelled via TriggerOperatorCancelled (valid from any active state); the agent's next heartbeat returns cmd=cancel_stage, which fires a stored CancelFunc on the per-stage context. Stage subprocesses spawned with exec.CommandContext die with the context, the agent posts a cancelled outcome, then powers the host off. Destructive stages mid-run may leave the host in an intermediate state — the UI confirm dialog warns the operator; recovery is manual for now. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
208 lines
7.4 KiB
Plaintext
208 lines
7.4 KiB
Plaintext
package templates
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"vetting/internal/model"
|
|
"vetting/internal/store"
|
|
)
|
|
|
|
// HostDetailData is the full payload the detail handler hands to the
|
|
// HostDetail template. Tile carries host + latest-run enrichment (same
|
|
// shape the dashboard tile uses), Stages/SpecDiffs drive the pipeline
|
|
// and diff list. 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
|
|
LogReplay string
|
|
}
|
|
|
|
templ HostDetail(d HostDetailData) {
|
|
@Layout(d.Tile.Host.Name) {
|
|
<section class="detail" 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>
|
|
|
|
<header class={ "detail-summary", "tile-" + tileMood(d.Tile.Latest) }>
|
|
<div class="detail-summary-head">
|
|
<h1 class="detail-name">{ d.Tile.Host.Name }</h1>
|
|
<div class="detail-status-row">
|
|
<span class={ "tile-last-seen", lastSeenClass(d.Tile.LastSeenAt) }>{ lastSeenLabel(d.Tile.LastSeenAt) }</span>
|
|
<span class="tile-status">{ tileStatus(d.Tile.Latest) }</span>
|
|
</div>
|
|
</div>
|
|
<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>
|
|
if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" {
|
|
<div>
|
|
<dt>Failed at</dt>
|
|
<dd class="bad">{ d.Tile.Latest.FailedStage }</dd>
|
|
</div>
|
|
}
|
|
if d.Tile.SpecDiffCritical > 0 {
|
|
<div>
|
|
<dt>Spec diffs</dt>
|
|
<dd class="bad">{ fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical) }</dd>
|
|
</div>
|
|
}
|
|
</dl>
|
|
</header>
|
|
|
|
if d.Tile.Latest != nil {
|
|
<section
|
|
id={ fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID) }
|
|
class="detail-section"
|
|
sse-swap={ fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID) }
|
|
hx-swap="outerHTML"
|
|
>
|
|
<h2>Pipeline</h2>
|
|
@Pipeline(BuildPipeline(d.Tile.Latest, d.Stages))
|
|
</section>
|
|
} else {
|
|
<section class="detail-section">
|
|
<h2>Pipeline</h2>
|
|
@Pipeline(BuildPipeline(nil, nil))
|
|
</section>
|
|
}
|
|
|
|
if d.Tile.Latest != nil && d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" {
|
|
<section class="detail-section detail-hold">
|
|
<h2>Host is holding — SSH available</h2>
|
|
<code class="hold-ssh">{ sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP) }</code>
|
|
</section>
|
|
}
|
|
|
|
<section class="detail-section detail-actions">
|
|
<h2>Actions</h2>
|
|
<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">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="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="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">
|
|
<button type="submit" class="danger">Delete host</button>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
|
|
if len(d.SpecDiffs) > 0 {
|
|
<section class="detail-section detail-diffs">
|
|
<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>
|
|
}
|
|
|
|
if d.Tile.Latest != nil {
|
|
@LogTabs(d.Tile.Latest.ID, d.LogReplay)
|
|
}
|
|
|
|
<section class="detail-section detail-host-meta">
|
|
<details>
|
|
<summary><h2>Host details</h2></summary>
|
|
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>
|
|
</section>
|
|
</section>
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// LogTabs renders an "All" tab plus one tab per stage in DefaultStageOrder.
|
|
// Switching is pure CSS: hidden radio inputs drive sibling-selector
|
|
// visibility on the panes. Each pane carries its own sse-swap target so
|
|
// live events append only to the relevant pane. The All pane is seeded
|
|
// with replay HTML so reload on an in-flight run still shows history.
|
|
templ LogTabs(runID int64, replay string) {
|
|
<section class="detail-section log-section">
|
|
<h2>Log</h2>
|
|
<div class="log-tabs">
|
|
<input type="radio" name={ fmt.Sprintf("log-tab-%d", runID) } id={ fmt.Sprintf("log-tab-%d-all", runID) } class="log-tab-input log-tab-all" checked/>
|
|
<label for={ fmt.Sprintf("log-tab-%d-all", runID) } class="log-tab-label">All</label>
|
|
for _, s := range store.DefaultStageOrder {
|
|
<input type="radio" name={ fmt.Sprintf("log-tab-%d", runID) } id={ fmt.Sprintf("log-tab-%d-%s", runID, s) } class={ "log-tab-input", "log-tab-" + s }/>
|
|
<label for={ fmt.Sprintf("log-tab-%d-%s", runID, s) } class="log-tab-label">{ s }</label>
|
|
}
|
|
<div
|
|
class="log-pane log-pane-all"
|
|
id={ fmt.Sprintf("log-%d", runID) }
|
|
sse-swap={ fmt.Sprintf("log-%d", runID) }
|
|
hx-swap="beforeend show:bottom"
|
|
>
|
|
@templ.Raw(replay)
|
|
</div>
|
|
for _, s := range store.DefaultStageOrder {
|
|
<div
|
|
class={ "log-pane", "log-pane-" + s }
|
|
id={ fmt.Sprintf("log-%d-%s", runID, s) }
|
|
sse-swap={ fmt.Sprintf("log-%d-%s", runID, s) }
|
|
hx-swap="beforeend show:bottom"
|
|
></div>
|
|
}
|
|
</div>
|
|
</section>
|
|
}
|