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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user