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:
1 parent
6117c044b5
commit
ec44d54ac1
4 files changed
+78
-3
No files matched your search
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user