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>
This commit is contained in:
ayurishchevandClaude Sonnet 5.5 committed 2026-10-06 14:23:48 +03:00
1 parent 068c10ea1c
commit ded196ec8d
40 files changed
+2545 -188

No files matched your search

+68 -9
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"html/template"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -24,6 +25,17 @@ type analyticsPageData struct {
RunID int64
PrevURL string // older run, "" when there is none
NextURL string // newer run
// CompareURL is the comparison page with this run as the target.
CompareURL string
// The filter of the page: a subnet recomputes every block, a direction
// and a protocol focus the page on them (and give the chart).
Subnet string
Direction string
Protocol string
Protocols []string
SubnetOptions []subnetOption
Filtered bool // a subnet, direction or protocol is chosen
Breakdown *breakdownView // chart of the direction and protocol; nil without both
// DataJSON is the page's data for analytics.js (run meta, labels, report),
// HTML-safe JSON.
DataJSON template.JS
@@ -40,12 +52,25 @@ type analyticsMeta struct {
Rechecked int `json:"rechecked"`
ListURL string `json:"list_url"`
CSVURL string `json:"csv_url"`
Registry string `json:"registry_url"`
Registry string `json:"registry_url"` // registry of the run with the page's direction and protocol; a link adds the subnet
Subnet string `json:"subnet"`
Direction string `json:"direction"`
Protocol string `json:"protocol"`
}
// analyticsURL is the analytics page of a run with the slice filter f (subnet,
// direction, protocol) of the page that links to it.
func analyticsURL(run int64, f registryQuery) string {
v := f.filter()
v.Set("run", strconv.FormatInt(run, 10))
return "/analytics?" + v.Encode()
}
// handleAnalyticsPage renders the analytics of one finished run: ?run=ID, by
// default the newest finished run. The run selector lists every run, the open
// one disabled; nothing of any other run is on the page.
// one disabled; nothing of any other run is on the page. ?subnet= narrows every
// block to the addresses of that subnet; ?direction=&protocol= focus the page
// (analytics.js) and, both given, add the chart of the registry.
func (s *Server) handleAnalyticsPage(w http.ResponseWriter, r *http.Request) {
data := analyticsPageData{}
data.ActiveNav = "analytics"
@@ -56,7 +81,16 @@ func (s *Server) handleAnalyticsPage(w http.ResponseWriter, r *http.Request) {
s.renderPage(w, r, "analytics_page", data)
return
}
want, _ := strconv.ParseInt(r.URL.Query().Get("run"), 10, 64)
f := parseSliceFilter(r.URL.Query())
want := f.Run
f.Run = 0 // the links below name the run themselves
// the subnet choices come from the configured list; without it the filter
// still works, just without the choices
subnets, subnetsErr := s.CA.GetSubnets(r.Context())
data.Subnet, data.Direction, data.Protocol = f.Subnet, f.Direction, f.Protocol
data.Protocols = registryProtocols
data.SubnetOptions = registrySubnetOptions(subnets.Subnets, f.Subnet)
data.Filtered = f.Subnet != "" || f.Direction != "" || f.Protocol != ""
var chosen *analyticsRun
for i := range runs { // newest first
if runs[i].State != "finalized" || runs[i].Addresses == 0 {
@@ -94,15 +128,16 @@ func (s *Server) handleAnalyticsPage(w http.ResponseWriter, r *http.Request) {
for i, x := range finished {
if x.ID == chosen.ID {
if i+1 < len(finished) {
data.PrevURL = "/analytics?run=" + strconv.FormatInt(finished[i+1].ID, 10)
data.PrevURL = analyticsURL(finished[i+1].ID, f)
}
if i > 0 {
data.NextURL = "/analytics?run=" + strconv.FormatInt(finished[i-1].ID, 10)
data.NextURL = analyticsURL(finished[i-1].ID, f)
}
}
}
data.CompareURL = compareURL(chosen.ID, f)
report, err := s.CA.GetAnalyticsReport(r.Context(), chosen.ID)
report, err := s.CA.GetAnalyticsReport(r.Context(), chosen.ID, f.Subnet)
if err != nil {
data.Banner = bannerFor(err)
s.renderPage(w, r, "analytics_page", data)
@@ -120,7 +155,12 @@ func (s *Server) handleAnalyticsPage(w http.ResponseWriter, r *http.Request) {
Start: fmtShort(chosen.StartedAt), Duration: "—", Rechecked: info.Run.Rechecked,
ListURL: "/analytics/lists/",
CSVURL: "/analytics/csv/",
Registry: "/registry?run=" + id,
Registry: "/registry?run=" + id, Subnet: f.Subnet, Direction: f.Direction, Protocol: f.Protocol,
}
for _, p := range [][2]string{{"direction", f.Direction}, {"protocol", f.Protocol}} {
if p[1] != "" {
meta.Registry += "&" + p[0] + "=" + url.QueryEscape(p[1])
}
}
if chosen.FinalizedAt != nil {
meta.End = fmtShort(*chosen.FinalizedAt)
@@ -137,14 +177,33 @@ func (s *Server) handleAnalyticsPage(w http.ResponseWriter, r *http.Request) {
}
data.HasRun = true
data.DataJSON = template.JS(payload)
if f.Direction != "" && f.Protocol != "" {
f.Run = chosen.ID
data.Breakdown = s.registryBreakdown(r, f, f.filter())
}
data.Banner = bannerFor(subnetsErr)
s.renderPage(w, r, "analytics_page", data)
}
// compareURL is the comparison page with a run as the target; it carries the
// slice filter along, as the other links of the page do.
func compareURL(target int64, f registryQuery) string {
v := f.filter()
v.Set("target", strconv.FormatInt(target, 10))
return "/analytics/compare?" + v.Encode()
}
func parseRunParam(r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.URL.Query().Get("run"), 10, 64)
return id, err == nil && id > 0
}
// analyticsSubnetParam is the ?subnet= of a list request as it came: control-api
// validates it, so a malformed one is an error and never a silently wider list.
func analyticsSubnetParam(r *http.Request) string {
return strings.TrimSpace(r.URL.Query().Get("subnet"))
}
// handleAnalyticsList proxies one address table of a run as JSON.
func (s *Server) handleAnalyticsList(w http.ResponseWriter, r *http.Request) {
id, ok := parseRunParam(r)
@@ -152,7 +211,7 @@ func (s *Server) handleAnalyticsList(w http.ResponseWriter, r *http.Request) {
http.Error(w, "run is required", http.StatusBadRequest)
return
}
out, err := s.CA.GetAnalyticsList(r.Context(), id, r.PathValue("kind"), r.URL.Query().Get("class"))
out, err := s.CA.GetAnalyticsList(r.Context(), id, r.PathValue("kind"), r.URL.Query().Get("class"), analyticsSubnetParam(r))
if err != nil {
writeProxyError(w, err)
return
@@ -169,7 +228,7 @@ func (s *Server) handleAnalyticsCSV(w http.ResponseWriter, r *http.Request) {
http.Error(w, "run is required", http.StatusBadRequest)
return
}
body, disposition, err := s.CA.GetAnalyticsListCSV(r.Context(), id, r.PathValue("kind"), r.URL.Query().Get("class"))
body, disposition, err := s.CA.GetAnalyticsListCSV(r.Context(), id, r.PathValue("kind"), r.URL.Query().Get("class"), analyticsSubnetParam(r))
if err != nil {
writeProxyError(w, err)
return