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:
1 parent
068c10ea1c
commit
ded196ec8d
40 files changed
+2545
-188
No files matched your search
@@ -22,6 +22,9 @@ type Input struct {
|
||||
Subnets []db.Subnet
|
||||
SiteNames map[int]string // site index -> site id
|
||||
Rechecked int // addresses with more than one cycle in the run
|
||||
// Subnet narrows the report to the addresses of the run inside it, nested
|
||||
// subnets included; the zero prefix means the whole run.
|
||||
Subnet netip.Prefix
|
||||
// Each feeds every check of the run's result cycles to fn.
|
||||
Each func(fn func(db.RunCheck)) error
|
||||
}
|
||||
@@ -29,6 +32,7 @@ type Input struct {
|
||||
// Report is the data of the analytics page for one run.
|
||||
type Report struct {
|
||||
Run RunInfo `json:"run"`
|
||||
Scope *Scope `json:"scope,omitempty"` // set when the report is narrowed to a subnet
|
||||
Summary Summary `json:"summary"`
|
||||
Reasons []Reason `json:"reasons"`
|
||||
Quality Quality `json:"quality"`
|
||||
@@ -50,6 +54,14 @@ type RunInfo struct {
|
||||
Rechecked int `json:"rechecked"`
|
||||
}
|
||||
|
||||
// Scope says which part of the run a narrowed report covers: Addresses of
|
||||
// Summary are those inside Subnet, RunAddresses all of the run (cancelled
|
||||
// ones excluded in both).
|
||||
type Scope struct {
|
||||
Subnet string `json:"subnet"`
|
||||
RunAddresses int `json:"run_addresses"`
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
Addresses int `json:"addresses"`
|
||||
Pass int `json:"pass"`
|
||||
@@ -176,7 +188,17 @@ func Compute(in Input) (*Analysis, error) {
|
||||
byReg := make(map[int64]*addr, len(in.Results))
|
||||
var addrs []*addr
|
||||
subnetOf := newSubnetMatcher(in.Subnets)
|
||||
runAddrs := 0
|
||||
for _, r := range in.Results {
|
||||
if r.Verdict != db.ResultCancelled {
|
||||
runAddrs++
|
||||
}
|
||||
if in.Subnet.IsValid() {
|
||||
// checks of an address outside the subnet find no entry in byReg below
|
||||
if ip, err := netip.ParseAddr(r.IPAddress); err != nil || !in.Subnet.Contains(ip) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
a := &addr{res: r, subnet: subnetOf(r.IPAddress), failedTargets: map[string]bool{}}
|
||||
a.ssh.errs = map[string]bool{}
|
||||
byReg[r.RegistryID] = a
|
||||
@@ -279,6 +301,9 @@ func Compute(in Input) (*Analysis, error) {
|
||||
rep := Report{Matrix: map[string][]MatrixRow{}}
|
||||
rep.Run = RunInfo{ID: in.Run.ID, Kind: in.Run.Kind, State: in.Run.State, StartedAt: in.Run.StartedAt,
|
||||
FinalizedAt: in.Run.FinalizedAt, Rechecked: in.Rechecked}
|
||||
if in.Subnet.IsValid() {
|
||||
rep.Scope = &Scope{Subnet: in.Subnet.Masked().String(), RunAddresses: runAddrs}
|
||||
}
|
||||
if in.Run.FinalizedAt != nil {
|
||||
rep.Run.DurationSec = int(in.Run.FinalizedAt.Sub(in.Run.StartedAt).Seconds())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -30,12 +31,18 @@ func (f *fixture) check(reg int64, source, typ, target string, ok bool, validato
|
||||
}
|
||||
|
||||
func (f *fixture) compute(t *testing.T, subnets []db.Subnet) *Analysis {
|
||||
t.Helper()
|
||||
return f.computeIn(t, subnets, netip.Prefix{})
|
||||
}
|
||||
|
||||
func (f *fixture) computeIn(t *testing.T, subnets []db.Subnet, subnet netip.Prefix) *Analysis {
|
||||
t.Helper()
|
||||
end := t0.Add(10 * time.Minute)
|
||||
an, err := Compute(Input{
|
||||
Run: db.CheckRun{ID: 7, Kind: db.RunManual, State: db.RunFinalized, StartedAt: t0, FinalizedAt: &end},
|
||||
Results: f.results,
|
||||
Subnets: subnets,
|
||||
Subnet: subnet,
|
||||
SiteNames: map[int]string{1: "rxmsk", 2: "rxyc"},
|
||||
Each: func(fn func(db.RunCheck)) error {
|
||||
for _, c := range f.checks {
|
||||
@@ -153,6 +160,57 @@ func TestComputeCountsFactsPerAddress(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeNarrowedToSubnet: with Input.Subnet every block and every list is
|
||||
// built from the addresses inside it (nested subnets included) and nothing else.
|
||||
func TestComputeNarrowedToSubnet(t *testing.T) {
|
||||
f := &fixture{}
|
||||
f.addr(1, "10.0.0.1", db.ResultPass, 3)
|
||||
f.addr(2, "10.0.0.2", db.ResultPartial, 3)
|
||||
f.addr(3, "10.0.1.1", db.ResultPartial, 3)
|
||||
f.addr(4, "10.0.1.200", db.ResultCancelled, 3)
|
||||
for reg := int64(1); reg <= 4; reg++ {
|
||||
f.check(reg, eg, "https", "https://a.test", reg != 2, "vkiplab-v1", "", false)
|
||||
f.check(reg, s1, "icmp", "ip", true, "vkiplab-v1", "", false)
|
||||
f.check(reg, s1, "ssh", "ip", reg != 3, "vkiplab-v1", "dial tcp: i/o timeout", false)
|
||||
}
|
||||
subnets := []db.Subnet{{CIDR: "10.0.0.0/24"}, {CIDR: "10.0.1.0/24"}}
|
||||
|
||||
all := f.compute(t, subnets).Report
|
||||
if all.Scope != nil || all.Summary.Addresses != 3 || len(all.Subnets) != 2 {
|
||||
t.Fatalf("without a subnet the whole run is reported: %+v %+v", all.Scope, all.Summary)
|
||||
}
|
||||
|
||||
// 10.0.0.0/23 holds both configured subnets: the same numbers as the whole run.
|
||||
if wide := f.computeIn(t, subnets, netip.MustParsePrefix("10.0.0.0/23")).Report; wide.Summary != all.Summary || len(wide.Subnets) != 2 {
|
||||
t.Errorf("a wider subnet changes nothing: %+v", wide.Summary)
|
||||
}
|
||||
|
||||
an := f.computeIn(t, subnets, netip.MustParsePrefix("10.0.1.0/24"))
|
||||
r := an.Report
|
||||
if r.Scope == nil || r.Scope.Subnet != "10.0.1.0/24" || r.Scope.RunAddresses != 3 {
|
||||
t.Errorf("scope = %+v", r.Scope)
|
||||
}
|
||||
if r.Summary.Addresses != 1 || r.Summary.Partial != 1 || r.Summary.Pass != 0 || r.Summary.Cancelled != 1 || r.Summary.IngressSSHAny != 1 || r.Summary.EgressHTTPSAny != 0 {
|
||||
t.Errorf("summary = %+v", r.Summary)
|
||||
}
|
||||
if len(r.Subnets) != 1 || r.Subnets[0].CIDR != "10.0.1.0/24" || r.Subnets[0].Addresses != 1 {
|
||||
t.Errorf("subnets = %+v", r.Subnets)
|
||||
}
|
||||
if !reflect.DeepEqual(r.Targets.Failed["https"], []int{0}) || len(r.Sites.Rows) != 1 || r.Sites.Rows[0].Stats[1] != (SiteStat{Total: 1, OK: 0}) {
|
||||
t.Errorf("targets %+v, sites %+v", r.Targets, r.Sites)
|
||||
}
|
||||
// the lists are cut from the same subset, so they agree with the tiles
|
||||
for kind, want := range map[string]int{ListVerdictPartial: r.Summary.Partial, ListVerdictPass: 0, ListIngressSSHAny: r.Summary.IngressSSHAny, ListEgressHTTPSAny: 0} {
|
||||
l, err := an.List(kind, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(l.Rows) != want {
|
||||
t.Errorf("%s: %d rows, want %d", kind, len(l.Rows), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubnetMatching(t *testing.T) {
|
||||
in := []db.Subnet{{CIDR: "10.0.0.0/8"}, {CIDR: "10.1.0.0/16"}}
|
||||
m := newSubnetMatcher(in)
|
||||
|
||||
@@ -2,12 +2,14 @@ package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
|
||||
"cloudipvalidator/internal/db"
|
||||
)
|
||||
|
||||
// Load reads a finished run from the database and computes its analysis.
|
||||
func Load(ctx context.Context, d *db.DB, runID int64) (*Analysis, error) {
|
||||
// Load reads a finished run from the database and computes its analysis,
|
||||
// narrowed to subnet unless that is the zero prefix.
|
||||
func Load(ctx context.Context, d *db.DB, runID int64, subnet netip.Prefix) (*Analysis, error) {
|
||||
run, err := d.GetRun(ctx, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -32,8 +34,19 @@ func Load(ctx context.Context, d *db.DB, runID int64) (*Analysis, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// With a subnet only the checks of its addresses are read; Compute narrows
|
||||
// the results by the same rule.
|
||||
var ids []int64
|
||||
if subnet.IsValid() {
|
||||
ids = []int64{}
|
||||
for _, r := range results {
|
||||
if ip, err := netip.ParseAddr(r.IPAddress); err == nil && subnet.Contains(ip) {
|
||||
ids = append(ids, r.RegistryID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Compute(Input{
|
||||
Run: *run, Results: results, Subnets: subnets, SiteNames: names, Rechecked: rechecked,
|
||||
Each: func(fn func(db.RunCheck)) error { return d.EachRunCheck(ctx, runID, fn) },
|
||||
Run: *run, Results: results, Subnets: subnets, SiteNames: names, Rechecked: rechecked, Subnet: subnet,
|
||||
Each: func(fn func(db.RunCheck)) error { return d.EachRunCheck(ctx, runID, ids, fn) },
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user