From ec44d54ac1229771e2d7e8ba406cd96d9e4ef6d1 Mon Sep 17 00:00:00 2001 From: ayurishchev Date: Fri, 18 Sep 2026 18:47:11 +0300 Subject: [PATCH] Add bulk recheck and auto-refresh to the IPs dashboard page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Перепроверить выбранные" 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 --- internal/dashboard/handlers_ips.go | 25 +++++++++++++- internal/dashboard/handlers_test.go | 49 +++++++++++++++++++++++++++ internal/dashboard/routes.go | 2 ++ internal/dashboard/templates/ips.html | 5 +-- 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/internal/dashboard/handlers_ips.go b/internal/dashboard/handlers_ips.go index 916f4f4..13764e3 100644 --- a/internal/dashboard/handlers_ips.go +++ b/internal/dashboard/handlers_ips.go @@ -9,6 +9,7 @@ type ipsPageData struct { PageData Items []ipQueueItem FIPSettleSeconds int + PollSeconds int } type ipDetailData struct { @@ -22,12 +23,20 @@ func (s *Server) handleIPsPage(w http.ResponseWriter, r *http.Request) { if err == nil { err = settingsErr } - data := ipsPageData{Items: items, FIPSettleSeconds: settings.FIPSettleSeconds} + data := ipsPageData{Items: items, FIPSettleSeconds: settings.FIPSettleSeconds, PollSeconds: s.Cfg.OverviewPollIntervalS} data.ActiveNav = "ips" data.Banner = bannerFor(err) 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) { ip := r.PathValue("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) } +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) { _, err := s.CA.ClearQueue(r.Context()) s.renderIPsTable(w, r, err) diff --git a/internal/dashboard/handlers_test.go b/internal/dashboard/handlers_test.go index 50dab2a..c4e6e18 100644 --- a/internal/dashboard/handlers_test.go +++ b/internal/dashboard/handlers_test.go @@ -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) { fake, caURL := newFakeControlAPI(t) now := time.Now() diff --git a/internal/dashboard/routes.go b/internal/dashboard/routes.go index d9f22a6..dffdbb9 100644 --- a/internal/dashboard/routes.go +++ b/internal/dashboard/routes.go @@ -9,12 +9,14 @@ func (s *Server) routes(mux *http.ServeMux) { mux.HandleFunc("GET /overview/fragment", s.handleOverviewFragment) mux.HandleFunc("GET /ips", s.handleIPsPage) + mux.HandleFunc("GET /ips/fragment", s.handleIPsFragment) mux.HandleFunc("GET /ips/{ip}", s.handleIPDetail) mux.HandleFunc("POST /ips", s.handleIPsSubmit) mux.HandleFunc("POST /ips/{ip}/recheck", s.handleIPRecheck) mux.HandleFunc("POST /ips/{ip}/cancel", s.handleIPCancel) mux.HandleFunc("DELETE /ips/{ip}", s.handleIPDelete) mux.HandleFunc("POST /ips/delete", s.handleIPsDeleteSelected) + mux.HandleFunc("POST /ips/recheck", s.handleIPsRecheckSelected) mux.HandleFunc("POST /ips/clear", s.handleIPsClear) mux.HandleFunc("GET /validators", s.handleValidatorsPage) diff --git a/internal/dashboard/templates/ips.html b/internal/dashboard/templates/ips.html index 42bfbd2..b6cecc4 100644 --- a/internal/dashboard/templates/ips.html +++ b/internal/dashboard/templates/ips.html @@ -38,7 +38,7 @@ -
+
{{template "ips_table" .}}
{{end}} @@ -47,6 +47,7 @@
+
@@ -58,7 +59,7 @@ {{$b := ipBadge .State .OverallResult .FIPAssociatedAt $.FIPSettleSeconds}} {{$terminal := or (eq .State "done") (eq .State "failed") (eq .State "occupied")}} - + {{.IPAddress}} {{$b.Label}} {{deref .OwnerValidatorID}}