Add host-mode heartbeat: vetting-agent host + last-seen badge
CI / Lint + build + test (push) Has been cancelled
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>
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
"vetting/internal/model"
|
||||
)
|
||||
|
||||
@@ -19,7 +20,10 @@ templ HostTile(t TileData) {
|
||||
>
|
||||
<header class="tile-head">
|
||||
<div class="tile-name">{ t.Host.Name }</div>
|
||||
<div class="tile-status">{ tileStatus(t.Latest) }</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>
|
||||
@@ -142,3 +146,46 @@ func RenderTileString(t TileData) string {
|
||||
_ = 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)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user