package dashboard import ( "errors" "net/http" "net/netip" "net/url" "strconv" "strings" ) // Values of the registry's direction and protocol filters; same as // db.RegistryFilter's Level and Family. var ( registryDirections = []string{"egress", "ingress"} registryProtocols = []string{"icmp", "tcp", "ssh", "https", "tls"} ) type registryPageData struct { PageData Items []registryItem Query string StatusFilter string Run int64 // data slice: the address's cycle in this run (also the drill-down from analytics) Subnet string Direction string Protocol string Protocols []string Scoped bool // direction or protocol is set Runs []runOption SubnetOptions []subnetOption Breakdown *breakdownView // nil unless a direction or protocol is chosen AnalyticsURL string // the analytics page of Run with the same subnet, direction and protocol Page, PerPage int Total int Pager pagerData PerPageOptions []int } type registryDetailData struct { PageData History registryHistoryResponse } // handleRegistryPage lists every address ever submitted to the check // queue, with a summary of its accumulated check history — the durable // record that survives an address being deleted from /ips and later // re-added. See internal/db/migrations/0007_ip_registry.sql. Optional // ?q=&status= query params narrow the list by address substring and by // LastResult, ?run=&subnet=&direction=&protocol= by run, subnet and the // direction/protocol of the checks, and ?page=&per_page= select a page — all // applied server-side (control-api's ListRegistryPage), so only the visible // rows are transferred. The run and subnet choices come from the analytics // run list and the configured subnets; if either list is unavailable the // filters still work, just without those choices. func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) { query := parseRegistryQuery(r) q, status, run, subnet, direction, protocol := query.Q, query.LastResult, query.Run, query.Subnet, query.Direction, query.Protocol perPage := parsePerPage(r.URL.Query().Get("per_page")) page := parsePage(r.URL.Query().Get("page")) query.Limit, query.Offset = perPage, (page-1)*perPage res, err := s.CA.ListRegistryPage(r.Context(), query) if err == nil { if clamped := clampPage(page, res.Total, perPage); clamped != page { page = clamped query.Offset = (page - 1) * perPage res, err = s.CA.ListRegistryPage(r.Context(), query) } } params := url.Values{} if q != "" { params.Set("q", q) } if status != "" { params.Set("status", status) } if run > 0 { params.Set("run", strconv.FormatInt(run, 10)) } if subnet != "" { params.Set("subnet", subnet) } if direction != "" { params.Set("direction", direction) } if protocol != "" { params.Set("protocol", protocol) } // A filter/pager request from htmx swaps only #registry-table-wrap // (hx-select), so the run and subnet choices of the form are not needed. // A history-restore fetch needs the full page. var runs []analyticsRun var subnets subnetList var runsErr, subnetsErr error if r.Header.Get("HX-Request") != "true" || r.Header.Get("HX-History-Restore-Request") == "true" { runs, runsErr = s.CA.ListAnalyticsRuns(r.Context()) subnets, subnetsErr = s.CA.GetSubnets(r.Context()) } data := registryPageData{ Run: run, Subnet: subnet, Direction: direction, Protocol: protocol, Protocols: registryProtocols, Scoped: direction != "" || protocol != "", Runs: registryRunOptions(runs, run), SubnetOptions: registrySubnetOptions(subnets.Subnets, subnet), Items: res.Items, Query: q, StatusFilter: status, Page: page, PerPage: perPage, Total: res.Total, Pager: newPager("/registry", "registry-table-wrap", params, page, perPage, res.Total), PerPageOptions: perPageOptions, } data.ActiveNav = "registry" if run > 0 { data.AnalyticsURL = analyticsURL(run, query) } if err == nil { data.Breakdown = s.registryBreakdown(r, query, params) err = errors.Join(runsErr, subnetsErr) } data.Banner = bannerFor(err) s.renderPage(w, r, "registry_page", data) } // parseRegistryQuery reads the filter of the registry page and of its chart // requests (?q=&status=&run=&subnet=&direction=&protocol=); a value that is // not valid is dropped. func parseRegistryQuery(r *http.Request) registryQuery { v := r.URL.Query() q := parseSliceFilter(v) q.Q, q.LastResult = strings.TrimSpace(v.Get("q")), v.Get("status") if !containsStr(ipResults, q.LastResult) { q.LastResult = "" } return q } // parseSliceFilter reads the part of the filter that the registry and the // analytics page share (?run=&subnet=&direction=&protocol=): the data slice // of a run, a subnet, and the direction and protocol of the checks. A value // that is not valid is dropped. func parseSliceFilter(v url.Values) registryQuery { var q registryQuery if q.Run, _ = strconv.ParseInt(v.Get("run"), 10, 64); q.Run < 0 { q.Run = 0 } if q.Subnet = strings.TrimSpace(v.Get("subnet")); q.Subnet != "" { if _, err := netip.ParsePrefix(q.Subnet); err != nil { q.Subnet = "" } } if q.Direction = v.Get("direction"); !containsStr(registryDirections, q.Direction) { q.Direction = "" } if q.Protocol = v.Get("protocol"); !containsStr(registryProtocols, q.Protocol) { q.Protocol = "" } return q } // subnetOption is one entry of the "Подсеть" selector. type subnetOption struct { CIDR, Text string Selected bool } // registrySubnetOptions lists the configured subnets for the selector as // "CIDR — label". The chosen subnet is always present and selected: one that is // not configured (a link from analytics, or the list is unavailable) is added // as a separate entry. func registrySubnetOptions(subnets []subnetEntry, chosen string) []subnetOption { var out []subnetOption found := false for _, x := range subnets { text := x.CIDR if x.Label != "" { text += " — " + x.Label } out = append(out, subnetOption{CIDR: x.CIDR, Text: text, Selected: x.CIDR == chosen}) found = found || x.CIDR == chosen } if chosen != "" && !found { out = append(out, subnetOption{CIDR: chosen, Text: chosen + " (нет в списке)", Selected: true}) } return out } // breakdownView is the chart "successful checks per target / site" above the // registry table. With Hint set, it is only a prompt to choose the missing // filter; with Err set, the chart could not be loaded. type breakdownView struct { Title, Scope, Slice, QS string Hint, Err string Addresses int Rows []breakdownRowView } type breakdownRowView struct { Key, Label, Width, Text, Tip string } // registryBreakdown builds the chart for the page's filter: nothing without a // direction and a protocol, then a hint for the missing one. params is the // filter as the page's links carry it; the chart's dialog requests its lists // with the same query string. func (s *Server) registryBreakdown(r *http.Request, q registryQuery, params url.Values) *breakdownView { switch { case q.Direction == "" && q.Protocol == "": return nil case q.Protocol == "": return &breakdownView{Hint: "Выберите протокол, чтобы увидеть распределение по " + breakdownGroupName(q.Direction) + "."} case q.Direction == "": return &breakdownView{Hint: "Выберите направление, чтобы увидеть распределение по целям или площадкам."} } v := &breakdownView{Scope: strings.ToUpper(q.Direction[:1]) + q.Direction[1:] + " " + q.Protocol, QS: params.Encode(), Slice: "последний цикл адреса"} if q.Run > 0 { v.Slice = "запуск " + strconv.FormatInt(q.Run, 10) } if q.Subnet != "" { v.Slice += " · подсеть " + q.Subnet } v.Title = "Успешные проверки по " + breakdownGroupName(q.Direction) b, err := s.CA.GetRegistryBreakdown(r.Context(), q) if err != nil { v.Err = "Не удалось получить распределение: " + err.Error() return v } v.Addresses = b.Addresses most := 0 for _, x := range b.Rows { most = max(most, x.OK) } for _, x := range b.Rows { // control-api sorts them, most successful first width := 0.0 if most > 0 { width = float64(x.OK) * 100 / float64(most) } ok, total := groupThousands(x.OK), groupThousands(x.Total) v.Rows = append(v.Rows, breakdownRowView{ Key: x.Key, Label: x.Label, Width: strconv.FormatFloat(width, 'f', 1, 64), Text: ok + " из " + total + " · " + pct1(x.OK, x.Total) + "%", Tip: x.Label + ": успешно " + ok + " из " + total + " проверок. Нажмите, чтобы открыть список адресов", }) } return v } // breakdownGroupName is what the chart groups the checks of a direction by. func breakdownGroupName(direction string) string { if direction == "ingress" { return "площадкам" } return "целям" } // pct1 is a/b in percent with at most one decimal and a decimal comma: 98,7. func pct1(a, b int) string { if b == 0 { return "0" } s := strconv.FormatFloat(float64(a)*100/float64(b), 'f', 1, 64) return strings.Replace(strings.TrimSuffix(s, ".0"), ".", ",", 1) } // breakdownQuery reads the request of the chart's list proxies: the page's // filter, with direction and protocol and the row's key required. func breakdownQuery(w http.ResponseWriter, r *http.Request) (q registryQuery, key string, ok bool) { q, key = parseRegistryQuery(r), r.URL.Query().Get("key") if q.Direction == "" || q.Protocol == "" || key == "" { http.Error(w, "direction, protocol and key are required", http.StatusBadRequest) return q, key, false } return q, key, true } // handleRegistryBreakdownList proxies the checks behind one row of the chart as JSON. func (s *Server) handleRegistryBreakdownList(w http.ResponseWriter, r *http.Request) { q, key, ok := breakdownQuery(w, r) if !ok { return } out, err := s.CA.GetRegistryBreakdownList(r.Context(), q, key) if err != nil { writeProxyError(w, err) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(out) } // handleRegistryBreakdownCSV proxies the same table as a CSV download. func (s *Server) handleRegistryBreakdownCSV(w http.ResponseWriter, r *http.Request) { q, key, ok := breakdownQuery(w, r) if !ok { return } body, disposition, err := s.CA.GetRegistryBreakdownCSV(r.Context(), q, key) if err != nil { writeProxyError(w, err) return } w.Header().Set("Content-Type", "text/csv; charset=utf-8") if disposition != "" { w.Header().Set("Content-Disposition", disposition) } w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(body) } // registryRunOptions lists the runs for the "Запуск" selector, newest first as // control-api returns them; a finished run without addresses is hidden. The // chosen run is always present and selected, even if the list lacks it (the // history was cleaned up, or the list is unavailable). func registryRunOptions(runs []analyticsRun, chosen int64) []runOption { var out []runOption found := false for _, x := range runs { if x.State == "finalized" && x.Addresses == 0 && x.ID != chosen { continue } out = append(out, runOption{ID: x.ID, Label: runLabel(x), Selected: x.ID == chosen}) found = found || x.ID == chosen } if chosen > 0 && !found { out = append(out, runOption{ID: chosen, Label: "Запуск " + strconv.FormatInt(chosen, 10), Selected: true}) } return out } // handleRegistryDetail shows one address's full retained check history // across every cycle it has ever run, not just the current attempt — see // ip_detail_content in ip_detail.html for the attempt-scoped equivalent. func (s *Server) handleRegistryDetail(w http.ResponseWriter, r *http.Request) { ip := r.PathValue("ip") history, err := s.CA.GetRegistryHistory(r.Context(), ip) data := registryDetailData{History: history} data.ActiveNav = "registry" data.Banner = bannerFor(err) s.renderPage(w, r, "registry_detail_page", data) }