Runs (migration 0011): a run groups the cycles of one launch. It opens when an address enters an idle queue, takes everything submitted or re-checked while it is open and is finalized when all its addresses are done; a re-check after that opens a new run, so results of different runs never mix. check_runs, run_results (one result per address and run, with the verdict and the expected and stored check counts), subnets, run_id on ip_queue and checks. Existing data is split into runs at pauses of more than an hour; ingress checks get the validator that held the address (also at write time from now on). Analytics (internal/analytics): figures computed from the stored checks of the latest cycle of each address in the run, as facts next to the verdict: summary, reasons of partial, data quality, subnets, targets and the subnet x target matrix by check type, ingress by site, error classes, validators, and the address lists behind the indicators and error classes. API: analytics runs, report, lists (JSON or CSV), subnet list; run and subnet filters for the registry. Dashboard: /analytics matching the approved mockup (run selector, indicators with address lists and CSV, error-class dialogs, drill-down to the registry), subnet list on /settings. Sidebar: the control-api link state, theme toggle and logout moved to the top, the three dots next to the logo removed, sections grouped. Rebuilt bin/control-api and bin/admin-dashboard to match. Plan, summary and the updated README, API, USAGE, DASHBOARD and ADMIN_CLEANUP docs are in docs/. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
176 lines
6.3 KiB
Go
176 lines
6.3 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
|
|
}
|
|
_, err = s.CA.PutOrchestratorSettings(r.Context(), seconds, retentionCycles)
|
|
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)
|
|
}
|