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>
119 lines
2.9 KiB
Go
119 lines
2.9 KiB
Go
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
|
||
}
|