Files
cloud-ip-validator/internal/dashboard/handlers_settings.go
T
ayurishchevandClaude Sonnet 5.5 e95b5eb7d5 Retry a failed self-check on another validator; add the self-check failure ceiling
A validator that failed the self-check of an address no longer gets that address
again in the current round (ClaimNextQueued skips it); the validator itself stays
in service and takes all other addresses. The verdict fail is set when the number
of failed self-checks of an address reaches settings.self_check_max_attempts
(1..50, default 5, independent of the number of validators); max_retries and
retry_count are no longer used for self-check. If every working validator has
already failed the address, a new round starts and the exclusions lapse.

Migration 0012: ip_self_check_failures (permanent history per registry address),
ip_queue.sc_failures and sc_round_start_cycle (cycle_id is used instead of
attempt_number, which restarts when a queue row is recreated), the setting.
db.FailSelfCheck does it in one transaction; re-submission starts a new series.
API: self_check_max_attempts in GET/PUT /admin/config/orchestrator,
self_check_failed_on in /admin/ips/{ip} and /admin/registry/{ip}. Dashboard: the
field on /settings and the line "Self-check не прошёл на: ..." on the address
pages. Docs, plan and summary in docs/changes/.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 09:50:12 +03:00

181 lines
6.6 KiB
Go

