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:
ayurishchevandClaude Sonnet 5.5 committed 2026-10-03 18:36:03 +03:00
1 parent 864208238f
commit b7669c9e41
44 files changed
+4123 -62

No files matched your search

+664
View File
@@ -0,0 +1,664 @@
// Package analytics turns the stored checks of one finished run into the
// numbers behind the dashboard's analytics page. It works on facts: every
// check stored for the latest cycle of each address in the run, whenever it
// arrived. The verdict is shown next to those facts, never mixed into them.
package analytics
import (
"net/netip"
"net/url"
"sort"
"strconv"
"strings"
"time"
"cloudipvalidator/internal/db"
)
// Input is everything Compute needs, already read from the database.
type Input struct {
Run db.CheckRun
Results []db.RunResult
Subnets []db.Subnet
SiteNames map[int]string // site index -> site id
Rechecked int // addresses with more than one cycle in the run
// Each feeds every check of the run's result cycles to fn.
Each func(fn func(db.RunCheck)) error
}
// Report is the data of the analytics page for one run.
type Report struct {
Run RunInfo `json:"run"`
Summary Summary `json:"summary"`
Reasons []Reason `json:"reasons"`
Quality Quality `json:"quality"`
Subnets []SubnetRow `json:"subnets"`
Targets TargetsBlock `json:"targets"`
Matrix map[string][]MatrixRow `json:"matrix"`
Sites SitesBlock `json:"sites"`
Errors []ErrorClass `json:"errors"`
Validators []ValidatorRow `json:"validators"`
}
type RunInfo struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
State string `json:"state"`
StartedAt time.Time `json:"started_at"`
FinalizedAt *time.Time `json:"finalized_at"`
DurationSec int `json:"duration_seconds"`
Rechecked int `json:"rechecked"`
}
type Summary struct {
Addresses int `json:"addresses"`
Pass int `json:"pass"`
Partial int `json:"partial"`
Fail int `json:"fail"`
Cancelled int `json:"cancelled"`
EgressOK int `json:"egress_ok"`
IngressOK int `json:"ingress_ok"`
EgressHTTPSAny int `json:"egress_https_any_failed"`
EgressHTTPSAll int `json:"egress_https_all_failed"`
// EgressHTTPSAllTargets counts the addresses that failed https to every
// target of the full set (as many checks as the best-covered address).
EgressHTTPSAllTargets int `json:"egress_https_all_targets_failed"`
IngressSSHAny int `json:"ingress_ssh_any_failed"`
IngressSSHAll int `json:"ingress_ssh_all_failed"`
PerMinute float64 `json:"addresses_per_minute"`
}
type Reason struct {
Name string `json:"name"`
Count int `json:"count"`
}
// Quality is the data-quality block: how the verdict relates to the checks.
type Quality struct {
LateFailedAtPass int `json:"late_failed_checks_at_pass"`
LateFailedAtPassAddresses int `json:"late_failed_addresses_at_pass"`
IngressFailed int `json:"ingress_failed_checks"`
IngressFailedLate int `json:"ingress_failed_late"`
Incomplete int `json:"incomplete_addresses"`
PassWithFailed int `json:"pass_with_failed_addresses"`
PassByFacts int `json:"pass_by_facts"`
}
type SubnetRow struct {
CIDR string `json:"cidr"`
Label string `json:"label,omitempty"`
Addresses int `json:"addresses"`
Pass int `json:"pass"`
EgressOK int `json:"egress_ok"`
IngressOK int `json:"ingress_ok"`
}
type TargetsBlock struct {
Types []string `json:"types"`
Targets []string `json:"targets"`
Failed map[string][]int `json:"failed"` // type -> failed addresses per target, in Targets order
}
type MatrixRow struct {
CIDR string `json:"cidr"`
Partial int `json:"partial"`
Percent []int `json:"percent"` // per target, in Targets order
}
type SitesBlock struct {
Types []string `json:"types"`
Rows []SiteRow `json:"rows"`
}
type SiteRow struct {
Site string `json:"site"`
Stats []SiteStat `json:"stats"` // per type, in Types order
}
type SiteStat struct {
Total int `json:"total"`
OK int `json:"ok"`
}
type ErrorClass struct {
Name string `json:"name"`
Count int `json:"count"`
}
type ValidatorRow struct {
Validator string `json:"validator"`
Total int `json:"total"`
OK int `json:"ok"`
}
type typeStat struct{ n, ok int }
type failedIngress struct {
class, site, validator string
late bool
}
// addr is everything known about one address of the run.
type addr struct {
res db.RunResult
subnet string
egress typeStat
ingress typeStat
stored int
https struct {
typeStat
validator string
failedTargets []string
}
ssh struct {
typeStat
sites []string
errs map[string]bool
}
failedTargets map[string]bool // family\x00target -> failed
failedIngress []failedIngress
lateFailed int
}
// Analysis is a computed report plus the per-address data the lists are cut from.
type Analysis struct {
Report Report
addrs []*addr
siteNames map[int]string
}
// Compute reads the checks of the run once and builds the report.
func Compute(in Input) (*Analysis, error) {
byReg := make(map[int64]*addr, len(in.Results))
var addrs []*addr
subnetOf := newSubnetMatcher(in.Subnets)
for _, r := range in.Results {
a := &addr{res: r, subnet: subnetOf(r.IPAddress), failedTargets: map[string]bool{}}
a.ssh.errs = map[string]bool{}
byReg[r.RegistryID] = a
addrs = append(addrs, a)
}
siteName := func(source string) string {
idx, _ := strconv.Atoi(strings.TrimPrefix(source, "inbound-site-"))
if n := in.SiteNames[idx]; n != "" {
return n
}
return "site-" + strconv.Itoa(idx)
}
type key struct{ site, typ string }
siteStats := map[key]*typeStat{}
siteTypes := map[string]bool{}
egressTypes := map[string]bool{}
valHTTPS := map[string]*typeStat{}
targetSet := map[string]bool{}
errCount := map[string]int{}
err := in.Each(func(c db.RunCheck) {
a := byReg[c.RegistryID]
if a == nil || a.res.Verdict == db.ResultCancelled {
return // a cancelled address was stopped, its checks say nothing
}
a.stored++
late := c.AfterVerdict || c.RecordedAt.After(a.res.AggregatedAt)
family := db.CheckFamily(c.CheckType)
switch db.CheckLevel(c.Source) {
case db.LevelEgress:
a.egress.n++
if c.Success {
a.egress.ok++
}
egressTypes[family] = true
target := normalizeTarget(c.Target)
targetSet[target] = true
if !c.Success {
a.failedTargets[family+"\x00"+target] = true
}
if family == "https" {
a.https.n++
a.https.validator = c.ValidatorID
if c.Success {
a.https.ok++
} else {
a.https.failedTargets = append(a.https.failedTargets, target)
}
v := valHTTPS[c.ValidatorID]
if v == nil {
v = &typeStat{}
valHTTPS[c.ValidatorID] = v
}
v.n++
if c.Success {
v.ok++
}
}
case db.LevelIngress:
a.ingress.n++
if c.Success {
a.ingress.ok++
}
site := siteName(c.Source)
siteTypes[family] = true
ss := siteStats[key{site, family}]
if ss == nil {
ss = &typeStat{}
siteStats[key{site, family}] = ss
}
ss.n++
if c.Success {
ss.ok++
}
if family == "ssh" {
a.ssh.n++
if c.Success {
a.ssh.ok++
}
}
if !c.Success {
class := ErrorClassOf(c.CheckType, c.Detail)
errCount[class]++
a.failedIngress = append(a.failedIngress, failedIngress{class: class, site: site, validator: c.ValidatorID, late: late})
if family == "ssh" {
a.ssh.sites = append(a.ssh.sites, site)
a.ssh.errs[errorReason(c.CheckType, c.Detail)] = true
}
}
}
if late && !c.Success {
a.lateFailed++
}
})
if err != nil {
return nil, err
}
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.Run.FinalizedAt != nil {
rep.Run.DurationSec = int(in.Run.FinalizedAt.Sub(in.Run.StartedAt).Seconds())
}
maxHTTPS := 0
for _, a := range addrs {
if a.https.n > maxHTTPS {
maxHTTPS = a.https.n
}
}
reasonCount := map[string]int{}
type subAgg struct {
n, pass, eg, ing, partial int
failed map[string]int
}
subs := map[string]*subAgg{}
sum := &rep.Summary
for _, a := range addrs {
v := a.res.Verdict
if v == db.ResultCancelled {
sum.Cancelled++
continue
}
sum.Addresses++
switch v {
case db.ResultPass:
sum.Pass++
case db.ResultPartial:
sum.Partial++
case db.ResultFail:
sum.Fail++
}
egOK := a.egress.n > 0 && a.egress.ok == a.egress.n
inOK := a.ingress.n > 0 && a.ingress.ok == a.ingress.n
if egOK {
sum.EgressOK++
}
if inOK {
sum.IngressOK++
}
if a.https.n > 0 && a.https.ok < a.https.n {
sum.EgressHTTPSAny++
if a.https.ok == 0 {
sum.EgressHTTPSAll++
if a.https.n == maxHTTPS {
sum.EgressHTTPSAllTargets++
}
}
}
if a.ssh.n > 0 && a.ssh.ok < a.ssh.n {
sum.IngressSSHAny++
if a.ssh.ok == 0 {
sum.IngressSSHAll++
}
}
egFail := a.egress.ok < a.egress.n
inFail := a.ingress.ok < a.ingress.n
incomplete := a.res.ExpectedChecks >= 0 && a.stored < a.res.ExpectedChecks
if incomplete {
rep.Quality.Incomplete++
}
if v == db.ResultPartial {
reasonCount[reasonName(egFail, inFail, incomplete)]++
}
if v == db.ResultPass {
if a.egress.ok < a.egress.n || a.ingress.ok < a.ingress.n {
rep.Quality.PassWithFailed++
rep.Quality.LateFailedAtPass += a.lateFailed
rep.Quality.LateFailedAtPassAddresses++
}
}
sa := subs[a.subnet]
if sa == nil {
sa = &subAgg{failed: map[string]int{}}
subs[a.subnet] = sa
}
sa.n++
if v == db.ResultPass {
sa.pass++
}
if egOK {
sa.eg++
}
if inOK {
sa.ing++
}
if v == db.ResultPartial {
sa.partial++
for k := range a.failedTargets {
sa.failed[k]++
}
}
}
rep.Quality.PassByFacts = sum.Pass - rep.Quality.PassWithFailed
if sum.Addresses > 0 && rep.Run.DurationSec > 0 {
sum.PerMinute = float64(sum.Addresses) / (float64(rep.Run.DurationSec) / 60)
}
for _, a := range addrs {
for _, f := range a.failedIngress {
rep.Quality.IngressFailed++
if f.late {
rep.Quality.IngressFailedLate++
}
}
}
for _, name := range reasonOrder {
if n := reasonCount[name]; n > 0 {
rep.Reasons = append(rep.Reasons, Reason{Name: name, Count: n})
}
}
// Subnets: worst first is the page's job; the report lists them by size.
labels := map[string]string{}
for _, s := range in.Subnets {
labels[s.CIDR] = s.Label
}
for cidr, sa := range subs {
rep.Subnets = append(rep.Subnets, SubnetRow{CIDR: cidr, Label: labels[cidr], Addresses: sa.n, Pass: sa.pass, EgressOK: sa.eg, IngressOK: sa.ing})
}
sort.Slice(rep.Subnets, func(i, j int) bool {
if rep.Subnets[i].Addresses != rep.Subnets[j].Addresses {
return rep.Subnets[i].Addresses > rep.Subnets[j].Addresses
}
return rep.Subnets[i].CIDR < rep.Subnets[j].CIDR
})
// Targets and the subnet x target matrix, per egress check family.
types := sortedKeys(egressTypes)
rep.Targets.Types = types
failedAddrs := map[string]int{} // family\x00target -> addresses
for _, a := range addrs {
if a.res.Verdict == db.ResultCancelled {
continue
}
for k := range a.failedTargets {
failedAddrs[k]++
}
}
targets := sortedKeys(targetSet)
lead := ""
if len(types) > 0 {
lead = types[0]
for _, t := range types {
if t == "https" {
lead = t
}
}
}
sort.SliceStable(targets, func(i, j int) bool {
fi, fj := failedAddrs[lead+"\x00"+targets[i]], failedAddrs[lead+"\x00"+targets[j]]
if fi != fj {
return fi > fj
}
return targets[i] < targets[j]
})
rep.Targets.Targets = targets
rep.Targets.Failed = map[string][]int{}
for _, t := range types {
row := make([]int, len(targets))
for i, tg := range targets {
row[i] = failedAddrs[t+"\x00"+tg]
}
rep.Targets.Failed[t] = row
}
for _, t := range types {
var rows []MatrixRow
for cidr, sa := range subs {
if sa.partial == 0 {
continue
}
pc := make([]int, len(targets))
for i, tg := range targets {
pc[i] = int(float64(sa.failed[t+"\x00"+tg])/float64(sa.partial)*100 + 0.5)
}
rows = append(rows, MatrixRow{CIDR: cidr, Partial: sa.partial, Percent: pc})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Partial != rows[j].Partial {
return rows[i].Partial > rows[j].Partial
}
return rows[i].CIDR < rows[j].CIDR
})
rep.Matrix[t] = rows
}
// Sites.
rep.Sites.Types = sortedKeys(siteTypes)
names := map[string]bool{}
for k := range siteStats {
names[k.site] = true
}
siteList := sortedKeys(names)
sort.Slice(siteList, func(i, j int) bool {
return siteIndexOf(in.SiteNames, siteList[i]) < siteIndexOf(in.SiteNames, siteList[j])
})
for _, s := range siteList {
row := SiteRow{Site: s}
for _, t := range rep.Sites.Types {
st := siteStats[key{s, t}]
if st == nil {
st = &typeStat{}
}
row.Stats = append(row.Stats, SiteStat{Total: st.n, OK: st.ok})
}
rep.Sites.Rows = append(rep.Sites.Rows, row)
}
for name, n := range errCount {
rep.Errors = append(rep.Errors, ErrorClass{Name: name, Count: n})
}
sort.Slice(rep.Errors, func(i, j int) bool {
if rep.Errors[i].Count != rep.Errors[j].Count {
return rep.Errors[i].Count > rep.Errors[j].Count
}
return rep.Errors[i].Name < rep.Errors[j].Name
})
for id, st := range valHTTPS {
rep.Validators = append(rep.Validators, ValidatorRow{Validator: id, Total: st.n, OK: st.ok})
}
sort.Slice(rep.Validators, func(i, j int) bool {
a, b := validatorNumber(rep.Validators[i].Validator), validatorNumber(rep.Validators[j].Validator)
if a != b {
return a < b
}
return rep.Validators[i].Validator < rep.Validators[j].Validator
})
return &Analysis{Report: rep, addrs: addrs, siteNames: in.SiteNames}, nil
}
var reasonOrder = []string{
"Только egress",
"Ingress и egress",
"Egress и неполный набор",
"Ingress, egress и неполный набор",
"Только неполный набор",
"Только ingress",
"Ingress и неполный набор",
"Прочее",
}
func reasonName(egress, ingress, incomplete bool) string {
switch {
case egress && ingress && incomplete:
return "Ingress, egress и неполный набор"
case egress && ingress:
return "Ingress и egress"
case egress && incomplete:
return "Egress и неполный набор"
case egress:
return "Только egress"
case ingress && incomplete:
return "Ingress и неполный набор"
case ingress:
return "Только ingress"
case incomplete:
return "Только неполный набор"
}
return "Прочее"
}
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func siteIndexOf(names map[int]string, site string) int {
for i, n := range names {
if n == site {
return i
}
}
if n, err := strconv.Atoi(strings.TrimPrefix(site, "site-")); err == nil {
return n
}
return 1 << 20
}
// validatorNumber is the trailing number of a validator id ("vkiplab-v12" ->
// 12), or 1<<20 when there is none, so numbered validators sort naturally.
func validatorNumber(id string) int {
i := len(id)
for i > 0 && id[i-1] >= '0' && id[i-1] <= '9' {
i--
}
if i == len(id) {
return 1 << 20
}
n, _ := strconv.Atoi(id[i:])
return n
}
// ShortValidator is the validator id as the page shows it: "vkiplab-v12" ->
// "v12"; ids without a number stay whole.
func ShortValidator(id string) string {
n := validatorNumber(id)
if n == 1<<20 {
return id
}
return "v" + strconv.Itoa(n)
}
// normalizeTarget is the host of an egress target: https://host/path -> host.
func normalizeTarget(t string) string {
if u, err := url.Parse(t); err == nil && u.Host != "" {
return u.Hostname()
}
return strings.TrimSuffix(t, "/")
}
// newSubnetMatcher returns a function that maps an address to the most
// specific configured subnet. With no subnets configured addresses group by
// /24 (/64 for IPv6). An address outside the list goes to "прочие".
func newSubnetMatcher(subnets []db.Subnet) func(string) string {
type entry struct {
p netip.Prefix
name string
}
var list []entry
for _, s := range subnets {
if p, err := netip.ParsePrefix(s.CIDR); err == nil {
list = append(list, entry{p.Masked(), p.Masked().String()})
}
}
sort.Slice(list, func(i, j int) bool { return list[i].p.Bits() > list[j].p.Bits() })
return func(ip string) string {
a, err := netip.ParseAddr(ip)
if err != nil {
return "прочие"
}
if len(list) == 0 {
bits := 24
if a.Is6() {
bits = 64
}
p, _ := a.Prefix(bits)
return p.String()
}
for _, e := range list {
if e.p.Contains(a) {
return e.name
}
}
return "прочие"
}
}
// ErrorClassOf names the class of a failed ingress check: the check type and
// the reason, e.g. "SSH: таймаут", "ICMP: нет ответа".
func ErrorClassOf(checkType, detail string) string {
return strings.ToUpper(checkType) + ": " + errorReason(checkType, detail)
}
func errorReason(checkType, detail string) string {
d := strings.ToLower(detail)
switch {
case strings.Contains(d, "unexpected banner prefix"):
if strings.Contains(d, "not allo") {
return "баннер «Not allowed»"
}
return "неожиданный баннер"
case strings.Contains(d, "no route to host"):
return "нет маршрута"
case strings.Contains(d, "time exceeded"):
return "time exceeded"
case strings.Contains(d, "connection refused"):
return "отказ в соединении"
case strings.Contains(d, "timeout") || strings.Contains(d, "deadline exceeded"):
if strings.EqualFold(checkType, "icmp") {
return "нет ответа"
}
return "таймаут"
}
if strings.EqualFold(checkType, "icmp") {
return "нет ответа"
}
return "прочее"
}
+229
View File
@@ -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)
}
}
}
+126
View File
@@ -0,0 +1,126 @@
package analytics
import (
"fmt"
"net/netip"
"sort"
"strings"
"cloudipvalidator/internal/db"
)
// List kinds served by Analysis.List.
const (
ListEgressHTTPSAny = "egress_https_any"
ListEgressHTTPSAll = "egress_https_all"
ListIngressSSHAny = "ingress_ssh_any"
ListIngressSSHAll = "ingress_ssh_all"
ListError = "error"
)
// List is a table of addresses behind one indicator or one error class.
type List struct {
Kind string `json:"kind"`
Class string `json:"class,omitempty"`
Columns []string `json:"columns"`
Rows [][]string `json:"rows"`
}
// ErrUnknownList is returned for a list kind that does not exist.
type ErrUnknownList string
func (e ErrUnknownList) Error() string { return fmt.Sprintf("unknown list %q", string(e)) }
// List builds the table for a kind; class is only used with ListError.
func (an *Analysis) List(kind, class string) (*List, error) {
l := &List{Kind: kind, Class: class, Rows: [][]string{}}
switch kind {
case ListEgressHTTPSAny, ListEgressHTTPSAll:
l.Columns = []string{"Адрес", "Подсеть", "Валидатор", "https-проверок", "Проваленные цели"}
if kind == ListEgressHTTPSAny {
l.Columns[3] = "Провалено https"
}
for _, a := range an.sorted() {
if a.https.n == 0 || a.https.ok == a.https.n || (kind == ListEgressHTTPSAll && a.https.ok != 0) {
continue
}
targets := append([]string(nil), a.https.failedTargets...)
sort.Strings(targets)
count := fmt.Sprint(a.https.n)
if kind == ListEgressHTTPSAny {
count = fmt.Sprintf("%d из %d", a.https.n-a.https.ok, a.https.n)
}
l.Rows = append(l.Rows, []string{a.res.IPAddress, a.subnet, ShortValidator(a.https.validator), count, strings.Join(targets, ", ")})
}
case ListIngressSSHAny, ListIngressSSHAll:
l.Columns = []string{"Адрес", "Подсеть", "Провалено ssh", "Площадки с провалом", "Ошибка"}
if kind == ListIngressSSHAll {
l.Columns = []string{"Адрес", "Подсеть", "Площадки с провалом ssh", "Ошибка"}
}
for _, a := range an.sorted() {
if a.ssh.n == 0 || a.ssh.ok == a.ssh.n || (kind == ListIngressSSHAll && a.ssh.ok != 0) {
continue
}
sites := an.sortSites(a.ssh.sites)
errs := make([]string, 0, len(a.ssh.errs))
for e := range a.ssh.errs {
errs = append(errs, e)
}
sort.Strings(errs)
if kind == ListIngressSSHAny {
l.Rows = append(l.Rows, []string{a.res.IPAddress, a.subnet, fmt.Sprintf("%d из %d", len(a.ssh.sites), a.ssh.n), strings.Join(sites, ", "), strings.Join(errs, ", ")})
} else {
l.Rows = append(l.Rows, []string{a.res.IPAddress, a.subnet, strings.Join(sites, ", "), strings.Join(errs, ", ")})
}
}
case ListError:
if class == "" {
return nil, ErrUnknownList("error without class")
}
l.Columns = []string{"Адрес", "Подсеть", "Площадка", "Валидатор", "Вердикт адреса", "Статус проверки"}
for _, a := range an.sorted() {
fs := append([]failedIngress(nil), a.failedIngress...)
sort.SliceStable(fs, func(i, j int) bool { return an.siteIndex(fs[i].site) < an.siteIndex(fs[j].site) })
for _, f := range fs {
if f.class != class {
continue
}
status := "провал, в вердикте"
if f.late {
status = "провал, после вердикта"
}
l.Rows = append(l.Rows, []string{a.res.IPAddress, a.subnet, f.site, ShortValidator(f.validator), a.res.Verdict, status})
}
}
default:
return nil, ErrUnknownList(kind)
}
return l, nil
}
// sorted returns the non-cancelled addresses in numeric address order.
func (an *Analysis) sorted() []*addr {
out := make([]*addr, 0, len(an.addrs))
for _, a := range an.addrs {
if a.res.Verdict != db.ResultCancelled {
out = append(out, a)
}
}
sort.Slice(out, func(i, j int) bool {
x, errX := netip.ParseAddr(out[i].res.IPAddress)
y, errY := netip.ParseAddr(out[j].res.IPAddress)
if errX != nil || errY != nil {
return out[i].res.IPAddress < out[j].res.IPAddress
}
return x.Less(y)
})
return out
}
func (an *Analysis) siteIndex(site string) int { return siteIndexOf(an.siteNames, site) }
func (an *Analysis) sortSites(sites []string) []string {
out := append([]string(nil), sites...)
sort.SliceStable(out, func(i, j int) bool { return an.siteIndex(out[i]) < an.siteIndex(out[j]) })
return out
}
+39
View File
@@ -0,0 +1,39 @@
package analytics
import (
"context"
"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) {
run, err := d.GetRun(ctx, runID)
if err != nil {
return nil, err
}
results, err := d.ListRunResults(ctx, runID)
if err != nil {
return nil, err
}
subnets, err := d.ListSubnets(ctx)
if err != nil {
return nil, err
}
sites, err := d.ListSites(ctx)
if err != nil {
return nil, err
}
names := map[int]string{}
for _, s := range sites {
names[s.Index] = s.SiteID
}
rechecked, err := d.CountRecheckedInRun(ctx, runID)
if err != nil {
return nil, err
}
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) },
})
}