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:
ayurishchevandClaude Sonnet 5.5 committed 2026-10-01 19:31:11 +03:00
1 parent debf2afed2
commit aff8fe38b5
61 files changed
+5833 -536

No files matched your search

+109 -71
View File
@@ -2,23 +2,51 @@ package dashboard
import (
"net/http"
"sort"
"strings"
"time"
)
const (
// overviewActiveLimit caps the "В работе" table; overviewQueueLimit caps
// the compact list of the next queued addresses. The queue itself can hold
// thousands of rows, so the overview never lists more than these.
overviewActiveLimit = 100
overviewQueueLimit = 10
// etaMinSamples is how many completed rows (with AggregatedAt) the ETA
// needs to derive a rate from.
etaMinSamples = 5
)
// overviewProgress is the "Готово D из T" indicator of the stats block.
type overviewProgress struct {
Total, Done, Active, Queued, Percent int
// ETA is a rough time-to-finish estimate ("" when not enough data).
ETA string
}
type overviewData struct {
PageData
Status statusResponse
CurrentItems []ipQueueItem
Status statusResponse
// ActiveItems are the addresses being checked right now (≤ overviewActiveLimit
// of ActiveTotal); QueuedItems are the next few waiting ones (QueuedTotal
// in total). Both stay empty under a result filter: an address without a
// verdict can't match one.
ActiveItems []ipQueueItem
ActiveTotal int
QueuedItems []ipQueueItem
QueuedTotal int
LastCompleted []ipQueueItem
Breakdown map[string]int
LastN int
PollSeconds int
Query string
StatusFilter string
Progress overviewProgress
// AutoCycle is nil when the auto-cycle status could not be fetched; the
// indicator is then simply hidden (a non-fatal failure).
// indicator is then simply hidden (a non-fatal failure). Scan is likewise
// nil when the scan status is unavailable.
AutoCycle *autoCycleDTO
Scan *scanStatusDTO
}
func (s *Server) loadOverview(r *http.Request) (overviewData, error) {
@@ -27,30 +55,58 @@ func (s *Server) loadOverview(r *http.Request) (overviewData, error) {
if err != nil {
return overviewData{}, err
}
ips, err := s.CA.ListIPs(ctx)
q := strings.TrimSpace(r.URL.Query().Get("q"))
resultFilter := r.URL.Query().Get("status")
if !containsStr(ipResults, resultFilter) {
resultFilter = ""
}
lastN := s.Cfg.LastCompletedCount
if lastN < 1 {
lastN = 20
}
data := overviewData{
Status: status,
LastN: lastN,
PollSeconds: s.Cfg.OverviewPollIntervalS,
Query: q,
StatusFilter: resultFilter,
}
if resultFilter == "" {
active, err := s.CA.ListIPsPage(ctx, ipsQuery{States: activeStates, Q: q, Order: "sequence", Limit: overviewActiveLimit})
if err != nil {
return overviewData{}, err
}
queued, err := s.CA.ListIPsPage(ctx, ipsQuery{States: []string{"queued"}, Q: q, Order: "sequence", Limit: overviewQueueLimit})
if err != nil {
return overviewData{}, err
}
data.ActiveItems, data.ActiveTotal = active.Items, active.Total
data.QueuedItems, data.QueuedTotal = queued.Items, queued.Total
}
completed, err := s.CA.ListIPsPage(ctx, ipsQuery{States: []string{"done", "failed"}, Q: q, Result: resultFilter, Order: "aggregated_at_desc", Limit: lastN})
if err != nil {
return overviewData{}, err
}
var autoCycle *autoCycleDTO
data.LastCompleted = completed.Items
data.Breakdown = resultBreakdown(completed.Items)
// ETA from the unfiltered window only: a filtered list is not a sample of
// the checks' real throughput.
data.Progress = computeProgress(status, completed.Items, q == "" && resultFilter == "")
if ac, acErr := s.CA.GetAutoCycle(ctx); acErr != nil {
s.Log.Warn("overview: auto-cycle status unavailable", "err", acErr)
} else {
autoCycle = &ac
data.AutoCycle = &ac
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
resultFilter := r.URL.Query().Get("status")
last := lastCompleted(ips, s.Cfg.LastCompletedCount)
return overviewData{
Status: status,
CurrentItems: filterQueueItems(currentlyChecking(ips), q, resultFilter),
LastCompleted: filterQueueItems(last, q, resultFilter),
Breakdown: resultBreakdown(last),
LastN: s.Cfg.LastCompletedCount,
PollSeconds: s.Cfg.OverviewPollIntervalS,
Query: q,
StatusFilter: resultFilter,
AutoCycle: autoCycle,
}, nil
if sc, scErr := s.CA.ScanStatus(ctx); scErr != nil {
s.Log.Warn("overview: scan status unavailable", "err", scErr)
} else if sc.Running {
data.Scan = &sc
}
return data, nil
}
func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) {
@@ -75,40 +131,45 @@ func (s *Server) handleOverviewFragment(w http.ResponseWriter, r *http.Request)
}
}
// currentlyChecking is every IP not yet in a terminal state, ordered by
// queue position — the "текущая проверка" live snapshot. No backend
// concept of a "run" exists; this is computed fresh on every request.
func currentlyChecking(ips []ipQueueItem) []ipQueueItem {
var out []ipQueueItem
for _, ip := range ips {
if ip.State != "done" && ip.State != "failed" {
out = append(out, ip)
// computeProgress derives the done/active/queued counters from the status
// breakdown (done = every terminal state, "occupied" included) and, when
// useETA is set and the newest-first list of completed rows has at least
// etaMinSamples with AggregatedAt, a rough ETA from their completion rate.
func computeProgress(st statusResponse, completed []ipQueueItem, useETA bool) overviewProgress {
p := overviewProgress{
Total: st.TotalIPs,
Done: sumStates(st.IPsByState, terminalStates),
Active: sumStates(st.IPsByState, activeStates),
Queued: st.IPsByState["queued"],
}
if p.Total > 0 {
p.Percent = p.Done * 100 / p.Total
}
remaining := p.Active + p.Queued
if !useETA || remaining == 0 {
return p
}
var stamps []time.Time
for _, ip := range completed {
if ip.AggregatedAt != nil {
stamps = append(stamps, *ip.AggregatedAt)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Sequence < out[j].Sequence })
return out
}
// lastCompleted returns the n most recently completed (done/failed) IPs by
// AggregatedAt descending — the "последняя завершённая проверка" summary
// window. This is an operational definition, not a real "batch": resubmit
// n if the window size needs tuning (overview.last_completed_count).
func lastCompleted(ips []ipQueueItem, n int) []ipQueueItem {
var done []ipQueueItem
for _, ip := range ips {
if (ip.State == "done" || ip.State == "failed") && ip.AggregatedAt != nil {
done = append(done, ip)
}
if len(stamps) < etaMinSamples {
return p
}
sort.Slice(done, func(i, j int) bool { return done[i].AggregatedAt.After(*done[j].AggregatedAt) })
if len(done) > n {
done = done[:n]
// completed is newest-first: stamps[0] is the newest, the last the oldest.
span := stamps[0].Sub(stamps[len(stamps)-1])
if span <= 0 {
return p
}
return done
perItem := span / time.Duration(len(stamps)-1)
p.ETA = fmtDuration(perItem * time.Duration(remaining))
return p
}
// resultBreakdown counts OverallResult values across exactly the given
// items (normally the output of lastCompleted) — pass/partial/fail/cancelled.
// items (the "последние N завершённых" window) — pass/partial/fail/cancelled.
func resultBreakdown(items []ipQueueItem) map[string]int {
out := map[string]int{"pass": 0, "partial": 0, "fail": 0, "cancelled": 0}
for _, ip := range items {
@@ -116,26 +177,3 @@ func resultBreakdown(items []ipQueueItem) map[string]int {
}
return out
}
// filterQueueItems narrows items to those whose address contains q
// (case-insensitive substring) and, if status is set, whose OverallResult
// matches it exactly. A still-in-progress item always has an empty
// OverallResult, so picking any specific status hides it — the intended
// behavior for "Текущая проверка", which has no verdict yet.
func filterQueueItems(items []ipQueueItem, q, status string) []ipQueueItem {
if q == "" && status == "" {
return items
}
q = strings.ToLower(q)
out := make([]ipQueueItem, 0, len(items))
for _, ip := range items {
if q != "" && !strings.Contains(strings.ToLower(ip.IPAddress), q) {
continue
}
if status != "" && ip.OverallResult != status {
continue
}
out = append(out, ip)
}
return out
}