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>
This commit is contained in:
1 parent
2f038f8362
commit
068c10ea1c
28 files changed
+2057
-138
No files matched your search
@@ -0,0 +1,432 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"cloudipvalidator/internal/db"
|
||||
)
|
||||
|
||||
// indicator is one of the seven counters of the analytics page. Its key is the
|
||||
// name of the list behind the counter; has repeats the condition Compute counts
|
||||
// it by (an address with a cancelled result is never asked).
|
||||
type indicator struct {
|
||||
key, name string
|
||||
has func(a *addr) bool
|
||||
}
|
||||
|
||||
var indicators = []indicator{
|
||||
{ListVerdictPass, "pass", func(a *addr) bool { return a.res.Verdict == db.ResultPass }},
|
||||
{ListVerdictPartial, "partial", func(a *addr) bool { return a.res.Verdict == db.ResultPartial }},
|
||||
{ListVerdictFail, "fail", func(a *addr) bool { return a.res.Verdict == db.ResultFail }},
|
||||
{ListEgressHTTPSAny, "Egress https: есть провалы", func(a *addr) bool { return a.https.n > 0 && a.https.ok < a.https.n }},
|
||||
{ListEgressHTTPSAll, "Egress https: все провалены", func(a *addr) bool { return a.https.n > 0 && a.https.ok == 0 }},
|
||||
{ListIngressSSHAny, "Ingress ssh: есть провалы", func(a *addr) bool { return a.ssh.n > 0 && a.ssh.ok < a.ssh.n }},
|
||||
{ListIngressSSHAll, "Ingress ssh: все провалены", func(a *addr) bool { return a.ssh.n > 0 && a.ssh.ok == 0 }},
|
||||
}
|
||||
|
||||
// indicatorIndex is the position of an indicator key in the table, -1 if unknown.
|
||||
func indicatorIndex(key string) int {
|
||||
for i, ind := range indicators {
|
||||
if ind.key == key {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// verdicts are the rows and columns of the transition matrix.
|
||||
var verdicts = []string{db.ResultPass, db.ResultPartial, db.ResultFail}
|
||||
|
||||
func verdictIndex(v string) int {
|
||||
for i, x := range verdicts {
|
||||
if x == v {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// membership is the set of indicators an address belongs to, one bit per
|
||||
// entry of the indicators table.
|
||||
func membership(a *addr) uint8 {
|
||||
var m uint8
|
||||
for i, ind := range indicators {
|
||||
if ind.has(a) {
|
||||
m |= 1 << i
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Comparison is the difference between two finished runs: the base (older) and
|
||||
// the target (newer) one. An address is its IP; one with a cancelled result is
|
||||
// not in its run.
|
||||
type Comparison struct {
|
||||
Runs CompareRuns `json:"runs"`
|
||||
Groups CompareGroups `json:"groups"`
|
||||
Indicators []IndicatorDiff `json:"indicators"`
|
||||
Transitions Transitions `json:"transitions"`
|
||||
Cancelled CompareCancel `json:"cancelled"`
|
||||
|
||||
rows []*cmpRow // numeric address order
|
||||
target *Analysis
|
||||
}
|
||||
|
||||
type CompareRuns struct {
|
||||
Base CompareRun `json:"base"`
|
||||
Target CompareRun `json:"target"`
|
||||
}
|
||||
|
||||
type CompareRun struct {
|
||||
RunInfo
|
||||
Addresses int `json:"addresses"`
|
||||
}
|
||||
|
||||
// CompareGroups counts the addresses by how they relate to the two runs:
|
||||
// New are only in the target, Left only in the base, Common in both, and
|
||||
// Common = Changed + Same.
|
||||
type CompareGroups struct {
|
||||
New int `json:"new"`
|
||||
Left int `json:"left"`
|
||||
Common int `json:"common"`
|
||||
Changed int `json:"changed"`
|
||||
Same int `json:"same"`
|
||||
}
|
||||
|
||||
// IndicatorDiff is one indicator in both runs. Delta = Target - Base =
|
||||
// New - Left + Entered - Exited.
|
||||
type IndicatorDiff struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Base int `json:"base"`
|
||||
Target int `json:"target"`
|
||||
Delta int `json:"delta"`
|
||||
New int `json:"new"` // new addresses that are in the indicator
|
||||
Left int `json:"left"` // left addresses that were in it
|
||||
Entered int `json:"entered"` // common addresses that entered it
|
||||
Exited int `json:"exited"` // common addresses that left it
|
||||
}
|
||||
|
||||
// Transitions is the verdict of the common addresses: Matrix[from][to] with
|
||||
// the verdicts of the base on the rows and of the target on the columns. New
|
||||
// holds the new addresses by their verdict in the target, Left the addresses
|
||||
// that left by their verdict in the base.
|
||||
type Transitions struct {
|
||||
Verdicts []string `json:"verdicts"`
|
||||
Matrix [][]int `json:"matrix"`
|
||||
New []int `json:"new"`
|
||||
Left []int `json:"left"`
|
||||
}
|
||||
|
||||
type CompareCancel struct {
|
||||
Base int `json:"base"`
|
||||
Target int `json:"target"`
|
||||
}
|
||||
|
||||
// cmpRow is one address with its state in each run (nil when absent) and the
|
||||
// indicators it belongs to there.
|
||||
type cmpRow struct {
|
||||
ip string
|
||||
a, b *addr
|
||||
ma, mb uint8
|
||||
}
|
||||
|
||||
func (r *cmpRow) common() bool { return r.a != nil && r.b != nil }
|
||||
func (r *cmpRow) changed() bool { return r.common() && r.ma != r.mb }
|
||||
|
||||
// Compare puts two analyses side by side; base is the older run, target the newer.
|
||||
func Compare(base, target *Analysis) *Comparison {
|
||||
byIP := map[string]*cmpRow{}
|
||||
var rows []*cmpRow
|
||||
add := func(list []*addr, isBase bool) {
|
||||
for _, x := range list {
|
||||
r := byIP[x.res.IPAddress]
|
||||
if r == nil {
|
||||
r = &cmpRow{ip: x.res.IPAddress}
|
||||
byIP[r.ip] = r
|
||||
rows = append(rows, r)
|
||||
}
|
||||
if isBase {
|
||||
r.a, r.ma = x, membership(x)
|
||||
} else {
|
||||
r.b, r.mb = x, membership(x)
|
||||
}
|
||||
}
|
||||
}
|
||||
add(base.sorted(), true)
|
||||
add(target.sorted(), false)
|
||||
sort.Slice(rows, func(i, j int) bool { return lessIP(rows[i].ip, rows[j].ip) })
|
||||
|
||||
c := &Comparison{rows: rows, target: target}
|
||||
c.Runs = CompareRuns{
|
||||
Base: CompareRun{RunInfo: base.Report.Run, Addresses: base.Report.Summary.Addresses},
|
||||
Target: CompareRun{RunInfo: target.Report.Run, Addresses: target.Report.Summary.Addresses},
|
||||
}
|
||||
c.Cancelled = CompareCancel{Base: base.Report.Summary.Cancelled, Target: target.Report.Summary.Cancelled}
|
||||
c.Indicators = make([]IndicatorDiff, len(indicators))
|
||||
for i, ind := range indicators {
|
||||
c.Indicators[i] = IndicatorDiff{Key: ind.key, Name: ind.name}
|
||||
}
|
||||
tr := &c.Transitions
|
||||
tr.Verdicts = verdicts
|
||||
tr.New, tr.Left = make([]int, len(verdicts)), make([]int, len(verdicts))
|
||||
tr.Matrix = make([][]int, len(verdicts))
|
||||
for i := range tr.Matrix {
|
||||
tr.Matrix[i] = make([]int, len(verdicts))
|
||||
}
|
||||
|
||||
for _, r := range rows {
|
||||
switch {
|
||||
case r.a == nil:
|
||||
c.Groups.New++
|
||||
if v := verdictIndex(r.b.res.Verdict); v >= 0 {
|
||||
tr.New[v]++
|
||||
}
|
||||
case r.b == nil:
|
||||
c.Groups.Left++
|
||||
if v := verdictIndex(r.a.res.Verdict); v >= 0 {
|
||||
tr.Left[v]++
|
||||
}
|
||||
default:
|
||||
c.Groups.Common++
|
||||
if r.changed() {
|
||||
c.Groups.Changed++
|
||||
} else {
|
||||
c.Groups.Same++
|
||||
}
|
||||
if from, to := verdictIndex(r.a.res.Verdict), verdictIndex(r.b.res.Verdict); from >= 0 && to >= 0 {
|
||||
tr.Matrix[from][to]++
|
||||
}
|
||||
}
|
||||
for i := range indicators {
|
||||
bit := uint8(1) << i
|
||||
inA, inB := r.ma&bit != 0, r.mb&bit != 0
|
||||
d := &c.Indicators[i]
|
||||
if inA {
|
||||
d.Base++
|
||||
}
|
||||
if inB {
|
||||
d.Target++
|
||||
}
|
||||
switch {
|
||||
case r.a == nil && inB:
|
||||
d.New++
|
||||
case r.b == nil && inA:
|
||||
d.Left++
|
||||
case r.common() && !inA && inB:
|
||||
d.Entered++
|
||||
case r.common() && inA && !inB:
|
||||
d.Exited++
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range c.Indicators {
|
||||
c.Indicators[i].Delta = c.Indicators[i].Target - c.Indicators[i].Base
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Groups served by Comparison.List.
|
||||
const (
|
||||
GroupNew = "new"
|
||||
GroupLeft = "left"
|
||||
GroupCommon = "common"
|
||||
GroupChanged = "changed"
|
||||
GroupSame = "same"
|
||||
GroupEntered = "entered"
|
||||
GroupExited = "exited"
|
||||
)
|
||||
|
||||
// CompareFilter narrows a group. Indicator is a list key of the indicator
|
||||
// table: for new and left the address is in it in its own run, for common,
|
||||
// changed and same in either run, for entered and exited it is required.
|
||||
// From and To, only together, keep the common addresses whose verdict was From
|
||||
// in the base and is To in the target.
|
||||
type CompareFilter struct {
|
||||
Indicator string
|
||||
From, To string
|
||||
}
|
||||
|
||||
// CompareList is a table of addresses of one group.
|
||||
type CompareList struct {
|
||||
Group string `json:"group"`
|
||||
Indicator string `json:"indicator,omitempty"`
|
||||
Columns []string `json:"columns"`
|
||||
Rows [][]string `json:"rows"`
|
||||
}
|
||||
|
||||
// List builds the table of a group; an unknown group, indicator or verdict, or
|
||||
// a filter that does not fit the group, is an ErrUnknownList.
|
||||
func (c *Comparison) List(group string, f CompareFilter) (*CompareList, error) {
|
||||
switch group {
|
||||
case GroupNew, GroupLeft, GroupCommon, GroupChanged, GroupSame, GroupEntered, GroupExited:
|
||||
default:
|
||||
return nil, ErrUnknownList(group)
|
||||
}
|
||||
var bit uint8
|
||||
if f.Indicator != "" {
|
||||
i := indicatorIndex(f.Indicator)
|
||||
if i < 0 {
|
||||
return nil, ErrUnknownList("indicator " + f.Indicator)
|
||||
}
|
||||
bit = 1 << i
|
||||
}
|
||||
if bit == 0 && (group == GroupEntered || group == GroupExited) {
|
||||
return nil, ErrUnknownList(group + " without indicator")
|
||||
}
|
||||
if (f.From != "") != (f.To != "") {
|
||||
return nil, ErrUnknownList("from without to")
|
||||
}
|
||||
if f.From != "" {
|
||||
if verdictIndex(f.From) < 0 || verdictIndex(f.To) < 0 {
|
||||
return nil, ErrUnknownList("verdict " + f.From + " → " + f.To)
|
||||
}
|
||||
if group == GroupNew || group == GroupLeft {
|
||||
return nil, ErrUnknownList("from and to for " + group)
|
||||
}
|
||||
}
|
||||
|
||||
l := &CompareList{Group: group, Indicator: f.Indicator, Rows: [][]string{}}
|
||||
if group == GroupNew || group == GroupLeft {
|
||||
l.Columns = []string{"Адрес", "Подсеть", "Вердикт", "Egress", "Ingress", "Индикаторы"}
|
||||
} else {
|
||||
l.Columns = []string{"Адрес", "Подсеть", "Вердикт (A → B)", "Egress (A → B)", "Ingress (A → B)", "Что изменилось"}
|
||||
}
|
||||
for _, r := range c.rows {
|
||||
var ok bool
|
||||
switch group {
|
||||
case GroupNew:
|
||||
ok = r.a == nil && (bit == 0 || r.mb&bit != 0)
|
||||
case GroupLeft:
|
||||
ok = r.b == nil && (bit == 0 || r.ma&bit != 0)
|
||||
case GroupCommon:
|
||||
ok = r.common()
|
||||
case GroupChanged:
|
||||
ok = r.changed()
|
||||
case GroupSame:
|
||||
ok = r.common() && !r.changed()
|
||||
case GroupEntered:
|
||||
ok = r.common() && r.ma&bit == 0 && r.mb&bit != 0
|
||||
case GroupExited:
|
||||
ok = r.common() && r.ma&bit != 0 && r.mb&bit == 0
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if r.common() && bit != 0 && group != GroupEntered && group != GroupExited && (r.ma|r.mb)&bit == 0 {
|
||||
continue
|
||||
}
|
||||
if f.From != "" && (r.a.res.Verdict != f.From || r.b.res.Verdict != f.To) {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case r.a == nil:
|
||||
l.Rows = append(l.Rows, []string{r.ip, r.b.subnet, r.b.res.Verdict, okOf(r.b.egress), okOf(r.b.ingress), indicatorsCell(r.mb)})
|
||||
case r.b == nil:
|
||||
l.Rows = append(l.Rows, []string{r.ip, r.a.subnet, r.a.res.Verdict, okOf(r.a.egress), okOf(r.a.ingress), indicatorsCell(r.ma)})
|
||||
default:
|
||||
l.Rows = append(l.Rows, []string{r.ip, r.b.subnet, arrow(r.a.res.Verdict, r.b.res.Verdict),
|
||||
arrow(okOf(r.a.egress), okOf(r.b.egress)), arrow(okOf(r.a.ingress), okOf(r.b.ingress)), c.changeText(r)})
|
||||
}
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func arrow(from, to string) string { return from + " → " + to }
|
||||
|
||||
// indicatorsCell names the indicators of a membership set, "—" for none.
|
||||
func indicatorsCell(m uint8) string {
|
||||
var names []string
|
||||
for i, ind := range indicators {
|
||||
if m&(1<<i) != 0 {
|
||||
names = append(names, ind.name)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return "—"
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// changeText says step by step what differs between the two runs for a common
|
||||
// address. An address with the same indicators is "без изменений" even if its
|
||||
// failed targets or sites differ.
|
||||
func (c *Comparison) changeText(r *cmpRow) string {
|
||||
if r.ma == r.mb {
|
||||
return "без изменений"
|
||||
}
|
||||
var steps []string
|
||||
if r.a.res.Verdict != r.b.res.Verdict {
|
||||
steps = append(steps, "вердикт "+arrow(r.a.res.Verdict, r.b.res.Verdict))
|
||||
}
|
||||
var in, out []string
|
||||
for i, ind := range indicators {
|
||||
if strings.HasPrefix(ind.key, "verdict_") {
|
||||
continue // the verdict step says it
|
||||
}
|
||||
bit := uint8(1) << i
|
||||
switch {
|
||||
case r.ma&bit == 0 && r.mb&bit != 0:
|
||||
in = append(in, ind.name)
|
||||
case r.ma&bit != 0 && r.mb&bit == 0:
|
||||
out = append(out, ind.name)
|
||||
}
|
||||
}
|
||||
if len(in) > 0 {
|
||||
steps = append(steps, "вошёл в: "+strings.Join(in, ", "))
|
||||
}
|
||||
if len(out) > 0 {
|
||||
steps = append(steps, "вышел из: "+strings.Join(out, ", "))
|
||||
}
|
||||
if added, removed := setDiff(r.a.https.failedTargets, r.b.https.failedTargets, sortedStrings); len(added)+len(removed) > 0 {
|
||||
steps = append(steps, "https: провалены цели "+signed(added, removed))
|
||||
}
|
||||
if added, removed := setDiff(r.a.ssh.sites, r.b.ssh.sites, c.target.sortSites); len(added)+len(removed) > 0 {
|
||||
steps = append(steps, "ssh: площадки "+signed(added, removed))
|
||||
}
|
||||
if was, now := r.a.https.validator, r.b.https.validator; was != "" && now != "" && was != now {
|
||||
steps = append(steps, "валидатор "+arrow(ShortValidator(was), ShortValidator(now)))
|
||||
}
|
||||
return strings.Join(steps, "; ")
|
||||
}
|
||||
|
||||
// setDiff is what is in now and not in was, and the reverse, each ordered by order.
|
||||
func setDiff(was, now []string, order func([]string) []string) (added, removed []string) {
|
||||
in := func(list []string) map[string]bool {
|
||||
m := make(map[string]bool, len(list))
|
||||
for _, s := range list {
|
||||
m[s] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
w, n := in(was), in(now)
|
||||
for s := range n {
|
||||
if !w[s] {
|
||||
added = append(added, s)
|
||||
}
|
||||
}
|
||||
for s := range w {
|
||||
if !n[s] {
|
||||
removed = append(removed, s)
|
||||
}
|
||||
}
|
||||
return order(added), order(removed)
|
||||
}
|
||||
|
||||
func sortedStrings(s []string) []string {
|
||||
sort.Strings(s)
|
||||
return s
|
||||
}
|
||||
|
||||
// signed writes a set difference as "+new1 +new2 −gone1".
|
||||
func signed(added, removed []string) string {
|
||||
parts := make([]string, 0, len(added)+len(removed))
|
||||
for _, s := range added {
|
||||
parts = append(parts, "+"+s)
|
||||
}
|
||||
for _, s := range removed {
|
||||
parts = append(parts, "−"+s)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
Reference in new issue
Block a user