ui+server: P2-18d pending hosts dashboard panel + expiry sweeper

Dashboard handler loads ListPendingHosts(now); template renders a
warn-bordered panel above the host table with hostname, OS/arch,
fingerprint (selectable / copyable), source IP, age, expiry. Each
row carries an inline accept form (repo URL/user/password) plus a
Reject button. cmd/server adds a 60s ticker calling
DeleteExpiredPendingHosts so 1h-stale rows drop off.
This commit is contained in:
2026-05-04 11:11:32 +01:00
parent a3a53e3b87
commit bbdf631a01
3 changed files with 75 additions and 6 deletions
+8
View File
@@ -156,6 +156,10 @@ func run() error {
// shouldn't, but the queue exists either way).
pendingDrainTick := time.NewTicker(30 * time.Second)
defer pendingDrainTick.Stop()
// Pending-hosts expiry sweeper: drops announce rows past their 1h
// ceiling so the dashboard panel doesn't accumulate stale entries.
pendingExpiryTick := time.NewTicker(60 * time.Second)
defer pendingExpiryTick.Stop()
mt := maintenance.New(st)
go func() {
for {
@@ -176,6 +180,10 @@ func run() error {
}
case <-pendingDrainTick.C:
srv.DrainAllDue(ctx)
case <-pendingExpiryTick.C:
if n, err := st.DeleteExpiredPendingHosts(ctx, time.Now().UTC()); err == nil && n > 0 {
slog.Info("expired pending hosts swept", "n", n)
}
case <-maintenanceTick.C:
decisions, err := mt.Decide(ctx, time.Now().UTC())
if err != nil {
+13 -6
View File
@@ -109,9 +109,10 @@ func (s *Server) version() string {
// dashboardPage is the data the dashboard template renders against.
type dashboardPage struct {
Hosts []dashboardHostRow
HostCount int
Summary store.FleetSummary
Hosts []dashboardHostRow
HostCount int
Summary store.FleetSummary
PendingHosts []store.PendingHost // announce-and-approve queue (P2-18d)
}
// dashboardHostRow carries a host plus the per-row Run-now decision
@@ -220,12 +221,18 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request)
rows = append(rows, row)
}
pending, perr := s.deps.Store.ListPendingHosts(r.Context(), time.Now().UTC())
if perr != nil {
slog.Warn("ui dashboard: list pending hosts", "err", perr)
}
view := s.baseView(u)
view.OpenAlerts = summary.OpenAlerts
view.Page = dashboardPage{
Hosts: rows,
HostCount: len(hosts),
Summary: summary,
Hosts: rows,
HostCount: len(hosts),
Summary: summary,
PendingHosts: pending,
}
if err := s.deps.UI.Render(w, "dashboard", view); err != nil {
slog.Error("ui: render dashboard", "err", err)
+54
View File
@@ -65,6 +65,60 @@
</div>
</div>
{{/* ---------- Pending hosts (announce-and-approve queue) ---------- */}}
{{if gt (len $page.PendingHosts) 0}}
<div class="pt-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3">
<h2 class="text-[13px] font-semibold tracking-[0.01em] text-warn">Pending hosts</h2>
<div class="text-xs text-ink-fade">{{len $page.PendingHosts}} waiting for approval</div>
</div>
</div>
<div class="panel rounded-[7px] overflow-hidden"
style="border-color: color-mix(in oklch, var(--warn), transparent 70%);">
{{range $i, $ph := $page.PendingHosts}}
<div class="p-4 {{if not (eq $i 0)}}hairline{{end}}">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="mono text-ink font-medium">{{$ph.Hostname}}</span>
<span class="mono text-[11px] text-ink-fade">{{$ph.OS}}/{{$ph.Arch}}</span>
<span class="mono text-[11px] text-ink-fade">agent {{$ph.AgentVersion}}</span>
<span class="mono text-[11px] text-ink-fade">restic {{$ph.ResticVersion}}</span>
</div>
<div class="mt-2 mono text-[12px] text-ink-mid select-all break-all"
style="font-family: var(--font-mono); padding: 6px 8px; background: var(--panel-hi); border-radius: 4px;">
{{$ph.Fingerprint}}
</div>
<div class="text-[11px] text-ink-fade mt-2">
from {{$ph.AnnouncedFromIP}} · {{relTime $ph.FirstSeenAt}}
· expires {{relTime $ph.ExpiresAt}}
</div>
</div>
<form method="post" action="/api/pending-hosts/{{$ph.ID}}/accept"
class="flex flex-col gap-2 flex-none" style="width: 320px;"
onsubmit="return confirm('Accept host &quot;{{$ph.Hostname}}&quot; (fingerprint {{$ph.Fingerprint}})? Make sure this matches what the install script printed.');">
<input type="text" name="repo_url" required placeholder="rest:http://…"
class="input mono" style="height: 28px; padding: 0 8px; font-size: 12px;">
<input type="text" name="repo_username" placeholder="repo username (optional)"
class="input mono" style="height: 28px; padding: 0 8px; font-size: 12px;">
<input type="password" name="repo_password" required placeholder="repo password"
class="input mono" style="height: 28px; padding: 0 8px; font-size: 12px;">
<div class="flex gap-2">
<button type="submit" class="btn btn-primary flex-1">Accept</button>
<button type="button" class="btn btn-danger flex-1"
hx-post="/api/pending-hosts/{{$ph.ID}}/reject"
hx-confirm="Reject pending host '{{$ph.Hostname}}'?"
hx-on::after-request="window.location.reload()">Reject</button>
</div>
</form>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
{{/* ---------- hosts table ---------- */}}
<div class="pt-6 pb-4">
<div class="flex items-center justify-between mb-3">