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

+140
View File
@@ -3,8 +3,12 @@ package httpapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"reflect"
"strconv"
"strings"
"testing"
@@ -355,6 +359,82 @@ func TestAnalyticsCacheFollowsData(t *testing.T) {
}
}
// ?subnet= narrows the report and the lists to the addresses inside it, a
// malformed CIDR is a 400, and the cache keeps the subnets apart and bounded.
func TestAnalyticsSubnetFilter(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t)
id := finishedRun(t, d) // 9.9.9.1 passes, 9.9.9.2 is partial
base := "/api/v1/admin/analytics/runs/" + itoa64(id)
report := func(subnet string) (addresses, partial int, scope string) {
resp, body := fc.do(http.MethodGet, base+"?subnet="+url.QueryEscape(subnet), nil)
var rep struct {
Summary struct{ Addresses, Partial int } `json:"summary"`
Scope *struct {
Subnet string `json:"subnet"`
RunAddresses int `json:"run_addresses"`
} `json:"scope"`
}
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &rep) != nil {
t.Fatalf("report %q: %d %s", subnet, resp.StatusCode, body)
}
if rep.Scope != nil {
scope = fmt.Sprintf("%s/%d", rep.Scope.Subnet, rep.Scope.RunAddresses)
}
return rep.Summary.Addresses, rep.Summary.Partial, scope
}
for _, c := range []struct {
subnet string
addrs, partial int
scope string
}{
{"9.9.9.2/32", 1, 1, "9.9.9.2/32/2"},
{"9.9.9.1/32", 1, 0, "9.9.9.1/32/2"},
{"9.9.9.0/24", 2, 1, "9.9.9.0/24/2"},
{"9.9.9.77/24", 2, 1, "9.9.9.0/24/2"}, // host bits are masked
{"10.0.0.0/8", 0, 0, "10.0.0.0/8/2"},
{"", 2, 1, ""},
{"9.9.9.2/32", 1, 1, "9.9.9.2/32/2"}, // the first subnet again: not mixed up with the others
} {
if a, p, sc := report(c.subnet); a != c.addrs || p != c.partial || sc != c.scope {
t.Errorf("subnet %q: addresses %d, partial %d, scope %q; want %d, %d, %q", c.subnet, a, p, sc, c.addrs, c.partial, c.scope)
}
}
var l struct {
Rows [][]string `json:"rows"`
}
resp, body := fc.do(http.MethodGet, base+"/lists/verdict_partial?subnet=9.9.9.2/32", nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.2" {
t.Fatalf("list in the subnet: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, base+"/lists/verdict_partial?subnet=9.9.9.1/32", nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 0 {
t.Fatalf("list outside the subnet: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, base+"/lists/verdict_partial?format=csv&subnet=9.9.9.1/32", nil)
if resp.StatusCode != http.StatusOK || strings.Contains(string(body), "9.9.9.2") {
t.Fatalf("csv outside the subnet: %d %q", resp.StatusCode, body)
}
for _, path := range []string{base + "?subnet=nonsense", base + "/lists/verdict_pass?subnet=9.9.9.0", base + "/lists/verdict_pass?subnet=9.9.9.0/33"} {
if resp, body := fc.do(http.MethodGet, path, nil); resp.StatusCode != http.StatusBadRequest {
t.Errorf("%s: %d %s, want 400", path, resp.StatusCode, body)
}
}
// The cache holds a bounded number of entries.
s := &Server{DB: d}
for i := 0; i < analyticsCacheMax+5; i++ {
rec := httptest.NewRecorder()
subnet := netip.PrefixFrom(netip.AddrFrom4([4]byte{9, 9, byte(i), 0}), 24)
if an := s.analysisByID(rec, httptest.NewRequest(http.MethodGet, "/", nil), id, subnet); an == nil {
t.Fatalf("analysis of %s: %d %s", subnet, rec.Code, rec.Body)
}
}
if n := len(s.analytics.entries); n != analyticsCacheMax {
t.Errorf("cache entries = %d, want %d", n, analyticsCacheMax)
}
}
func TestSubnetsConfigEndpoints(t *testing.T) {
fc, _, _, _ := newConfigTestHarness(t)
resp, body := fc.do(http.MethodPut, "/api/v1/admin/config/subnets", subnetsDTO{Subnets: []subnetDTO{{CIDR: " 10.1.2.3/24 ", Label: "a"}, {CIDR: "10.0.0.0/8"}}})
@@ -407,3 +487,63 @@ func TestRegistryRunAndSubnetFilters(t *testing.T) {
}
func itoa64(n int64) string { return strconv.FormatInt(n, 10) }
// The breakdown endpoints: 400 without direction and protocol, the shape of the
// chart rows (site names, run slice), the list behind a row as JSON and CSV, 404
// for an unknown key.
func TestRegistryBreakdownEndpoints(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t)
fc.do(http.MethodPut, "/api/v1/admin/config/sites/1", putSiteRequest{SiteID: "rxmsk"})
id := finishedRun(t, d)
const base = "/api/v1/admin/registry/breakdown"
for _, bad := range []string{"", "?direction=egress", "?protocol=ssh", "?direction=up&protocol=ssh", "?direction=egress&protocol=dns"} {
if resp, _ := fc.do(http.MethodGet, base+bad, nil); resp.StatusCode != http.StatusBadRequest {
t.Errorf("%q: %d, want 400", bad, resp.StatusCode)
}
}
if resp, _ := fc.do(http.MethodGet, base+"/list?direction=ingress&protocol=ssh", nil); resp.StatusCode != http.StatusBadRequest {
t.Errorf("list without key: %d, want 400", resp.StatusCode)
}
resp, body := fc.do(http.MethodGet, base+"?direction=ingress&protocol=ssh&run="+itoa64(id), nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("breakdown: %d %s", resp.StatusCode, body)
}
var b registryBreakdownDTO
if err := json.Unmarshal(body, &b); err != nil {
t.Fatal(err)
}
want := registryBreakdownDTO{Group: "site", Direction: "ingress", Protocol: "ssh", Run: id, Addresses: 2,
Rows: []breakdownRowDTO{{Key: "inbound-site-1", Label: "rxmsk", Total: 2, OK: 1}}}
if !reflect.DeepEqual(b, want) {
t.Errorf("breakdown = %+v, want %+v", b, want)
}
resp, body = fc.do(http.MethodGet, base+"?direction=egress&protocol=https", nil)
if err := json.Unmarshal(body, &b); err != nil || resp.StatusCode != http.StatusOK || b.Group != "target" ||
len(b.Rows) != 1 || b.Rows[0].Key != "https://a.test" || b.Rows[0].Label != "a.test" || b.Rows[0].OK != 2 {
t.Errorf("egress breakdown: %d %s", resp.StatusCode, body)
}
listURL := base + "/list?direction=ingress&protocol=ssh&run=" + itoa64(id) + "&key=inbound-site-1"
resp, body = fc.do(http.MethodGet, listURL, nil)
var l struct {
Columns []string `json:"columns"`
Rows [][]string `json:"rows"`
}
if err := json.Unmarshal(body, &l); err != nil || resp.StatusCode != http.StatusOK || len(l.Rows) != 2 || len(l.Rows[0]) != len(l.Columns) {
t.Fatalf("list: %d %s", resp.StatusCode, body)
}
if r := l.Rows[0]; r[0] != "9.9.9.2" || r[1] != "провал" || r[3] != "rxmsk" || r[5] != "dial tcp: i/o timeout" {
t.Errorf("failure must come first: %v", r)
}
resp, body = fc.do(http.MethodGet, listURL+"&format=csv", nil)
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/csv") ||
!strings.Contains(resp.Header.Get("Content-Disposition"), "registry_ingress_ssh_rxmsk.csv") || !strings.Contains(string(body), "9.9.9.2") {
t.Errorf("csv: %d %v %s", resp.StatusCode, resp.Header, body)
}
if resp, _ := fc.do(http.MethodGet, base+"/list?direction=ingress&protocol=ssh&key=inbound-site-9", nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("unknown key: %d, want 404", resp.StatusCode)
}
}