f79fe0f0db
Reshapes the detail page into a run-view: hybrid horizontal pipeline
+ expanded active-step pane with sub-steps, a per-step log pane with
line-numbered permalinks and client-side search, and a runs-history
sidebar that navigates via ?run=N. Default step is server-picked
(running → failed → Reporting) so the operator lands on the thing
that's moving.
Adds a sub_steps table + SSE topic (substep-{run}-{stage}-{ordinal})
so per-disk and per-pass work (SMART, CPUStress CPU/RAM, Storage,
GPU) is visible in the UI instead of buried in stage summary JSON.
Agent emits sub-step reports from existing per-iteration loops.
Dashboard tiles become a mini run-view with a 9-dot step strip so
the operator reads run health across the whole grid at a glance.
Register page gets the same card shell + button styling.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
209 lines
6.8 KiB
Plaintext
209 lines
6.8 KiB
Plaintext
package templates
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"time"
|
||
|
||
"vetting/internal/model"
|
||
"vetting/internal/store"
|
||
)
|
||
|
||
// HostTile renders a single dashboard card as a mini run-view. The whole
|
||
// tile is a link to /hosts/{id} (via a CSS-overlay <a>) — every control
|
||
// beyond the one primary action lives on the detail page. It's the SSE-
|
||
// swap target for per-host tile refreshes (`tile-N`). The step list is
|
||
// a compact vertical strip of the 9 canonical stages with just a
|
||
// coloured dot per stage; operators can read run health at a glance
|
||
// across the whole dashboard without drilling in.
|
||
templ HostTile(t TileData) {
|
||
<article
|
||
id={ fmt.Sprintf("host-%d", t.Host.ID) }
|
||
class={ "tile", "tile-" + tileMood(t.Latest) }
|
||
sse-swap={ fmt.Sprintf("tile-%d", t.Host.ID) }
|
||
hx-swap="outerHTML"
|
||
>
|
||
<a class="tile-link" href={ templ.SafeURL(fmt.Sprintf("/hosts/%d", t.Host.ID)) } aria-label={ "Open " + t.Host.Name }></a>
|
||
<header class="tile-head">
|
||
<div class="tile-name">{ t.Host.Name }</div>
|
||
<div class="tile-header-right">
|
||
<span class={ "tile-last-seen", lastSeenClass(t.LastSeenAt) }>{ lastSeenLabel(t.LastSeenAt) }</span>
|
||
<div class="tile-status">{ tileStatus(t.Latest) }</div>
|
||
</div>
|
||
</header>
|
||
if t.Latest != nil {
|
||
<div class="tile-meta-row">
|
||
<span class="tile-run-id">{ fmt.Sprintf("#%d", t.Latest.ID) }</span>
|
||
<span class="tile-run-duration">{ runDuration(t.Latest) }</span>
|
||
</div>
|
||
}
|
||
<ol class="tile-steplist">
|
||
for _, name := range store.DefaultStageOrder {
|
||
@tileStep(stageForName(t.Stages, name))
|
||
}
|
||
</ol>
|
||
<div class="tile-primary-action">
|
||
if canStart(t) {
|
||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)) } class="inline tile-start-form">
|
||
<label class="tile-nd-toggle">
|
||
<input type="checkbox" name="non_destructive" value="1"/>
|
||
Non-destructive
|
||
</label>
|
||
<button type="submit">Start vetting</button>
|
||
</form>
|
||
} else if canStartIfOnline(t.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 if canCancel(t.Latest) {
|
||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", t.Host.ID)) } class="inline tile-cancel-form" 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>
|
||
} else if hasReport(t.Latest) {
|
||
<a class="button-like" href={ templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)) } target="_blank" rel="noopener">View report</a>
|
||
}
|
||
</div>
|
||
</article>
|
||
}
|
||
|
||
// tileStep renders one entry of the tile's mini step-list: a small
|
||
// coloured dot plus the short stage name. Kept as its own templ so the
|
||
// markup stays consistent with the detail page's larger stage-dot
|
||
// elements (same class prefix, different size via the `-sm` modifier).
|
||
templ tileStep(s model.Stage) {
|
||
<li class={ "tile-step", "tile-step-" + string(s.State) }>
|
||
<span class={ "stage-dot", "stage-dot-sm", "stage-dot-" + string(s.State) }>{ stageMarker(string(s.State)) }</span>
|
||
<span class="tile-step-name">{ s.Name }</span>
|
||
</li>
|
||
}
|
||
|
||
func canOverrideWipe(r *model.Run) bool {
|
||
if r == nil {
|
||
return false
|
||
}
|
||
return r.State == model.StateFailedHolding && r.FailedStage == "Storage"
|
||
}
|
||
|
||
// hasReport is true once the reporting stage has produced an HTML
|
||
// artifact. We cheat slightly: Completed runs always have one, and
|
||
// that's the only state in which the tile wants to surface a link.
|
||
func hasReport(r *model.Run) bool {
|
||
return r != nil && r.State == model.StateCompleted
|
||
}
|
||
|
||
// canStart gates the Start button on two things: the run is in a state
|
||
// that accepts a fresh start, AND the host is currently heartbeating.
|
||
// The heartbeat check mirrors the StartRun handler's preflight so the
|
||
// button never offers a click that the server would reject with 409.
|
||
func canStart(t TileData) bool {
|
||
if !canStartIfOnline(t.Latest) {
|
||
return false
|
||
}
|
||
if t.LastSeenAt == nil {
|
||
return false
|
||
}
|
||
return time.Since(*t.LastSeenAt) <= 60*time.Second
|
||
}
|
||
|
||
// canStartIfOnline is the run-state half of canStart, split out so the
|
||
// template can distinguish "waiting on run to end" (no button) from
|
||
// "run is done but host is offline" (disabled button with tooltip).
|
||
func canStartIfOnline(r *model.Run) bool {
|
||
if r == nil {
|
||
return true
|
||
}
|
||
return r.State.IsTerminal()
|
||
}
|
||
|
||
// canCancel is true for any non-terminal run — the Cancel button shows
|
||
// whenever the pipeline is live (Queued through the stage states). The
|
||
// handler refuses the action once the run enters a terminal state, so
|
||
// the render decision just has to mirror that.
|
||
func canCancel(r *model.Run) bool {
|
||
return r != nil && !r.State.IsTerminal()
|
||
}
|
||
|
||
func tileStatus(r *model.Run) string {
|
||
if r == nil {
|
||
return "Idle"
|
||
}
|
||
switch r.State {
|
||
case model.StateWaitingReboot:
|
||
return "Waiting for reboot"
|
||
}
|
||
return string(r.State)
|
||
}
|
||
|
||
func tileMood(r *model.Run) string {
|
||
if r == nil {
|
||
return "idle"
|
||
}
|
||
switch r.State {
|
||
case model.StateCompleted:
|
||
return "pass"
|
||
case model.StateFailed, model.StateFailedHolding:
|
||
return "fail"
|
||
case model.StateReleased, model.StateCancelled:
|
||
return "idle"
|
||
}
|
||
return "active"
|
||
}
|
||
|
||
func sshInvocation(keyPath, ip string) string {
|
||
if keyPath == "" {
|
||
return "ssh root@" + ip + " (hold key not yet recorded)"
|
||
}
|
||
return fmt.Sprintf("ssh -i %s root@%s", keyPath, ip)
|
||
}
|
||
|
||
// RenderTileString renders a single tile fragment so the orchestrator
|
||
// can publish it over SSE without threading a context through every
|
||
// event publisher.
|
||
func RenderTileString(t TileData) string {
|
||
var buf bytes.Buffer
|
||
_ = HostTile(t).Render(context.Background(), &buf)
|
||
return buf.String()
|
||
}
|
||
|
||
// lastSeenLabel renders the host-mode agent's liveness into a short
|
||
// badge: "never" if the host has never heartbeated, "online" within
|
||
// a 2×heartbeat grace window (60s, since agents heartbeat every 30s),
|
||
// "Nm ago" / "Nh ago" / "Nd ago" otherwise.
|
||
func lastSeenLabel(t *time.Time) string {
|
||
if t == nil {
|
||
return "never"
|
||
}
|
||
return humanAgoFrom(time.Now(), *t)
|
||
}
|
||
|
||
// lastSeenClass pairs with lastSeenLabel to drive the badge color
|
||
// without the template having to carry its own logic.
|
||
func lastSeenClass(t *time.Time) string {
|
||
if t == nil {
|
||
return "offline"
|
||
}
|
||
if time.Since(*t) < 60*time.Second {
|
||
return "online"
|
||
}
|
||
return "stale"
|
||
}
|
||
|
||
// humanAgoFrom formats (now - t) as a short "Nm ago" style string.
|
||
// Buckets: <60s -> "online", <60m -> minutes, <24h -> hours, else days.
|
||
// Split on `now` so callers can hold time for tests.
|
||
func humanAgoFrom(now time.Time, t time.Time) string {
|
||
d := now.Sub(t)
|
||
if d < 0 {
|
||
d = 0
|
||
}
|
||
if d < 60*time.Second {
|
||
return "online"
|
||
}
|
||
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)))
|
||
}
|