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

+48
View File
@@ -1,6 +1,7 @@
package dashboard
import (
"fmt"
"reflect"
"strings"
"testing"
@@ -632,6 +633,53 @@ func TestRegistryPageAndDetail(t *testing.T) {
if !strings.Contains(notFound, "alert-warning") {
t.Fatalf("expected client error banner for unknown registry address, got:\n%s", notFound)
}
// The four filters: the run and subnet choices come from control-api, the
// values reach it and the pager, unknown direction/protocol are dropped, and
// with a direction/protocol only the levels that have checks are shown.
fake.runs = []analyticsRun{fakeRun(2, "open", 120, 40), fakeRun(1, "finalized", 900, 300)}
fake.subnets = subnetList{Subnets: []subnetEntry{{CIDR: "9.9.9.0/24", Label: "Офис"}}}
fake.registry["9.9.9.8"] = registryItem{
IPAddress: "9.9.9.8", FirstSeenAt: now, LastSeenAt: now, TotalCycles: 1, LastResult: "pass",
LastCycleID: 1, Ingress: levelResult{Total: 1, OK: 1, ByType: []typeStat{{"tls", 1, 1}}},
}
for i := 0; i < 30; i++ { // a second page for the pager
ip := fmt.Sprintf("9.9.9.%d", 100+i)
fake.registry[ip] = registryItem{IPAddress: ip, FirstSeenAt: now, LastSeenAt: now, TotalCycles: 1, LastResult: "pass",
LastCycleID: 1, Ingress: levelResult{Total: 1, OK: 1, ByType: []typeStat{{"tls", 1, 1}}}}
}
page = get(t, ts, "/registry?run=1&subnet=9.9.9.0/24&direction=ingress&protocol=tls&per_page=25")
fake.mu.Lock()
last := fake.registryQueries[len(fake.registryQueries)-1]
fake.mu.Unlock()
for _, want := range []string{"run=1", "subnet=9.9.9.0%2F24", "direction=ingress", "protocol=tls"} {
if !strings.Contains(last, want) {
t.Fatalf("control-api request %q lacks %s", last, want)
}
}
for _, want := range []string{
`<option value="1" selected>`, `<option value="2" >`, `<option value="9.9.9.0/24" selected>9.9.9.0/24 — Офис</option>`,
`value="ingress" selected`, `value="tls" selected`, "Результат в запуске 1", `level-name">Ingress`,
} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in the filtered registry page, got:\n%s", want, page)
}
}
if strings.Contains(page, `level-name">Egress`) {
t.Fatalf("the Egress level must be hidden when only ingress checks are selected, got:\n%s", page)
}
next := pagerLink(t, page, "next")
if next == nil || next.Query().Get("run") != "1" || next.Query().Get("subnet") != "9.9.9.0/24" ||
next.Query().Get("direction") != "ingress" || next.Query().Get("protocol") != "tls" {
t.Fatalf("pager link lost the filters: %v", next)
}
get(t, ts, "/registry?direction=sideways&protocol=udp")
fake.mu.Lock()
last = fake.registryQueries[len(fake.registryQueries)-1]
fake.mu.Unlock()
if strings.Contains(last, "direction=") || strings.Contains(last, "protocol=") {
t.Fatalf("unknown direction/protocol must be dropped: %q", last)
}
}
// TestRegistryPageShowsEgressIngressLevels proves the list shows, under the