a0c0fb114f
CI / Lint + build + test (push) Has been cancelled
vetting-agent gains a `host` subcommand that runs as a systemd service
installed by the quick-register one-liner, POSTing every 30s to
/api/v1/hosts/{mac}/heartbeat so the dashboard tile shows "online" or
"Nm ago" without waiting on WoL. Ships dormant client code for the
Phase 2 reboot_for_vetting command so the server can flip it on later
without a binary redeploy.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
192 lines
5.3 KiB
Plaintext
192 lines
5.3 KiB
Plaintext
package templates
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"time"
|
||
"vetting/internal/model"
|
||
)
|
||
|
||
// HostTile renders a single dashboard card. It's the SSE-swap target
|
||
// for per-host tile refreshes (`tile-N`) and contains a per-run log
|
||
// pane (`log-M`) whose live tail is appended by the events hub.
|
||
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"
|
||
>
|
||
<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>
|
||
<dl class="tile-meta">
|
||
<div>
|
||
<dt>MAC</dt>
|
||
<dd>{ t.Host.MAC }</dd>
|
||
</div>
|
||
<div>
|
||
<dt>WoL</dt>
|
||
<dd>{ fmt.Sprintf("%s:%d", t.Host.WoLBroadcastIP, t.Host.WoLPort) }</dd>
|
||
</div>
|
||
if t.Latest != nil && t.Latest.FailedStage != "" {
|
||
<div>
|
||
<dt>Failed at</dt>
|
||
<dd>{ t.Latest.FailedStage }</dd>
|
||
</div>
|
||
}
|
||
if t.SpecDiffCritical > 0 {
|
||
<div>
|
||
<dt>Spec diffs</dt>
|
||
<dd class="bad">{ fmt.Sprintf("%d critical", t.SpecDiffCritical) }</dd>
|
||
</div>
|
||
}
|
||
</dl>
|
||
if t.Latest != nil && t.Latest.State == model.StateFailedHolding && t.Latest.HoldIP != "" {
|
||
<div class="tile-hold">
|
||
<div class="hold-title">Host is holding — SSH available</div>
|
||
<code class="hold-ssh">{ sshInvocation(t.HoldKeyPath, t.Latest.HoldIP) }</code>
|
||
</div>
|
||
}
|
||
if t.Latest != nil {
|
||
<div
|
||
class="tile-log"
|
||
id={ fmt.Sprintf("log-%d", t.Latest.ID) }
|
||
sse-swap={ fmt.Sprintf("log-%d", t.Latest.ID) }
|
||
hx-swap="beforeend"
|
||
></div>
|
||
}
|
||
<div class="tile-actions">
|
||
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 {
|
||
<button type="button" disabled>Run in flight</button>
|
||
}
|
||
if canOverrideWipe(t.Latest) {
|
||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", t.Host.ID)) } class="inline">
|
||
<button type="submit" class="danger">Override wipe-probe</button>
|
||
</form>
|
||
}
|
||
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>
|
||
}
|
||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", t.Host.ID)) } class="inline">
|
||
<button type="submit" class="danger">Delete</button>
|
||
</form>
|
||
</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)))
|
||
}
|