017c3c38fe
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>
396 lines
13 KiB
Plaintext
396 lines
13 KiB
Plaintext
package templates
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"time"
|
||
|
||
"vetting/internal/model"
|
||
"vetting/internal/store"
|
||
)
|
||
|
||
// HostPageData is the payload HostPage renders. Host + LastSeenAt drive
|
||
// the summary drawer; Runs is the full newest-first run list for this
|
||
// host; ActiveRun is the non-terminal run (if any) that fills the sticky
|
||
// in-flight banner and highlights one row in the runs table; RunStages
|
||
// maps runID → stage rows so each row can paint its own 9-dot strip
|
||
// without a per-render query ladder in the template.
|
||
type HostPageData struct {
|
||
Host model.Host
|
||
LastSeenAt *time.Time
|
||
Runs []model.Run
|
||
ActiveRun *model.Run
|
||
RunStages map[int64][]model.Stage
|
||
}
|
||
|
||
// HostPage is the host-focused URL: summary + actions + in-flight banner
|
||
// + runs table. Everything run-specific (pipeline, logs, sub-steps, spec
|
||
// diffs, hold banner) lives on /runs/{runID} instead. SSE targets are
|
||
// scoped per region so live tile refreshes don't reflow the whole page.
|
||
templ HostPage(d HostPageData) {
|
||
@Layout(d.Host.Name) {
|
||
<section class="host-page" hx-ext="sse" sse-connect="/events">
|
||
<nav class="breadcrumb">
|
||
<a href="/">Dashboard</a>
|
||
<span class="breadcrumb-sep">/</span>
|
||
<span>{ d.Host.Name }</span>
|
||
</nav>
|
||
|
||
@HostSummary(d)
|
||
@HostActions(d)
|
||
@InFlightBanner(d)
|
||
|
||
if len(d.Runs) == 0 {
|
||
@HostEmptyState(d)
|
||
} else {
|
||
@RunsTable(d)
|
||
}
|
||
</section>
|
||
}
|
||
}
|
||
|
||
// HostSummary is the compact meta card at the top of the host page:
|
||
// hostname, last-seen chip, MAC, WoL target, expected spec (collapsed).
|
||
// SSE-swap target so an operator edit / heartbeat arriving mid-view
|
||
// updates the card without a reload.
|
||
templ HostSummary(d HostPageData) {
|
||
<section
|
||
id={ fmt.Sprintf("detail-summary-%d", d.Host.ID) }
|
||
class="host-summary"
|
||
sse-swap={ fmt.Sprintf("detail-summary-%d", d.Host.ID) }
|
||
hx-swap="outerHTML"
|
||
>
|
||
<div class="host-summary-head">
|
||
<h1 class="host-summary-name">{ d.Host.Name }</h1>
|
||
<span class={ "tile-last-seen", lastSeenClass(d.LastSeenAt) }>{ lastSeenLabel(d.LastSeenAt) }</span>
|
||
</div>
|
||
<dl class="host-summary-meta">
|
||
<div>
|
||
<dt>MAC</dt>
|
||
<dd>{ d.Host.MAC }</dd>
|
||
</div>
|
||
<div>
|
||
<dt>WoL</dt>
|
||
<dd>{ fmt.Sprintf("%s:%d", d.Host.WoLBroadcastIP, d.Host.WoLPort) }</dd>
|
||
</div>
|
||
</dl>
|
||
if d.Host.Notes != "" {
|
||
<div class="host-summary-notes">
|
||
<h3>Notes</h3>
|
||
<p>{ d.Host.Notes }</p>
|
||
</div>
|
||
}
|
||
<details class="host-summary-spec">
|
||
<summary>Expected spec</summary>
|
||
<pre class="host-summary-spec-yaml">{ d.Host.ExpectedSpecYAML }</pre>
|
||
</details>
|
||
</section>
|
||
}
|
||
|
||
// HostActions is the primary-action row: Start vetting (enabled only when
|
||
// no active run AND host is heartbeating), Delete host. Run-level actions
|
||
// (Cancel / Override / View report) live on the run page — the host page
|
||
// only exposes things scoped to the host itself.
|
||
templ HostActions(d HostPageData) {
|
||
<section
|
||
id={ fmt.Sprintf("detail-actions-%d", d.Host.ID) }
|
||
class="host-actions"
|
||
sse-swap={ fmt.Sprintf("detail-actions-%d", d.Host.ID) }
|
||
hx-swap="outerHTML"
|
||
>
|
||
<div class="host-actions-row">
|
||
if hostCanStart(d) {
|
||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)) } class="inline host-start-form">
|
||
<fieldset class="host-profile-picker">
|
||
<legend>Profile</legend>
|
||
<label>
|
||
<input type="radio" name="profile" value="quick" checked/>
|
||
<span class="profile-label">quick</span>
|
||
<span class="profile-desc">~10 min — post-repair sanity check</span>
|
||
</label>
|
||
<label>
|
||
<input type="radio" name="profile" value="deep"/>
|
||
<span class="profile-label">deep</span>
|
||
<span class="profile-desc">~8–12 h — overnight full-disk verify</span>
|
||
</label>
|
||
<label>
|
||
<input type="radio" name="profile" value="soak"/>
|
||
<span class="profile-label">soak</span>
|
||
<span class="profile-desc">≥24 h — burn-in for intermittent faults</span>
|
||
</label>
|
||
</fieldset>
|
||
<label class="host-nd-toggle">
|
||
<input type="checkbox" name="non_destructive" value="1"/>
|
||
<span>Non-destructive</span>
|
||
<span class="nd-hint">Skips the Storage wipe-and-verify stage. All other stages run normally.</span>
|
||
</label>
|
||
<button type="submit" class="btn-primary">Start vetting</button>
|
||
</form>
|
||
} else if hostCanStartIfOnline(d) {
|
||
<div class="offline-hint">
|
||
<button type="button" disabled>Start vetting</button>
|
||
<span>Host is offline — <a href="/hosts/new">run the reporter script</a> on the target host to bring it online.</span>
|
||
</div>
|
||
} else {
|
||
<button type="button" disabled>Run in flight</button>
|
||
}
|
||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Host.ID)) } class="inline" data-confirm="Delete this host and all its runs?">
|
||
<button type="submit" class="btn-danger">Delete host</button>
|
||
</form>
|
||
</div>
|
||
</section>
|
||
}
|
||
|
||
// InFlightBanner is the sticky "Run #N in progress — open →" strip that
|
||
// shows only when an active (non-terminal) run exists. SSE target so a
|
||
// run starting or ending flips the banner live.
|
||
templ InFlightBanner(d HostPageData) {
|
||
<section
|
||
id={ fmt.Sprintf("detail-inflight-%d", d.Host.ID) }
|
||
class="in-flight-banner-wrap"
|
||
sse-swap={ fmt.Sprintf("detail-inflight-%d", d.Host.ID) }
|
||
hx-swap="outerHTML"
|
||
>
|
||
if d.ActiveRun != nil {
|
||
<a class="in-flight-banner" href={ templ.SafeURL(fmt.Sprintf("/runs/%d", d.ActiveRun.ID)) }>
|
||
<span class="in-flight-label">Run #{ fmt.Sprintf("%d", d.ActiveRun.ID) } in progress —</span>
|
||
<span class="in-flight-state">{ tileStatus(d.ActiveRun) }</span>
|
||
<span class="in-flight-open">open →</span>
|
||
</a>
|
||
}
|
||
</section>
|
||
}
|
||
|
||
// HostEmptyState replaces the runs table with a big call-to-action when
|
||
// this host has never had a run. Only renders when the host is both
|
||
// reachable AND has no runs — the standard "Run in flight"-ish disabled
|
||
// button from HostActions handles the other corners.
|
||
templ HostEmptyState(d HostPageData) {
|
||
<section class="host-empty-state">
|
||
<p class="host-empty-title">No runs yet.</p>
|
||
<p class="host-empty-sub">Kick off the first vetting run whenever the host is heartbeating.</p>
|
||
if hostCanStart(d) {
|
||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)) } class="inline">
|
||
<button type="submit" class="btn-primary big">Start vetting</button>
|
||
</form>
|
||
} else {
|
||
<div class="offline-hint">
|
||
<button type="button" class="btn-primary big" disabled>Start vetting</button>
|
||
<span>Host is offline — <a href="/hosts/new">run the reporter script</a> on the target host to bring it online.</span>
|
||
</div>
|
||
}
|
||
</section>
|
||
}
|
||
|
||
// RunsTable is one row per run, newest first. Each row carries its own
|
||
// SSE-swap target so live state changes (a running row flipping to
|
||
// passed) update one <tr> without re-rendering the whole table.
|
||
templ RunsTable(d HostPageData) {
|
||
<section class="host-runs">
|
||
<h2>Runs</h2>
|
||
<table class="runs-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Run</th>
|
||
<th>State</th>
|
||
<th>Started</th>
|
||
<th>Duration</th>
|
||
<th>Stages</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
for _, r := range d.Runs {
|
||
@RunRow(RunRowData{
|
||
Run: r,
|
||
Stages: d.RunStages[r.ID],
|
||
Live: d.ActiveRun != nil && d.ActiveRun.ID == r.ID,
|
||
})
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
}
|
||
|
||
// RunRowData is a single row's payload. Live is true for the currently
|
||
// non-terminal run so CSS can highlight it at the top of the table.
|
||
type RunRowData struct {
|
||
Run model.Run
|
||
Stages []model.Stage
|
||
Live bool
|
||
}
|
||
|
||
// RunRow renders one <tr> keyed by runrow-{runID}. State changes fire
|
||
// runrow-{runID} from the orchestrator so the single row re-renders with
|
||
// its updated state + stage-strip without reloading the host page.
|
||
templ RunRow(d RunRowData) {
|
||
<tr
|
||
id={ fmt.Sprintf("runrow-%d", d.Run.ID) }
|
||
class={ "runs-row", "runs-row-" + tileMood(&d.Run), runRowLiveClass(d.Live) }
|
||
sse-swap={ fmt.Sprintf("runrow-%d", d.Run.ID) }
|
||
hx-swap="outerHTML"
|
||
>
|
||
<td class="runs-col-id">
|
||
<a href={ templ.SafeURL(fmt.Sprintf("/runs/%d", d.Run.ID)) }>{ fmt.Sprintf("#%d", d.Run.ID) }</a>
|
||
</td>
|
||
<td class="runs-col-state">
|
||
<span class={ "run-status-badge", "run-status-" + tileMood(&d.Run) }>{ tileStatus(&d.Run) }</span>
|
||
</td>
|
||
<td class="runs-col-started">{ relativeTime(d.Run.StartedAt) }</td>
|
||
<td class="runs-col-duration">{ runDuration(&d.Run) }</td>
|
||
<td class="runs-col-strip">
|
||
<div class="stage-strip">
|
||
for _, name := range store.DefaultStageOrder {
|
||
{{ st := stageForName(d.Stages, name) }}
|
||
<span class={ "stage-dot", "stage-dot-sm", "stage-dot-" + string(st.State) } title={ name }></span>
|
||
}
|
||
</div>
|
||
</td>
|
||
<td class="runs-col-open">
|
||
<a class="runs-open-link" href={ templ.SafeURL(fmt.Sprintf("/runs/%d", d.Run.ID)) }>open →</a>
|
||
</td>
|
||
</tr>
|
||
}
|
||
|
||
// runRowLiveClass tags the currently non-terminal run so CSS can
|
||
// highlight it. Empty string for every other row.
|
||
func runRowLiveClass(live bool) string {
|
||
if live {
|
||
return "runs-row-live"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// hostCanStart is the host-page analogue of canStart. Guards the Start
|
||
// button on two things: there's no active run, AND the host is currently
|
||
// heartbeating. Mirrors the StartRun handler's preflight so the button
|
||
// never offers a click the server rejects.
|
||
func hostCanStart(d HostPageData) bool {
|
||
if !hostCanStartIfOnline(d) {
|
||
return false
|
||
}
|
||
if d.LastSeenAt == nil {
|
||
return false
|
||
}
|
||
return time.Since(*d.LastSeenAt) <= 60*time.Second
|
||
}
|
||
|
||
// hostCanStartIfOnline is the run-state half of hostCanStart, split out
|
||
// so HostActions can distinguish "run in flight" (no button) from "run
|
||
// is done / no run yet but host is offline" (disabled button).
|
||
func hostCanStartIfOnline(d HostPageData) bool {
|
||
return d.ActiveRun == nil
|
||
}
|
||
|
||
// profileChipValue normalizes a Run.Profile string for display on the
|
||
// run page chip. Older runs with an empty column predate Phase 1 — show
|
||
// them as "quick" (the prior implicit default).
|
||
func profileChipValue(p string) string {
|
||
if p == "" {
|
||
return "quick"
|
||
}
|
||
return p
|
||
}
|
||
|
||
func runDuration(r *model.Run) string {
|
||
if r == nil || r.StartedAt.IsZero() {
|
||
return ""
|
||
}
|
||
end := time.Now()
|
||
if r.CompletedAt != nil {
|
||
end = *r.CompletedAt
|
||
}
|
||
d := end.Sub(r.StartedAt)
|
||
if d < 0 {
|
||
d = 0
|
||
}
|
||
return fmtElapsed(d, true)
|
||
}
|
||
|
||
// stageForName returns the persisted Stage row for a given name, or a
|
||
// synthetic pending-state stub when no row has been seeded yet (e.g.
|
||
// a run still in a pre-stage). Keeps the template free of nil checks —
|
||
// the caller always gets a concrete Stage.
|
||
func stageForName(stages []model.Stage, name string) model.Stage {
|
||
for _, s := range stages {
|
||
if s.Name == name {
|
||
return s
|
||
}
|
||
}
|
||
return model.Stage{Name: name, State: model.StagePending}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
func diffBadgeClass(diffs []model.SpecDiff) string {
|
||
for _, d := range diffs {
|
||
if d.Severity == "critical" && !d.Ignored {
|
||
return "diff-badge-critical"
|
||
}
|
||
}
|
||
return "diff-badge-warn"
|
||
}
|
||
|
||
// relativeTime renders a past time as "2m ago" / "1h ago" / "3d ago".
|
||
// Future times (clock skew) render as "now" so the runs table never
|
||
// shows nonsense when a host's clock is ahead of the orchestrator.
|
||
func relativeTime(t time.Time) string {
|
||
if t.IsZero() {
|
||
return ""
|
||
}
|
||
d := time.Since(t)
|
||
if d < 0 {
|
||
return "now"
|
||
}
|
||
if d < time.Minute {
|
||
return "just now"
|
||
}
|
||
if d < time.Hour {
|
||
return fmt.Sprintf("%dm ago", int(d/time.Minute))
|
||
}
|
||
if d < 24*time.Hour {
|
||
return fmt.Sprintf("%dh ago", int(d/time.Hour))
|
||
}
|
||
return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
|
||
}
|
||
|
||
// RenderHostSummaryString, RenderHostActionsString, and
|
||
// RenderInFlightBannerString render one region to a string for the
|
||
// orchestrator's SSE publish path. Matches the RenderTileString pattern.
|
||
func RenderHostSummaryString(d HostPageData) string {
|
||
var buf bytes.Buffer
|
||
_ = HostSummary(d).Render(context.Background(), &buf)
|
||
return buf.String()
|
||
}
|
||
|
||
func RenderHostActionsString(d HostPageData) string {
|
||
var buf bytes.Buffer
|
||
_ = HostActions(d).Render(context.Background(), &buf)
|
||
return buf.String()
|
||
}
|
||
|
||
func RenderInFlightBannerString(d HostPageData) string {
|
||
var buf bytes.Buffer
|
||
_ = InFlightBanner(d).Render(context.Background(), &buf)
|
||
return buf.String()
|
||
}
|
||
|
||
// RenderRunRowString renders one row for the runs table over SSE when
|
||
// a run's state changes. The orchestrator fires runrow-{runID} at every
|
||
// site that already fires tile-{hostID} + pipeline-{runID}.
|
||
func RenderRunRowString(d RunRowData) string {
|
||
var buf bytes.Buffer
|
||
_ = RunRow(d).Render(context.Background(), &buf)
|
||
return buf.String()
|
||
}
|