Scan floating IPs in the background, page by page, so thousands of addresses work
The "Scan Floating IP" button failed with a client timeout: the project now holds ~6.4k floating IPs and the scan listed them all in one unpaginated, timeout-less Neutron request on the HTTP request context. openstack: ListFreeFloatingIPs reads marker-based pages (fields= keeps them small) with per-page retry/backoff on transport errors, 5xx and 429, and every request now has a timeout (also ends hangs inside the orchestrator tick). orchestrator: the scan is a single-flight background job on the process context with progress (clearing/listing/enqueuing/done/error), dry_run, full discovery before anything is enqueued, then SubmitIPs in chunks of 500 in ascending IP order; a failed read leaves the queue untouched. The auto-cycle gets a "scanning" phase that polls the job, so the control loop and autoCycleMu are never held across OpenStack/DB work; it recovers after a restart and waits for (instead of adopting) a scan started by someone else. db: migration 0009 (indexes), paged ListIPsPage/ListRegistryPage, GROUP BY counters, EXISTS completion check, set-based ClearAllIPs. API: POST /admin/ips/scan -> 202 (dry_run, wait), GET /admin/ips/scan, paging and filters on /admin/ips and /admin/registry (bare arrays without limit), results_by_overall in /admin/status. dashboard: scan progress panel and dry-run button, paginated /ips and /registry with server-side filters, Overview on counters and capped lists with progress/ETA, "select all N by filter", hx-params fix for per-row buttons, real counts in confirmations. Also: docs (API, USAGE, DASHBOARD, README), plan and review under docs/changes/, bin/ rebuilt with new SHA256SUMS. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
debf2afed2
commit
aff8fe38b5
61 files changed
+5833
-536
No files matched your search
@@ -0,0 +1,118 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Server-side pagination shared by /ips and /registry: `page` (1-based,
|
||||
// clamped to the last page) and `per_page` (one of perPageOptions, else
|
||||
// defaultPerPage).
|
||||
|
||||
const defaultPerPage = 50
|
||||
|
||||
var perPageOptions = []int{25, 50, 100, 200}
|
||||
|
||||
// parsePerPage accepts only the whitelisted page sizes.
|
||||
func parsePerPage(raw string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return defaultPerPage
|
||||
}
|
||||
for _, o := range perPageOptions {
|
||||
if n == o {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return defaultPerPage
|
||||
}
|
||||
|
||||
// parsePage returns the 1-based page number; anything invalid is page 1.
|
||||
func parsePage(raw string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || n < 1 {
|
||||
return 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func pageCount(total, perPage int) int {
|
||||
if total <= 0 || perPage <= 0 {
|
||||
return 1
|
||||
}
|
||||
return (total + perPage - 1) / perPage
|
||||
}
|
||||
|
||||
// clampPage limits page to the last non-empty page for the given total.
|
||||
func clampPage(page, total, perPage int) int {
|
||||
if last := pageCount(total, perPage); page > last {
|
||||
page = last
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
// pagerData drives the shared "pager" template partial.
|
||||
type pagerData struct {
|
||||
// Wrap is the id of the swap-target element holding the table (the
|
||||
// ‹ › links replace it via hx-select + hx-target).
|
||||
Wrap string
|
||||
Page, PerPage, Total, Pages, From, To int
|
||||
PrevURL, NextURL string
|
||||
}
|
||||
|
||||
// newPager builds the pager for page (already clamped) of total rows. base is
|
||||
// the page path; params are the filters to preserve in the links (without
|
||||
// page/per_page, which the pager sets itself).
|
||||
func newPager(base, wrap string, params url.Values, page, perPage, total int) pagerData {
|
||||
p := pagerData{Wrap: wrap, Page: page, PerPage: perPage, Total: total, Pages: pageCount(total, perPage)}
|
||||
if total > 0 {
|
||||
p.From = (page-1)*perPage + 1
|
||||
p.To = page * perPage
|
||||
if p.To > total {
|
||||
p.To = total
|
||||
}
|
||||
}
|
||||
if page > 1 {
|
||||
p.PrevURL = pageURL(base, params, page-1, perPage)
|
||||
}
|
||||
if page < p.Pages {
|
||||
p.NextURL = pageURL(base, params, page+1, perPage)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// pageURL builds base?...&page=N&per_page=M with every value escaped by
|
||||
// url.Values (so a search string like "a&b" can't smuggle in a parameter).
|
||||
// Page 1 omits `page`.
|
||||
func pageURL(base string, params url.Values, page, perPage int) string {
|
||||
v := url.Values{}
|
||||
for k, vals := range params {
|
||||
for _, val := range vals {
|
||||
if val != "" {
|
||||
v.Add(k, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
v.Set("per_page", strconv.Itoa(perPage))
|
||||
if page > 1 {
|
||||
v.Set("page", strconv.Itoa(page))
|
||||
}
|
||||
return base + "?" + v.Encode()
|
||||
}
|
||||
|
||||
// chunk splits list into slices of at most size elements.
|
||||
func chunk(list []string, size int) [][]string {
|
||||
var out [][]string
|
||||
for len(list) > size {
|
||||
out = append(out, list[:size])
|
||||
list = list[size:]
|
||||
}
|
||||
if len(list) > 0 {
|
||||
out = append(out, list)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user