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).
|
// shouldn't, but the queue exists either way).
|
||||||
pendingDrainTick := time.NewTicker(30 * time.Second)
|
pendingDrainTick := time.NewTicker(30 * time.Second)
|
||||||
defer pendingDrainTick.Stop()
|
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)
|
mt := maintenance.New(st)
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
@@ -176,6 +180,10 @@ func run() error {
|
|||||||
}
|
}
|
||||||
case <-pendingDrainTick.C:
|
case <-pendingDrainTick.C:
|
||||||
srv.DrainAllDue(ctx)
|
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:
|
case <-maintenanceTick.C:
|
||||||
decisions, err := mt.Decide(ctx, time.Now().UTC())
|
decisions, err := mt.Decide(ctx, time.Now().UTC())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -109,9 +109,10 @@ func (s *Server) version() string {
|
|||||||
|
|
||||||
// dashboardPage is the data the dashboard template renders against.
|
// dashboardPage is the data the dashboard template renders against.
|
||||||
type dashboardPage struct {
|
type dashboardPage struct {
|
||||||
Hosts []dashboardHostRow
|
Hosts []dashboardHostRow
|
||||||
HostCount int
|
HostCount int
|
||||||
Summary store.FleetSummary
|
Summary store.FleetSummary
|
||||||
|
PendingHosts []store.PendingHost // announce-and-approve queue (P2-18d)
|
||||||
}
|
}
|
||||||
|
|
||||||
// dashboardHostRow carries a host plus the per-row Run-now decision
|
// 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)
|
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 := s.baseView(u)
|
||||||
view.OpenAlerts = summary.OpenAlerts
|
view.OpenAlerts = summary.OpenAlerts
|
||||||
view.Page = dashboardPage{
|
view.Page = dashboardPage{
|
||||||
Hosts: rows,
|
Hosts: rows,
|
||||||
HostCount: len(hosts),
|
HostCount: len(hosts),
|
||||||
Summary: summary,
|
Summary: summary,
|
||||||
|
PendingHosts: pending,
|
||||||
}
|
}
|
||||||
if err := s.deps.UI.Render(w, "dashboard", view); err != nil {
|
if err := s.deps.UI.Render(w, "dashboard", view); err != nil {
|
||||||
slog.Error("ui: render dashboard", "err", err)
|
slog.Error("ui: render dashboard", "err", err)
|
||||||
|
|||||||
@@ -65,6 +65,60 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 ---------- */}}
|
{{/* ---------- hosts table ---------- */}}
|
||||||
<div class="pt-6 pb-4">
|
<div class="pt-6 pb-4">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user