Files
ayurishchevandClaude Sonnet 5.5 aff8fe38b5 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>
2026-10-01 19:31:11 +03:00

119 lines
2.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}