Add the Analytics section: check runs, analytics API and page
Runs (migration 0011): a run groups the cycles of one launch. It opens when an address enters an idle queue, takes everything submitted or re-checked while it is open and is finalized when all its addresses are done; a re-check after that opens a new run, so results of different runs never mix. check_runs, run_results (one result per address and run, with the verdict and the expected and stored check counts), subnets, run_id on ip_queue and checks. Existing data is split into runs at pauses of more than an hour; ingress checks get the validator that held the address (also at write time from now on). Analytics (internal/analytics): figures computed from the stored checks of the latest cycle of each address in the run, as facts next to the verdict: summary, reasons of partial, data quality, subnets, targets and the subnet x target matrix by check type, ingress by site, error classes, validators, and the address lists behind the indicators and error classes. API: analytics runs, report, lists (JSON or CSV), subnet list; run and subnet filters for the registry. Dashboard: /analytics matching the approved mockup (run selector, indicators with address lists and CSV, error-class dialogs, drill-down to the registry), subnet list on /settings. Sidebar: the control-api link state, theme toggle and logout moved to the top, the three dots next to the logo removed, sections grouped. Rebuilt bin/control-api and bin/admin-dashboard to match. Plan, summary and the updated README, API, USAGE, DASHBOARD and ADMIN_CLEANUP docs are in docs/. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
864208238f
commit
b7669c9e41
44 files changed
+4123
-62
No files matched your search
@@ -0,0 +1,229 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cloudipvalidator/internal/db"
|
||||
)
|
||||
|
||||
var t0 = time.Date(2026, 10, 2, 13, 0, 0, 0, time.UTC)
|
||||
|
||||
type fixture struct {
|
||||
results []db.RunResult
|
||||
checks []db.RunCheck
|
||||
}
|
||||
|
||||
func (f *fixture) addr(reg int64, ip, verdict string, expected int) {
|
||||
f.results = append(f.results, db.RunResult{RegistryID: reg, IPAddress: ip, CycleID: 1, Verdict: verdict,
|
||||
AggregatedAt: t0.Add(time.Minute), ExpectedChecks: expected})
|
||||
}
|
||||
|
||||
func (f *fixture) check(reg int64, source, typ, target string, ok bool, validator, detail string, late bool) {
|
||||
rec := t0
|
||||
if late {
|
||||
rec = t0.Add(time.Hour)
|
||||
}
|
||||
f.checks = append(f.checks, db.RunCheck{RegistryID: reg, Source: source, CheckType: typ, Target: target, Success: ok,
|
||||
ValidatorID: validator, Detail: detail, RecordedAt: rec})
|
||||
}
|
||||
|
||||
func (f *fixture) compute(t *testing.T, subnets []db.Subnet) *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,
|
||||
SiteNames: map[int]string{1: "rxmsk", 2: "rxyc"},
|
||||
Each: func(fn func(db.RunCheck)) error {
|
||||
for _, c := range f.checks {
|
||||
fn(c)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return an
|
||||
}
|
||||
|
||||
const (
|
||||
eg = db.SourceEgress
|
||||
s1 = "inbound-site-1"
|
||||
s2 = "inbound-site-2"
|
||||
)
|
||||
|
||||
func TestComputeCountsFactsPerAddress(t *testing.T) {
|
||||
f := &fixture{}
|
||||
// 1: all fine
|
||||
f.addr(1, "10.0.0.1", db.ResultPass, 6)
|
||||
// 2: egress https fails on both targets, rest fine
|
||||
f.addr(2, "10.0.0.2", db.ResultPartial, 6)
|
||||
// 3: ingress ssh fails on one site, set incomplete (5 of 6 stored)
|
||||
f.addr(3, "10.0.1.1", db.ResultPartial, 6)
|
||||
// 4: pass at the verdict, but a failed ingress check arrived afterwards
|
||||
f.addr(4, "10.0.1.2", db.ResultPass, 6)
|
||||
// 5: cancelled, not counted
|
||||
f.addr(5, "10.0.1.3", db.ResultCancelled, 6)
|
||||
|
||||
good := func(reg int64) {
|
||||
f.check(reg, eg, "https", "https://a.test/x", true, "vkiplab-v1", "", false)
|
||||
f.check(reg, eg, "https", "https://b.test", true, "vkiplab-v1", "", false)
|
||||
f.check(reg, s1, "icmp", "ip", true, "vkiplab-v1", "", false)
|
||||
f.check(reg, s1, "ssh", "ip", true, "vkiplab-v1", "", false)
|
||||
f.check(reg, s2, "icmp", "ip", true, "vkiplab-v1", "", false)
|
||||
f.check(reg, s2, "ssh", "ip", true, "vkiplab-v1", "", false)
|
||||
}
|
||||
good(1)
|
||||
f.check(2, eg, "https", "https://a.test/x", false, "vkiplab-v2", `Get "https://a.test/x": context deadline exceeded`, false)
|
||||
f.check(2, eg, "https", "https://b.test", false, "vkiplab-v2", "", false)
|
||||
for _, s := range []string{s1, s2} {
|
||||
f.check(2, s, "icmp", "ip", true, "vkiplab-v2", "", false)
|
||||
f.check(2, s, "ssh", "ip", true, "vkiplab-v2", "", false)
|
||||
}
|
||||
f.check(3, eg, "https", "https://a.test/x", true, "vkiplab-v3", "", false)
|
||||
f.check(3, eg, "https", "https://b.test", true, "vkiplab-v3", "", false)
|
||||
f.check(3, s1, "icmp", "ip", true, "vkiplab-v3", "", false)
|
||||
f.check(3, s1, "ssh", "ip", false, "vkiplab-v3", "dial tcp 1.2.3.4:22: i/o timeout", false)
|
||||
f.check(3, s2, "icmp", "ip", true, "vkiplab-v3", "", false) // the 6th check is missing
|
||||
// 4: the failed ssh arrived after the verdict
|
||||
f.check(4, eg, "https", "https://a.test/x", true, "vkiplab-v4", "", false)
|
||||
f.check(4, eg, "https", "https://b.test", true, "vkiplab-v4", "", false)
|
||||
f.check(4, s1, "icmp", "ip", true, "vkiplab-v4", "", false)
|
||||
f.check(4, s1, "ssh", "ip", false, "vkiplab-v4", `unexpected banner prefix "Not allo"`, true)
|
||||
f.check(4, s2, "icmp", "ip", true, "vkiplab-v4", "", false)
|
||||
f.check(4, s2, "ssh", "ip", true, "vkiplab-v4", "", false)
|
||||
good(5)
|
||||
|
||||
an := f.compute(t, []db.Subnet{{CIDR: "10.0.0.0/24"}, {CIDR: "10.0.1.0/24"}})
|
||||
r := an.Report
|
||||
|
||||
want := Summary{Addresses: 4, Pass: 2, Partial: 2, Cancelled: 1, EgressOK: 3, IngressOK: 2,
|
||||
EgressHTTPSAny: 1, EgressHTTPSAll: 1, EgressHTTPSAllTargets: 1, IngressSSHAny: 2, IngressSSHAll: 1}
|
||||
got := r.Summary
|
||||
got.PerMinute = 0
|
||||
if got != want {
|
||||
t.Errorf("summary = %+v\nwant %+v", got, want)
|
||||
}
|
||||
if r.Run.DurationSec != 600 || r.Run.ID != 7 {
|
||||
t.Errorf("run info: %+v", r.Run)
|
||||
}
|
||||
|
||||
if wantReasons := []Reason{{"Только egress", 1}, {"Ingress и неполный набор", 1}}; !reflect.DeepEqual(r.Reasons, wantReasons) {
|
||||
t.Errorf("reasons = %+v, want %+v", r.Reasons, wantReasons)
|
||||
}
|
||||
|
||||
q := r.Quality
|
||||
if q.Incomplete != 1 || q.PassWithFailed != 1 || q.PassByFacts != 1 || q.LateFailedAtPass != 1 || q.LateFailedAtPassAddresses != 1 ||
|
||||
q.IngressFailed != 2 || q.IngressFailedLate != 1 {
|
||||
t.Errorf("quality = %+v", q)
|
||||
}
|
||||
|
||||
// Errors are classed by check type and reason.
|
||||
wantErrs := []ErrorClass{{"SSH: баннер «Not allowed»", 1}, {"SSH: таймаут", 1}}
|
||||
if !reflect.DeepEqual(r.Errors, wantErrs) {
|
||||
t.Errorf("errors = %+v", r.Errors)
|
||||
}
|
||||
|
||||
// Targets: hosts, https is the lead type; address 2 failed both.
|
||||
if !reflect.DeepEqual(r.Targets.Targets, []string{"a.test", "b.test"}) || !reflect.DeepEqual(r.Targets.Failed["https"], []int{1, 1}) {
|
||||
t.Errorf("targets = %+v", r.Targets)
|
||||
}
|
||||
if len(r.Subnets) != 2 || r.Subnets[0].Addresses != 2 {
|
||||
t.Errorf("subnets = %+v", r.Subnets)
|
||||
}
|
||||
if rows := r.Matrix["https"]; len(rows) != 2 || rows[0].CIDR != "10.0.0.0/24" && rows[0].CIDR != "10.0.1.0/24" {
|
||||
t.Errorf("matrix = %+v", r.Matrix)
|
||||
}
|
||||
|
||||
// Sites in index order, types sorted.
|
||||
if !reflect.DeepEqual(r.Sites.Types, []string{"icmp", "ssh"}) || len(r.Sites.Rows) != 2 || r.Sites.Rows[0].Site != "rxmsk" {
|
||||
t.Errorf("sites = %+v", r.Sites)
|
||||
}
|
||||
// ssh at rxmsk: addresses 1, 2, 3, 4 (the cancelled one is not counted) -> 4 checks, 2 failed.
|
||||
if st := r.Sites.Rows[0].Stats[1]; st.Total != 4 || st.OK != 2 {
|
||||
t.Errorf("rxmsk ssh = %+v", st)
|
||||
}
|
||||
// Validators by number.
|
||||
if len(r.Validators) != 4 || r.Validators[0].Validator != "vkiplab-v1" || r.Validators[0].Total != 2 {
|
||||
t.Errorf("validators = %+v", r.Validators)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubnetMatching(t *testing.T) {
|
||||
in := []db.Subnet{{CIDR: "10.0.0.0/8"}, {CIDR: "10.1.0.0/16"}}
|
||||
m := newSubnetMatcher(in)
|
||||
for ip, want := range map[string]string{"10.1.2.3": "10.1.0.0/16", "10.2.0.1": "10.0.0.0/8", "192.0.2.1": "прочие", "garbage": "прочие"} {
|
||||
if got := m(ip); got != want {
|
||||
t.Errorf("%s -> %s, want %s", ip, got, want)
|
||||
}
|
||||
}
|
||||
auto := newSubnetMatcher(nil)
|
||||
if got := auto("203.0.113.77"); got != "203.0.113.0/24" {
|
||||
t.Errorf("without a list addresses group by /24, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorClassOf(t *testing.T) {
|
||||
for _, c := range []struct{ typ, detail, want string }{
|
||||
{"ssh", `read banner: read tcp 1.2.3.4:5->6.7.8.9:22: i/o timeout`, "SSH: таймаут"},
|
||||
{"ssh", `unexpected banner prefix "Not allo"`, "SSH: баннер «Not allowed»"},
|
||||
{"ssh", `dial tcp 1.2.3.4:22: connect: no route to host`, "SSH: нет маршрута"},
|
||||
{"tcp-22", `dial tcp 1.2.3.4:22: i/o timeout`, "TCP-22: таймаут"},
|
||||
{"tcp-22", `connect: connection refused`, "TCP-22: отказ в соединении"},
|
||||
{"icmp", `read echo reply: read ip4 0.0.0.0: i/o timeout`, "ICMP: нет ответа"},
|
||||
{"icmp", `unexpected icmp type time exceeded`, "ICMP: time exceeded"},
|
||||
{"ssh", `something new`, "SSH: прочее"},
|
||||
} {
|
||||
if got := ErrorClassOf(c.typ, c.detail); got != c.want {
|
||||
t.Errorf("%s %q = %q, want %q", c.typ, c.detail, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListsAndShortValidator(t *testing.T) {
|
||||
f := &fixture{}
|
||||
f.addr(1, "10.0.0.9", db.ResultPartial, 4)
|
||||
f.addr(2, "10.0.0.10", db.ResultPass, 4)
|
||||
f.check(1, eg, "https", "https://a.test", false, "vkiplab-v12", "", false)
|
||||
f.check(1, eg, "https", "https://b.test", false, "vkiplab-v12", "", false)
|
||||
f.check(1, s2, "ssh", "ip", false, "vkiplab-v12", "dial tcp: i/o timeout", false)
|
||||
f.check(1, s1, "ssh", "ip", false, "vkiplab-v12", "dial tcp: i/o timeout", true)
|
||||
f.check(2, eg, "https", "https://a.test", true, "vkiplab-v3", "", false)
|
||||
f.check(2, eg, "https", "https://b.test", false, "vkiplab-v3", "", false)
|
||||
an := f.compute(t, nil)
|
||||
|
||||
l, err := an.List(ListEgressHTTPSAny, "")
|
||||
if err != nil || len(l.Rows) != 2 || l.Rows[0][0] != "10.0.0.9" || l.Rows[1][0] != "10.0.0.10" { // numeric order
|
||||
t.Fatalf("any: %+v %v", l, err)
|
||||
}
|
||||
if l.Rows[0][2] != "v12" || l.Rows[0][3] != "2 из 2" || l.Rows[1][3] != "1 из 2" || l.Rows[1][4] != "b.test" {
|
||||
t.Errorf("any rows: %+v", l.Rows)
|
||||
}
|
||||
l, _ = an.List(ListEgressHTTPSAll, "")
|
||||
if len(l.Rows) != 1 || l.Rows[0][3] != "2" || l.Rows[0][4] != "a.test, b.test" {
|
||||
t.Errorf("all: %+v", l.Rows)
|
||||
}
|
||||
l, _ = an.List(ListIngressSSHAll, "")
|
||||
if len(l.Rows) != 1 || l.Rows[0][2] != "rxmsk, rxyc" || l.Rows[0][3] != "таймаут" { // sites in index order
|
||||
t.Errorf("ssh all: %+v", l.Rows)
|
||||
}
|
||||
l, _ = an.List(ListError, "SSH: таймаут")
|
||||
if len(l.Rows) != 2 || l.Rows[0][2] != "rxmsk" || l.Rows[0][5] != "провал, после вердикта" || l.Rows[1][5] != "провал, в вердикте" {
|
||||
t.Errorf("error list: %+v", l.Rows)
|
||||
}
|
||||
if _, err := an.List(ListError, ""); err == nil {
|
||||
t.Error("an error list needs a class")
|
||||
}
|
||||
if _, err := an.List("nonsense", ""); err == nil {
|
||||
t.Error("unknown list must fail")
|
||||
}
|
||||
for in, want := range map[string]string{"vkiplab-v12": "v12", "validator": "validator", "": ""} {
|
||||
if got := ShortValidator(in); got != want {
|
||||
t.Errorf("ShortValidator(%q) = %q", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user