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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "{{$ph.Hostname}}" (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">
|
||||
|
||||
Reference in New Issue
Block a user