Files
cloud-ip-validator/internal/analytics/lists.go
T
ayurishchevandClaude Sonnet 5.5 068c10ea1c Analytics: compare two finished runs
New page /analytics/compare and API GET /admin/analytics/compare (+ /lists/{group}):
the administrator picks an old (A) and a new (B) run; the report shows the new
addresses (only in B), the ones that left (only in A) and the common ones whose
membership in the seven indicators (pass, partial, fail, egress https any/all,
ingress ssh any/all) differs, with a "what changed" summary per address; the
dynamics of each indicator (delta = new - left + entered - exited) and a verdict
transition matrix. Every number opens a list with CSV. Cancelled addresses are not
part of a run. The list dialog moved to a shared analytics-dialog.js and template;
/analytics got a "compare with another run" button.

Docs, plan and summary in docs/changes/.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:26:37 +03:00

173 lines
5.9 KiB
Go

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"
// Addresses by the verdict the system gave them at aggregation.
ListVerdictPass = "verdict_" + db.ResultPass
ListVerdictPartial = "verdict_" + db.ResultPartial
ListVerdictFail = "verdict_" + db.ResultFail
)
// 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 ListVerdictPass, ListVerdictPartial, ListVerdictFail:
verdict := strings.TrimPrefix(kind, "verdict_")
l.Columns = []string{"Адрес", "Подсеть", "Валидатор", "Egress", "Ingress", "Проверок в цикле"}
if kind == ListVerdictPartial {
l.Columns = append(l.Columns, "Причина")
}
for _, a := range an.sorted() {
if a.res.Verdict != verdict {
continue
}
row := []string{a.res.IPAddress, a.subnet, orDash(ShortValidator(a.https.validator)), okOf(a.egress), okOf(a.ingress), a.checksCell()}
if kind == ListVerdictPartial {
row = append(row, reasonName(a.egress.ok < a.egress.n, a.ingress.ok < a.ingress.n, a.incomplete()))
}
l.Rows = append(l.Rows, row)
}
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
}
// checksCell is "stored из expected", or just stored when expected is unknown.
func (a *addr) checksCell() string {
if a.res.ExpectedChecks < 0 {
return fmt.Sprint(a.stored)
}
return fmt.Sprintf("%d из %d", a.stored, a.res.ExpectedChecks)
}
// okOf is "successful из all" of one level, "—" when it has no checks.
func okOf(t typeStat) string {
if t.n == 0 {
return "—"
}
return fmt.Sprintf("%d из %d", t.ok, t.n)
}
func orDash(s string) string {
if s == "" {
return "—"
}
return s
}
// 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 { return lessIP(out[i].res.IPAddress, out[j].res.IPAddress) })
return out
}
// lessIP orders addresses numerically; text that is not an address by its text.
func lessIP(a, b string) bool {
x, errX := netip.ParseAddr(a)
y, errY := netip.ParseAddr(b)
if errX != nil || errY != nil {
return a < b
}
return x.Less(y)
}
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
}