package dashboard
import (
"fmt"
"math"
"net/http"
"strconv"
"strings"
)
type settingsPageData struct {
PageData
Settings orchestratorSettingsDTO
Inbound inboundChecksDTO
// AutoCycle is the automatic-check-cycle panel's status/parameters.
AutoCycle autoCycleDTO
// Subnets is the list the analytics page groups addresses by.
Subnets subnetList
}
func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
settings, err := s.CA.GetOrchestratorSettings(r.Context())
inbound, inboundErr := s.CA.GetInboundChecks(r.Context())
if err == nil {
err = inboundErr
}
autoCycle, autoCycleErr := s.CA.GetAutoCycle(r.Context())
if err == nil {
err = autoCycleErr
}
subnets, subnetsErr := s.CA.GetSubnets(r.Context())
if err == nil {
err = subnetsErr
}
data := settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle, Subnets: subnets}
data.ActiveNav = "settings"
data.Banner = bannerFor(err)
s.renderPage(w, r, "settings_page", data)
}
// renderSettingsForm re-fetches the current settings and renders the
// settings_form fragment, tagging actionErr (if any) on the shared error
// banner — same pattern as renderIPsTable: always reflect true current
// state regardless of whether the mutation itself succeeded.
func (s *Server) renderSettingsForm(w http.ResponseWriter, r *http.Request, actionErr error) {
settings, getErr := s.CA.GetOrchestratorSettings(r.Context())
if actionErr == nil {
actionErr = getErr
}
inbound, inboundErr := s.CA.GetInboundChecks(r.Context())
if actionErr == nil {
actionErr = inboundErr
}
autoCycle, autoCycleErr := s.CA.GetAutoCycle(r.Context())
if actionErr == nil {
actionErr = autoCycleErr
}
subnets, subnetsErr := s.CA.GetSubnets(r.Context())
if actionErr == nil {
actionErr = subnetsErr
}
s.renderFragment(w, "settings_form", settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle, Subnets: subnets}, actionErr)
}
func (s *Server) handleSettingsPut(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderSettingsForm(w, r, fmt.Errorf("invalid form: %w", err))
return
}
seconds, err := strconv.Atoi(r.PostFormValue("fip_settle_seconds"))
if err != nil {
s.renderSettingsForm(w, r, &apiErr{Status: http.StatusBadRequest, Message: "пауза должна быть целым числом секунд"})
return
}
retentionCycles, err := strconv.Atoi(r.PostFormValue("history_retention_cycles"))
if err != nil {
s.renderSettingsForm(w, r, &apiErr{Status: http.StatusBadRequest, Message: "глубина истории должна быть целым числом циклов"})
return
}
selfCheckMax, err := strconv.Atoi(r.PostFormValue("self_check_max_attempts"))
if err != nil {
s.renderSettingsForm(w, r, &apiErr{Status: http.StatusBadRequest, Message: "потолок провалов self-check должен быть целым числом"})
return
}
_, err = s.CA.PutOrchestratorSettings(r.Context(), seconds, retentionCycles, selfCheckMax)
s.renderSettingsForm(w, r, err)
}
// handleInboundChecksPut parses the comma-separated ports field of the
// second form on /settings and saves it via PUT
// /api/v1/admin/config/inbound-checks. A separate form/handler from
// fip_settle_seconds above — the two settings are unrelated and shouldn't
// share one submit.
func (s *Server) handleInboundChecksPut(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderSettingsForm(w, r, fmt.Errorf("invalid form: %w", err))
return
}
raw := strings.TrimSpace(r.PostFormValue("ports"))
var ports []int
if raw != "" {
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
p, err := strconv.Atoi(part)
if err != nil {
s.renderSettingsForm(w, r, &apiErr{Status: http.StatusBadRequest, Message: "порты должны быть целыми числами через запятую"})
return
}
ports = append(ports, p)
}
}
icmp := r.PostFormValue("icmp") == "on"
_, err := s.CA.PutInboundChecks(r.Context(), ports, icmp)
s.renderSettingsForm(w, r, err)
}
// handleSubnetsPut saves the subnet list from the textarea of /settings: one
// subnet per line, CIDR first and an optional label after a space.
func (s *Server) handleSubnetsPut(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderSettingsForm(w, r, fmt.Errorf("invalid form: %w", err))
return
}
list := subnetList{Subnets: []subnetEntry{}}
for _, line := range strings.Split(r.PostFormValue("subnets"), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
cidr, label, _ := strings.Cut(line, " ")
list.Subnets = append(list.Subnets, subnetEntry{CIDR: strings.TrimSpace(cidr), Label: strings.TrimSpace(label)})
}
_, err := s.CA.PutSubnets(r.Context(), list)
s.renderSettingsForm(w, r, err)
}
// parseMinutes converts a form field holding a (possibly fractional) number
// of minutes into whole seconds.
func parseMinutes(raw string) (int, error) {
f, err := strconv.ParseFloat(strings.TrimSpace(strings.ReplaceAll(raw, ",", ".")), 64)
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) || math.Abs(f) > 1e6 {
return 0, fmt.Errorf("not a number of minutes: %q", raw)
}
return int(math.Round(f * 60)), nil
}
// handleAutoCyclePut saves the auto-cycle interval and maximum run duration
// (entered in minutes, sent to control-api in seconds). It does not touch
// the enabled flag — that's what the start/stop buttons are for.
func (s *Server) handleAutoCyclePut(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderSettingsForm(w, r, fmt.Errorf("invalid form: %w", err))
return
}
intervalSec, err := parseMinutes(r.PostFormValue("interval_minutes"))
if err != nil {
s.renderSettingsForm(w, r, &apiErr{Status: http.StatusBadRequest, Message: "интервал должен быть числом минут"})
return
}
maxRunSec, err := parseMinutes(r.PostFormValue("max_run_minutes"))
if err != nil {
s.renderSettingsForm(w, r, &apiErr{Status: http.StatusBadRequest, Message: "максимальная длительность должна быть числом минут (0 — без лимита)"})
return
}
_, err = s.CA.PutAutoCycle(r.Context(), intervalSec, maxRunSec)
s.renderSettingsForm(w, r, err)
}
func (s *Server) handleAutoCycleStart(w http.ResponseWriter, r *http.Request) {
_, err := s.CA.StartAutoCycle(r.Context())
s.renderSettingsForm(w, r, err)
}
func (s *Server) handleAutoCycleStop(w http.ResponseWriter, r *http.Request) {
_, err := s.CA.StopAutoCycle(r.Context())
s.renderSettingsForm(w, r, err)
}