bb658a8435
CI / Lint + build + test (push) Has been cancelled
Click a tile to open /hosts/{id} — the canonical control surface per
host. Timeline renders every pre-stage, stage, and terminal node in
order, with the current one pulsing, failed ones flagged, and
downstream ones dimmed as skipped. Detail page shows summary, hold
card (when holding), all action buttons, spec diffs, a full-height
log pane, and a collapsed expected-spec YAML.
Tile slims to name, last-seen, status, and one primary action; a
CSS-overlay <a> makes the whole card clickable while buttons stay
receptive via z-index.
Runner.publishTileUpdate now also emits pipeline-{runID} fragments,
and CompleteStage wraps Stages.CompleteByName so stage completions
advance the timeline live — without this the dots only moved on
state transitions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
147 lines
4.0 KiB
Plaintext
147 lines
4.0 KiB
Plaintext
package templates
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"time"
|
||
"vetting/internal/model"
|
||
)
|
||
|
||
// HostTile renders a single dashboard card. 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`).
|
||
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>
|
||
<div class="tile-primary-action">
|
||
if canStart(t.Latest) {
|
||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)) } class="inline">
|
||
<button type="submit">Start vetting</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>
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
func canStart(r *model.Run) bool {
|
||
if r == nil {
|
||
return true
|
||
}
|
||
switch r.State {
|
||
case model.StateCompleted, model.StateReleased, model.StateFailedHolding:
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func tileStatus(r *model.Run) string {
|
||
if r == nil {
|
||
return "Idle"
|
||
}
|
||
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:
|
||
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)))
|
||
}
|