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,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 "прочее"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) },
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user