Files
cloud-ip-validator/internal/dashboard/dto.go
T
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

392 lines
11 KiB
Go
Raw 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 (
"fmt"
"strconv"
"time"
)
// Wire shapes for control-api's /api/v1/admin/* surface, defined locally
// rather than importing internal/httpapi's (unexported) DTOs or
// internal/db's models — the same "each binary owns the wire shapes it
// needs" pattern already used by internal/probercore and internal/agentcore.
//
// The read-only list/detail endpoints (ipQueueItem, validator, check,
// event below) mirror internal/db model structs field-for-field: those
// endpoints marshal Go structs with no json tags, so encoding/json matches
// fields by name (case-insensitively) with no tags needed here either.
// Everything else mirrors internal/httpapi/dto_admin.go's snake_case tags.
type statusResponse struct {
TotalIPs int `json:"total_ips"`
IPsByState map[string]int `json:"ips_by_state"`
TotalValidators int `json:"total_validators"`
// ResultsByOverall counts finished addresses by overall result
// (pass/partial/fail/cancelled).
ResultsByOverall map[string]int `json:"results_by_overall"`
}
// Terminal queue states: the address needs no further processing. Shared by
// the overview progress indicator; "occupied" counts as terminal too (the
// check cycle never ran because the floating IP was already bound).
var terminalStates = []string{"done", "failed", "occupied"}
// activeStates are the states of an address that is being worked on right
// now (everything between "queued" and a terminal state).
var activeStates = []string{"assigning_fip", "awaiting_self_check", "checking", "aggregating"}
// sumStates adds up the counts of the given states in a status breakdown.
func sumStates(byState map[string]int, states []string) int {
n := 0
for _, s := range states {
n += byState[s]
}
return n
}
type ipQueueItem struct {
ID int64
IPAddress string
Sequence int
State string
OwnerValidatorID *string
FIPID string
AttemptNumber int
RetryCount int
LeaseExpiresAt *time.Time
EgressComplete bool
OverallResult string
AssignedAt *time.Time
FIPAssociatedAt *time.Time
AggregatedAt *time.Time
FIPReleasedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type check struct {
ID int64
RegistryID int64
CycleID int
IPID int64
IPAddress string
AttemptNumber int
ValidatorID string
Source string
CheckType string
Target string
Success bool
LatencyMS int64
Detail string
CheckedAt time.Time
}
type event struct {
ID int64
SourceType string
SourceID string
IPID *int64
EventType string
Payload string
OccurredAt time.Time
}
type ipDetailResponse struct {
IP ipQueueItem `json:"ip"`
Checks []check `json:"checks"`
Events []event `json:"events"`
}
type validator struct {
ValidatorID string
Hostname string
OSPortID string
State string
CurrentIPID *int64
AgentVersion string
LastHeartbeatAt *time.Time
}
type submitIPsResponse struct {
Added []string `json:"added"`
Requeued []string `json:"requeued"`
Reordered []string `json:"reordered"`
SkippedInProgress []string `json:"skipped_in_progress"`
}
type deleteIPsResponse struct {
Deleted []string `json:"deleted"`
NotFound []string `json:"not_found"`
}
type clearQueueResponse struct {
Deleted []string `json:"deleted"`
Count int `json:"count"`
}
// ipsPage is the paginated envelope of GET /admin/ips (sent when the request
// carries `limit`).
type ipsPage struct {
Items []ipQueueItem `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// registryPage is the paginated envelope of GET /admin/registry.
type registryPage struct {
Items []registryItem `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// scanStatusDTO is the state of control-api's background floating-IP scan job
// (POST/GET /api/v1/admin/ips/scan).
type scanStatusDTO struct {
State string `json:"state"`
Running bool `json:"running"`
DryRun bool `json:"dry_run"`
Pages int `json:"pages"`
Discovered int `json:"discovered"`
Free int `json:"free"`
Added int `json:"added"`
Requeued int `json:"requeued"`
Reordered int `json:"reordered"`
SkippedInProgress int `json:"skipped_in_progress"`
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
Error string `json:"error"`
}
// Finished reports a job that has run to a terminal state (as opposed to
// "idle" = never started, or still running).
func (s scanStatusDTO) Finished() bool {
if s.Running {
return false
}
switch s.State {
case "done", "error", "cancelled":
return true
}
return false
}
// StateLabel is the Russian description of the job's state.
func (s scanStatusDTO) StateLabel() string {
switch s.State {
case "clearing":
return "очистка"
case "listing":
return "читаются страницы"
case "enqueuing":
return "ставятся в очередь"
case "done":
return "готово"
case "error":
return "ошибка"
case "cancelled":
return "отменено"
case "idle", "":
return "нет активного сканирования"
default:
return s.State
}
}
// PillClass picks the pill style for the state.
func (s scanStatusDTO) PillClass() string {
switch s.State {
case "done":
return "pill-success"
case "error":
return "pill-danger"
case "cancelled":
return "pill-cancel"
case "clearing", "listing", "enqueuing":
return "pill-info"
default:
return "pill-neutral"
}
}
// Handled is how many of the free addresses the enqueuing phase has already
// processed.
func (s scanStatusDTO) Handled() int {
return s.Added + s.Requeued + s.Reordered + s.SkippedInProgress
}
// Indeterminate is true while the amount of work is not known yet.
func (s scanStatusDTO) Indeterminate() bool {
return s.State == "clearing" || s.State == "listing" || (s.State == "enqueuing" && s.Free <= 0)
}
// Elapsed is the human-readable run time: until now while running, until
// finished_at afterwards; empty when the job never started.
func (s scanStatusDTO) Elapsed() string {
if s.StartedAt == nil {
return ""
}
end := time.Now()
if !s.Running && s.FinishedAt != nil {
end = *s.FinishedAt
}
return fmtDuration(end.Sub(*s.StartedAt))
}
// fmtDuration renders a duration in Russian as "2 ч 05 мин", "3 мин 07 с" or
// "42 с" — coarse on purpose (progress/ETA display).
func fmtDuration(d time.Duration) string {
if d < 0 {
d = 0
}
sec := int(d.Round(time.Second) / time.Second)
h, m, sc := sec/3600, (sec%3600)/60, sec%60
switch {
case h > 0:
return fmt.Sprintf("%d ч %02d мин", h, m)
case m > 0:
return fmt.Sprintf("%d мин %02d с", m, sc)
default:
return fmt.Sprintf("%d с", sc)
}
}
// registryItem is one row of the durable per-address registry — see
// httpapi's registryDTO. Survives an address being deleted from the check
// queue and later re-added, unlike ipQueueItem above.
type registryItem struct {
IPAddress string `json:"ip_address"`
FirstSeenAt time.Time `json:"first_seen_at"`
LastSeenAt time.Time `json:"last_seen_at"`
TotalCycles int `json:"total_cycles"`
LastResult string `json:"last_result"`
LastCheckedAt *time.Time `json:"last_checked_at"`
InQueue bool `json:"in_queue"`
CurrentState string `json:"current_state"`
}
type registryHistoryResponse struct {
Registry registryItem `json:"registry"`
Checks []check `json:"checks"`
}
type validatorDTO struct {
ValidatorID string `json:"validator_id"`
Hostname string `json:"hostname"`
OSPortID string `json:"os_port_id"`
State string `json:"state"`
LastHeartbeatAt *time.Time `json:"last_heartbeat_at"`
}
type siteDTO struct {
Index int `json:"index"`
SiteID string `json:"site_id"`
Hostname string `json:"hostname"`
State string `json:"state"`
LastHeartbeatAt *time.Time `json:"last_heartbeat_at"`
}
type targetGroupDTO struct {
Name string `json:"name"`
Targets []string `json:"targets"`
}
type checkTypeDTO struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
Targets []string `json:"targets"`
}
type errorResponse struct {
Error string `json:"error"`
}
type orchestratorSettingsDTO struct {
FIPSettleSeconds int `json:"fip_settle_seconds"`
HistoryRetentionCycles int `json:"history_retention_cycles"`
}
type inboundChecksDTO struct {
Ports []int `json:"ports"`
ICMP bool `json:"icmp"`
}
// autoCycleDTO mirrors internal/httpapi's autoCycleDTO — the status and
// parameters of the automatic check cycle (/api/v1/admin/auto-cycle).
type autoCycleDTO struct {
Enabled bool `json:"enabled"`
IntervalSeconds int `json:"interval_seconds"`
MaxRunSeconds int `json:"max_run_seconds"`
Phase string `json:"phase"`
RunStartedAt *time.Time `json:"run_started_at"`
NextRunAt *time.Time `json:"next_run_at"`
LastRunStartedAt *time.Time `json:"last_run_started_at"`
LastRunFinishedAt *time.Time `json:"last_run_finished_at"`
LastOutcome string `json:"last_outcome"`
LastError string `json:"last_error"`
LastScannedFree int `json:"last_scanned_free"`
RunsTotal int `json:"runs_total"`
}
// secondsToMinutes renders seconds as minutes for the settings form: a
// whole number when divisible by 60, otherwise a decimal ("1.5").
func secondsToMinutes(sec int) string {
return strconv.FormatFloat(float64(sec)/60, 'f', -1, 64)
}
// IntervalMinutes and MaxRunMinutes are used by the settings template: the
// UI works in minutes, the API in seconds.
func (a autoCycleDTO) IntervalMinutes() string { return secondsToMinutes(a.IntervalSeconds) }
func (a autoCycleDTO) MaxRunMinutes() string { return secondsToMinutes(a.MaxRunSeconds) }
// PhaseLabel is the Russian description of the current phase.
func (a autoCycleDTO) PhaseLabel() string {
switch a.Phase {
case "scanning":
return "сканирование Floating IP"
case "running":
return "идёт проверка"
case "waiting":
return "пауза между циклами"
case "idle":
return "ожидает запуска"
default:
return a.Phase
}
}
// OutcomeLabel is the Russian description of the last cycle's outcome.
func (a autoCycleDTO) OutcomeLabel() string {
switch a.LastOutcome {
case "completed":
return "завершён"
case "no_free_ips":
return "нет свободных IP"
case "timeout":
return "превышено время ожидания"
case "error":
return "ошибка"
case "stopped":
return "остановлен"
case "":
return "—"
default:
return a.LastOutcome
}
}
// OutcomePillClass picks the pill style for the last outcome.
func (a autoCycleDTO) OutcomePillClass() string {
switch a.LastOutcome {
case "completed":
return "pill-success"
case "no_free_ips", "timeout", "stopped":
return "pill-warning"
case "error":
return "pill-danger"
default:
return "pill-neutral"
}
}