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

+189 -3
View File
@@ -80,6 +80,72 @@ func TestAnalyticsPageShowsOneRun(t *testing.T) {
}
}
// The analytics filters: the subnet goes to control-api with the report and the
// lists, direction and protocol focus the page and, both given, add the chart of
// the registry for the run and subnet; every link of the page keeps the filter.
func TestAnalyticsPageFilters(t *testing.T) {
fake, ts := analyticsFake(t)
fake.subnets = subnetList{Subnets: []subnetEntry{{CIDR: "9.9.9.0/24", Label: "Офис"}, {CIDR: "10.0.0.0/8"}}}
fake.breakdown = registryBreakdown{Group: "target", Direction: "egress", Protocol: "https", Addresses: 5, Rows: []breakdownRow{{Key: "a.test", Label: "a.test", Total: 5, OK: 4}}}
lastReq := func(reqs []string) string { fake.mu.Lock(); defer fake.mu.Unlock(); return reqs[len(reqs)-1] }
page := get(t, ts, "/analytics?run=1&subnet=9.9.9.0/24&direction=egress&protocol=https")
for _, want := range []string{
`<option value="9.9.9.0/24" selected>9.9.9.0/24 — Офис</option>`, `<option value="egress" selected>`, `<option value="https" selected>`,
`"subnet":"9.9.9.0/24"`, `"direction":"egress"`, `"protocol":"https"`, "сбросить фильтры",
`id="registry-breakdown"`, "Успешные проверки по целям · Egress https", `data-slice="запуск 1 · подсеть 9.9.9.0/24"`,
`data-qs="direction=egress&amp;protocol=https&amp;run=1&amp;subnet=9.9.9.0%2F24"`,
// the newer run is one step forward, the comparison is opened for this run; both keep the filter
`href="/analytics?direction=egress&amp;protocol=https&amp;run=2&amp;subnet=9.9.9.0%2F24"`,
`href="/analytics/compare?direction=egress&amp;protocol=https&amp;subnet=9.9.9.0%2F24&amp;target=1"`,
} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in the page, got:\n%s", want, page)
}
}
if got := lastReq(fake.analyticsReqs); !strings.HasSuffix(got, "/runs/1?subnet=9.9.9.0%2F24") {
t.Errorf("the report must be requested for the subnet, got %q", got)
}
req := lastReq(fake.breakdownReqs)
for _, want := range []string{"run=1", "subnet=9.9.9.0%2F24", "direction=egress", "protocol=https"} {
if !strings.Contains(req, want) {
t.Errorf("chart request %q lacks %s", req, want)
}
}
// No chart without both direction and protocol (and no request for it); a
// malformed subnet is dropped and the whole run is shown.
fake.mu.Lock()
fake.breakdownReqs = nil
fake.mu.Unlock()
for _, q := range []string{"run=1", "run=1&direction=ingress", "run=1&protocol=tls&subnet=garbage"} {
page = get(t, ts, "/analytics?"+q)
if strings.Contains(page, `id="registry-breakdown"`) || strings.Contains(page, "Выберите") {
t.Errorf("%s: no chart and no hint expected:\n%s", q, page)
}
}
if len(fake.breakdownReqs) != 0 {
t.Errorf("the chart was requested without direction and protocol: %v", fake.breakdownReqs)
}
if got := lastReq(fake.analyticsReqs); strings.Contains(got, "subnet") {
t.Errorf("a malformed subnet must be dropped, got %q", got)
}
// The lists keep the subnet, a malformed one is passed on for control-api to refuse.
for _, path := range []string{"/analytics/lists/egress_https_any?run=1&subnet=9.9.9.0/24", "/analytics/csv/egress_https_any?run=1&subnet=9.9.9.0/24"} {
if page = get(t, ts, path); page == "" {
t.Fatalf("%s: empty answer", path)
}
if got := lastReq(fake.analyticsReqs); !strings.Contains(got, "subnet=9.9.9.0%2F24") {
t.Errorf("%s: control-api request %q lacks the subnet", path, got)
}
}
get(t, ts, "/analytics/lists/egress_https_any?run=1&subnet=bad")
if got := lastReq(fake.analyticsReqs); !strings.Contains(got, "subnet=bad") {
t.Errorf("a malformed subnet must reach control-api, got %q", got)
}
}
func TestAnalyticsPageWithoutRunsAndWithUnknownRun(t *testing.T) {
_, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
@@ -389,7 +455,7 @@ func TestSettingsSubnetsForm(t *testing.T) {
}
// The drill-down from the analytics page: run and subnet go to control-api,
// come back as hidden fields and a visible chip; a malformed subnet is dropped.
// come back in the filter fields and the reset link; a malformed subnet is dropped.
func TestRegistryDrillDownFromAnalytics(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
@@ -402,7 +468,8 @@ func TestRegistryDrillDownFromAnalytics(t *testing.T) {
if !strings.Contains(last, "run=2") || !strings.Contains(last, "subnet=10.0.0.0%2F24") {
t.Fatalf("control-api request %q lacks the run or subnet", last)
}
for _, want := range []string{`name="run" value="2"`, `name="subnet" value="10.0.0.0/24"`, "запуск 2", "подсеть 10.0.0.0/24", "сбросить фильтр"} {
for _, want := range []string{`<option value="2" selected>`, `<option value="10.0.0.0/24" selected>10.0.0.0/24 (нет в списке)</option>`,
`href="/analytics?run=2&amp;subnet=10.0.0.0%2F24"`, "сбросить фильтры"} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in:\n%s", want, page)
}
@@ -410,7 +477,7 @@ func TestRegistryDrillDownFromAnalytics(t *testing.T) {
page = get(t, ts, "/registry?subnet=garbage")
last = fake.registryQueries[len(fake.registryQueries)-1]
if strings.Contains(last, "subnet=") || strings.Contains(page, "сбросить фильтр") {
if strings.Contains(last, "subnet=") || strings.Contains(page, `garbage`) {
t.Fatalf("a malformed subnet must be ignored: %q", last)
}
}
@@ -426,3 +493,122 @@ func TestAnalyticsCompareListPath(t *testing.T) {
t.Errorf("path = %q", got)
}
}
// The "Подсеть" selector: configured subnets as "CIDR — label", disabled with a
// link to /settings when there are none.
func TestRegistrySubnetSelect(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
page := get(t, ts, "/registry")
for _, want := range []string{`<select id="registry-subnet" name="subnet" disabled`, `<option value="">Все подсети</option>`, `href="/settings">добавить в настройках`} {
if !strings.Contains(page, want) {
t.Fatalf("no subnets configured: expected %q in:\n%s", want, page)
}
}
fake.subnets = subnetList{Subnets: []subnetEntry{{CIDR: "9.9.9.0/24", Label: "Офис"}, {CIDR: "10.0.0.0/8"}}}
page = get(t, ts, "/registry?subnet=10.0.0.0/8")
for _, want := range []string{`<option value="9.9.9.0/24" >9.9.9.0/24 — Офис</option>`, `<option value="10.0.0.0/8" selected>10.0.0.0/8</option>`} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in:\n%s", want, page)
}
}
if strings.Contains(page, "disabled") || strings.Contains(page, "нет в списке") {
t.Fatalf("a configured subnet must neither disable the selector nor be added again:\n%s", page)
}
}
// The chart above the registry table: only with a direction and a protocol (a
// hint for the missing one), rows as control-api sorted them, an error inside
// the block that leaves the table in place; the list proxies forward the filter.
func TestRegistryBreakdownChartAndProxy(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
fake.registry["9.9.9.9"] = registryItem{IPAddress: "9.9.9.9", FirstSeenAt: now, LastSeenAt: now}
fake.breakdown = registryBreakdown{Group: "site", Direction: "ingress", Protocol: "tls", Addresses: 1, Rows: []breakdownRow{
{Key: "inbound-site-2", Label: "rxspb", Total: 1250, OK: 1234}, {Key: "inbound-site-1", Label: "rxmsk", Total: 617, OK: 617},
}}
ts := newTestServer(t, caURL)
page := get(t, ts, "/registry")
if strings.Contains(page, `id="registry-breakdown"`) || strings.Contains(page, "Выберите") {
t.Fatalf("no chart and no hint without filters:\n%s", page)
}
page = get(t, ts, "/registry?direction=ingress")
if !strings.Contains(page, "Выберите протокол, чтобы увидеть распределение по площадкам") || strings.Contains(page, `id="registry-breakdown"`) {
t.Fatalf("a direction alone gives a hint:\n%s", page)
}
page = get(t, ts, "/registry?direction=ingress&protocol=tls&run=3&subnet=9.9.9.0/24")
for _, want := range []string{
"Успешные проверки по площадкам · Ingress tls", `data-bd-key="inbound-site-2"`, `data-bd-label="rxspb"`,
"1\u00a0234 из 1\u00a0250 · 98,7%", "617 из 617 · 100%", `style="width:100.0%"`, `style="width:50.0%"`,
`data-slice="запуск 3 · подсеть 9.9.9.0/24"`, `data-qs="direction=ingress&amp;protocol=tls&amp;run=3&amp;subnet=9.9.9.0%2F24"`, "Адресов под фильтром: 1",
"9.9.9.9", // the table is still there
} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in:\n%s", want, page)
}
}
if strings.Index(page, "rxspb") > strings.Index(page, "rxmsk") {
t.Fatalf("rows must keep the order control-api gave:\n%s", page)
}
fake.mu.Lock()
req := fake.breakdownReqs[len(fake.breakdownReqs)-1]
fake.mu.Unlock()
for _, want := range []string{"direction=ingress", "protocol=tls", "run=3", "subnet=9.9.9.0%2F24"} {
if !strings.Contains(req, want) {
t.Fatalf("breakdown request %q lacks %s", req, want)
}
}
fake.breakdown.Rows = nil
if page = get(t, ts, "/registry?direction=egress&protocol=tls"); !strings.Contains(page, "Для этого сочетания проверок нет") {
t.Fatalf("an empty chart says so:\n%s", page)
}
fake.breakdownStatus = http.StatusInternalServerError
page = get(t, ts, "/registry?direction=egress&protocol=https")
if !strings.Contains(page, "Не удалось получить распределение") || !strings.Contains(page, "9.9.9.9") {
t.Fatalf("a chart error is shown in its block and the table stays:\n%s", page)
}
// The list proxies: filter and key reach control-api; the filter is checked.
fake.breakdownList = `{"columns":["Адрес"],"rows":[["1.2.3.4"]]}`
for path, want := range map[string]int{
"/registry/breakdown/list?direction=ingress&protocol=tls&key=inbound-site-1&run=3&status=fail": http.StatusOK,
"/registry/breakdown/csv?direction=ingress&protocol=tls&key=inbound-site-1": http.StatusOK,
"/registry/breakdown/list?direction=ingress&key=inbound-site-1": http.StatusBadRequest, // no protocol
"/registry/breakdown/csv?direction=ingress&protocol=tls": http.StatusBadRequest, // no key
} {
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != want {
t.Fatalf("%s: %d, want %d", path, resp.StatusCode, want)
}
if strings.Contains(path, "/csv") && want == http.StatusOK &&
(!strings.HasPrefix(resp.Header.Get("Content-Type"), "text/csv") || !strings.Contains(resp.Header.Get("Content-Disposition"), "registry_ingress_tls_rxmsk.csv")) {
t.Fatalf("csv: %v %s", resp.Header, body)
}
if strings.Contains(path, "/list") && want == http.StatusOK && !strings.Contains(string(body), `"1.2.3.4"`) {
t.Fatalf("list: %s", body)
}
}
fake.mu.Lock()
var list string
for _, r := range fake.breakdownReqs {
if strings.Contains(r, "/breakdown/list") && !strings.Contains(r, "format=csv") {
list = r
}
}
fake.mu.Unlock()
for _, want := range []string{"key=inbound-site-1", "run=3", "last_result=fail", "direction=ingress", "protocol=tls"} {
if !strings.Contains(list, want) {
t.Fatalf("list request %q lacks %s", list, want)
}
}
}