Add bulk recheck and auto-refresh to the IPs dashboard page

"Перепроверить выбранные" joins the existing "Удалить выбранные" / "Очистить
всё" bulk actions, using the checked-row selection the same way delete
already does — no control-api changes needed, since forcing a recheck of a
batch of addresses (new, finished, or already-queued, skipping anything
mid-check) is exactly what POST /api/v1/admin/ips (db.SubmitIPs) already
does, and the dashboard's own SubmitIPs client method already backs both
the top add/recheck form and the single-row recheck button.

The page also now auto-refreshes every 5s (handleIPsFragment + GET
/ips/fragment), mirroring the overview page's existing hx-trigger="every
Ns" polling and reusing the same config-driven interval
(Cfg.OverviewPollIntervalS) rather than adding a duplicate knob. Since the
table now polls itself, each row's selection checkbox gets a stable id
plus hx-preserve so a checked box survives the refresh (its own DOM node
is kept) while the rest of the row still updates live.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Sonnet 5 committed 2026-09-18 18:47:11 +03:00
1 parent 6117c044b5
commit ec44d54ac1
4 files changed
+78 -3

No files matched your search

+24 -1
View File
@@ -9,6 +9,7 @@ type ipsPageData struct {
PageData PageData
Items []ipQueueItem Items []ipQueueItem
FIPSettleSeconds int FIPSettleSeconds int
PollSeconds int
} }
type ipDetailData struct { type ipDetailData struct {
@@ -22,12 +23,20 @@ func (s *Server) handleIPsPage(w http.ResponseWriter, r *http.Request) {
if err == nil { if err == nil {
err = settingsErr err = settingsErr
} }
data := ipsPageData{Items: items, FIPSettleSeconds: settings.FIPSettleSeconds} data := ipsPageData{Items: items, FIPSettleSeconds: settings.FIPSettleSeconds, PollSeconds: s.Cfg.OverviewPollIntervalS}
data.ActiveNav = "ips" data.ActiveNav = "ips"
data.Banner = bannerFor(err) data.Banner = bannerFor(err)
s.renderPage(w, "ips_page", data) s.renderPage(w, "ips_page", data)
} }
// handleIPsFragment re-renders just the ips_table fragment, for the
// periodic auto-refresh poll (see ips_content's hx-trigger="every Ns") —
// identical to a mutating action's post-update render, just with no
// actionErr of its own.
func (s *Server) handleIPsFragment(w http.ResponseWriter, r *http.Request) {
s.renderIPsTable(w, r, nil)
}
func (s *Server) handleIPDetail(w http.ResponseWriter, r *http.Request) { func (s *Server) handleIPDetail(w http.ResponseWriter, r *http.Request) {
ip := r.PathValue("ip") ip := r.PathValue("ip")
detail, err := s.CA.GetIP(r.Context(), ip) detail, err := s.CA.GetIP(r.Context(), ip)
@@ -99,6 +108,20 @@ func (s *Server) handleIPsDeleteSelected(w http.ResponseWriter, r *http.Request)
s.renderIPsTable(w, r, err) s.renderIPsTable(w, r, err)
} }
func (s *Server) handleIPsRecheckSelected(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderIPsTable(w, r, fmt.Errorf("invalid form: %w", err))
return
}
addresses := r.Form["addresses"]
if len(addresses) == 0 {
s.renderIPsTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "ничего не выбрано"})
return
}
_, err := s.CA.SubmitIPs(r.Context(), addresses)
s.renderIPsTable(w, r, err)
}
func (s *Server) handleIPsClear(w http.ResponseWriter, r *http.Request) { func (s *Server) handleIPsClear(w http.ResponseWriter, r *http.Request) {
_, err := s.CA.ClearQueue(r.Context()) _, err := s.CA.ClearQueue(r.Context())
s.renderIPsTable(w, r, err) s.renderIPsTable(w, r, err)
+49
View File
@@ -155,6 +155,55 @@ func TestIPsDeleteSelected(t *testing.T) {
} }
} }
func TestIPsPagePollsForFragment(t *testing.T) {
_, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
body := get(t, ts, "/ips")
if !strings.Contains(body, `hx-get="/ips/fragment"`) || !strings.Contains(body, `hx-trigger="every 5s"`) {
t.Fatalf("expected ips-table-wrap to auto-poll every 5s, got:\n%s", body)
}
}
func TestIPsFragment(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
fake.ips = []ipQueueItem{
{IPAddress: "1.1.1.1", State: "queued", UpdatedAt: now, CreatedAt: now},
{IPAddress: "2.2.2.2", State: "checking", UpdatedAt: now, CreatedAt: now},
}
ts := newTestServer(t, caURL)
body := get(t, ts, "/ips/fragment")
if !strings.Contains(body, "1.1.1.1") || !strings.Contains(body, "2.2.2.2") {
t.Fatalf("expected both addresses in fragment, got:\n%s", body)
}
}
func TestIPsRecheckSelected(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
fake.ips = []ipQueueItem{
{IPAddress: "9.9.9.9", State: "done", OverallResult: "pass", AttemptNumber: 1, AggregatedAt: &now, UpdatedAt: now, CreatedAt: now},
{IPAddress: "1.1.1.1", State: "queued", AttemptNumber: 1, UpdatedAt: now, CreatedAt: now},
}
ts := newTestServer(t, caURL)
body := postForm(t, ts, "POST", "/ips/recheck", map[string][]string{"addresses": {"9.9.9.9", "1.1.1.1"}})
if !strings.Contains(body, "9.9.9.9") || !strings.Contains(body, "1.1.1.1") {
t.Fatalf("expected both addresses still in re-rendered table, got:\n%s", body)
}
if fake.ips[0].State != "queued" || fake.ips[0].AttemptNumber != 2 {
t.Fatalf("expected done address requeued with attempt_number=2, got %+v", fake.ips[0])
}
// Empty selection is a client error, surfaced via the banner, not a 500.
body = postForm(t, ts, "POST", "/ips/recheck", nil)
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected client error banner for empty selection, got:\n%s", body)
}
}
func TestIPsClear(t *testing.T) { func TestIPsClear(t *testing.T) {
fake, caURL := newFakeControlAPI(t) fake, caURL := newFakeControlAPI(t)
now := time.Now() now := time.Now()
+2
View File
@@ -9,12 +9,14 @@ func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /overview/fragment", s.handleOverviewFragment) mux.HandleFunc("GET /overview/fragment", s.handleOverviewFragment)
mux.HandleFunc("GET /ips", s.handleIPsPage) mux.HandleFunc("GET /ips", s.handleIPsPage)
mux.HandleFunc("GET /ips/fragment", s.handleIPsFragment)
mux.HandleFunc("GET /ips/{ip}", s.handleIPDetail) mux.HandleFunc("GET /ips/{ip}", s.handleIPDetail)
mux.HandleFunc("POST /ips", s.handleIPsSubmit) mux.HandleFunc("POST /ips", s.handleIPsSubmit)
mux.HandleFunc("POST /ips/{ip}/recheck", s.handleIPRecheck) mux.HandleFunc("POST /ips/{ip}/recheck", s.handleIPRecheck)
mux.HandleFunc("POST /ips/{ip}/cancel", s.handleIPCancel) mux.HandleFunc("POST /ips/{ip}/cancel", s.handleIPCancel)
mux.HandleFunc("DELETE /ips/{ip}", s.handleIPDelete) mux.HandleFunc("DELETE /ips/{ip}", s.handleIPDelete)
mux.HandleFunc("POST /ips/delete", s.handleIPsDeleteSelected) mux.HandleFunc("POST /ips/delete", s.handleIPsDeleteSelected)
mux.HandleFunc("POST /ips/recheck", s.handleIPsRecheckSelected)
mux.HandleFunc("POST /ips/clear", s.handleIPsClear) mux.HandleFunc("POST /ips/clear", s.handleIPsClear)
mux.HandleFunc("GET /validators", s.handleValidatorsPage) mux.HandleFunc("GET /validators", s.handleValidatorsPage)
+3 -2
View File
@@ -38,7 +38,7 @@
</div> </div>
</div> </div>
<div id="ips-table-wrap"> <div id="ips-table-wrap" hx-get="/ips/fragment" hx-trigger="every {{.PollSeconds}}s" hx-swap="innerHTML">
{{template "ips_table" .}} {{template "ips_table" .}}
</div> </div>
{{end}} {{end}}
@@ -47,6 +47,7 @@
<form id="ips-form"> <form id="ips-form">
<div class="panel"> <div class="panel">
<div class="panel-body" style="display:flex; gap:8px; align-items:center;"> <div class="panel-body" style="display:flex; gap:8px; align-items:center;">
<button type="button" class="btn btn-ghost btn-sm" hx-post="/ips/recheck" hx-target="#ips-table-wrap" hx-swap="innerHTML">Перепроверить выбранные</button>
<button type="button" class="btn btn-danger-ghost btn-sm" hx-post="/ips/delete" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить выбранные адреса без возможности восстановления?">Удалить выбранные</button> <button type="button" class="btn btn-danger-ghost btn-sm" hx-post="/ips/delete" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить выбранные адреса без возможности восстановления?">Удалить выбранные</button>
<button type="button" class="btn btn-danger-ghost btn-sm" hx-post="/ips/clear" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить ВСЕ адреса из очереди, включая те, что сейчас проверяются? Действие необратимо.">Очистить всё</button> <button type="button" class="btn btn-danger-ghost btn-sm" hx-post="/ips/clear" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить ВСЕ адреса из очереди, включая те, что сейчас проверяются? Действие необратимо.">Очистить всё</button>
</div> </div>
@@ -58,7 +59,7 @@
{{$b := ipBadge .State .OverallResult .FIPAssociatedAt $.FIPSettleSeconds}} {{$b := ipBadge .State .OverallResult .FIPAssociatedAt $.FIPSettleSeconds}}
{{$terminal := or (eq .State "done") (eq .State "failed") (eq .State "occupied")}} {{$terminal := or (eq .State "done") (eq .State "failed") (eq .State "occupied")}}
<tr> <tr>
<td data-label=""><input type="checkbox" name="addresses" value="{{.IPAddress}}"></td> <td data-label=""><input type="checkbox" name="addresses" value="{{.IPAddress}}" id="ip-select-{{.IPAddress}}" hx-preserve="true"></td>
<td class="addr" data-label="Адрес"><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td> <td class="addr" data-label="Адрес"><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td>
<td data-label="Состояние"><span class="pill {{$b.Class}}">{{$b.Label}}</span></td> <td data-label="Состояние"><span class="pill {{$b.Class}}">{{$b.Label}}</span></td>
<td data-label="Валидатор">{{deref .OwnerValidatorID}}</td> <td data-label="Валидатор">{{deref .OwnerValidatorID}}</td>