Files
cloud-ip-validator/internal/dashboard/handlers_registry.go
T
ayurishchevandClaude Sonnet 5.5 ded196ec8d Registry and Analytics: run, subnet, direction and protocol filters, successes-by-target chart
Registry (/registry):
- filters by run (slice by the address's cycle in that run), subnet
  (drop-down of configured subnets), direction (egress/ingress) and
  protocol (icmp, tcp, ssh, https, tls); status in scope is computed over
  the narrowed checks
- chart "successful checks per target (egress) / site (ingress)" when both
  direction and protocol are chosen; a row opens the list of addresses
  (dialog, CSV)
- API: direction/protocol parameters and run in GET /admin/registry,
  GET /admin/registry/breakdown and /breakdown/list
- subnet filter passes ids as one JSON parameter (SQLite variable limit)

Analytics (/analytics):
- subnet filter recomputes the whole page over the addresses of the run
  inside the subnet; only their checks are read; cache per run and subnet
- direction and protocol focus the page; with both set the registry chart
  is shown
- subnet parameter in GET /admin/analytics/runs/{id} and lists (JSON, CSV)

Docs: plans and summaries in docs/changes, README, API, USAGE.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:23:48 +03:00

347 lines
12 KiB
Go

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)
}