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:
ayurishchevandClaude Sonnet 5.5 committed 2026-10-04 10:26:37 +03:00
1 parent 2f038f8362
commit 068c10ea1c
28 files changed
+2057 -138

No files matched your search

+432
View File
@@ -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, " ")
}
+335
View File
@@ -0,0 +1,335 @@
package analytics
import (
"reflect"
"testing"
"cloudipvalidator/internal/db"
)
// set stores the four checks of an address: https to a.test and b.test, ssh
// from the two sites.
func (f *fixture) set(reg int64, validator string, a, b, ssh1, ssh2 bool) {
f.check(reg, eg, "https", "https://a.test", a, validator, "", false)
f.check(reg, eg, "https", "https://b.test", b, validator, "", false)
f.check(reg, s1, "ssh", "ip", ssh1, validator, "dial tcp: i/o timeout", false)
f.check(reg, s2, "ssh", "ip", ssh2, validator, "dial tcp: i/o timeout", false)
}
// comparedRuns builds the older and the newer run:
//
// 10.0.0.1 pass -> pass, nothing changed
// 10.0.0.2 partial -> pass: https recovered on both targets, another validator
// 10.0.0.3 pass -> partial: ssh fails from rxmsk
// 10.0.0.20 partial -> partial: another https target fails, same indicators
// 10.0.0.10 only in the old run (fail)
// 10.0.0.14 only in the old run (pass), cancelled in the new one
// 10.0.0.11 only in the new run (partial, https fails everywhere)
// 10.0.0.12 only in the new run (pass)
// 10.0.0.13 only in the new run (pass), cancelled in the old one
func comparedRuns(t *testing.T) (base, target *Analysis) {
t.Helper()
fa, fb := &fixture{}, &fixture{}
fa.addr(1, "10.0.0.1", db.ResultPass, 4)
fa.set(1, "vkiplab-v1", true, true, true, true)
fa.addr(2, "10.0.0.2", db.ResultPartial, 4)
fa.set(2, "vkiplab-v2", false, false, true, true)
fa.addr(3, "10.0.0.3", db.ResultPass, 4)
fa.set(3, "vkiplab-v3", true, true, true, true)
fa.addr(4, "10.0.0.20", db.ResultPartial, 4)
fa.set(4, "vkiplab-v4", false, true, true, true)
fa.addr(5, "10.0.0.10", db.ResultFail, 4)
fa.set(5, "vkiplab-v5", false, false, false, false)
fa.addr(6, "10.0.0.13", db.ResultCancelled, 4)
fa.set(6, "vkiplab-v6", true, true, true, true)
fa.addr(7, "10.0.0.14", db.ResultPass, 4)
fa.set(7, "vkiplab-v7", true, true, true, true)
fb.addr(1, "10.0.0.1", db.ResultPass, 4)
fb.set(1, "vkiplab-v1", true, true, true, true)
fb.addr(2, "10.0.0.2", db.ResultPass, 4)
fb.set(2, "vkiplab-v5", true, true, true, true)
fb.addr(3, "10.0.0.3", db.ResultPartial, 4)
fb.set(3, "vkiplab-v3", true, true, false, true)
fb.addr(4, "10.0.0.20", db.ResultPartial, 4)
fb.set(4, "vkiplab-v4", true, false, true, true)
fb.addr(8, "10.0.0.11", db.ResultPartial, 4)
fb.set(8, "vkiplab-v6", false, false, true, true)
fb.addr(9, "10.0.0.12", db.ResultPass, 4)
fb.set(9, "vkiplab-v6", true, true, true, true)
fb.addr(10, "10.0.0.13", db.ResultPass, 4)
fb.set(10, "vkiplab-v6", true, true, true, true)
fb.addr(11, "10.0.0.14", db.ResultCancelled, 4)
fb.set(11, "vkiplab-v7", true, true, true, true)
return fa.compute(t, nil), fb.compute(t, nil)
}
func ips(l *CompareList) []string {
out := []string{}
for _, r := range l.Rows {
out = append(out, r[0])
}
return out
}
func mustList(t *testing.T, c *Comparison, group string, f CompareFilter) *CompareList {
t.Helper()
l, err := c.List(group, f)
if err != nil {
t.Fatalf("%s %+v: %v", group, f, err)
}
for _, r := range l.Rows {
if len(r) != len(l.Columns) {
t.Errorf("%s: row %v for columns %v", group, r, l.Columns)
}
}
return l
}
func TestCompareGroupsAndIndicators(t *testing.T) {
base, target := comparedRuns(t)
c := Compare(base, target)
if want := (CompareGroups{New: 3, Left: 2, Common: 4, Changed: 2, Same: 2}); c.Groups != want {
t.Errorf("groups = %+v, want %+v", c.Groups, want)
}
// Every address is in exactly one of new, left, common; common = changed + same.
if c.Groups.New+c.Groups.Common != target.Report.Summary.Addresses || c.Groups.Left+c.Groups.Common != base.Report.Summary.Addresses ||
c.Groups.Changed+c.Groups.Same != c.Groups.Common {
t.Errorf("groups do not add up: %+v, runs %d and %d addresses", c.Groups, base.Report.Summary.Addresses, target.Report.Summary.Addresses)
}
if c.Cancelled != (CompareCancel{Base: 1, Target: 1}) {
t.Errorf("cancelled = %+v", c.Cancelled)
}
if c.Runs.Base.Addresses != 6 || c.Runs.Target.Addresses != 7 || c.Runs.Base.ID != 7 {
t.Errorf("runs = %+v", c.Runs)
}
// The count of each indicator in each run is the number of its card.
sa, sb := base.Report.Summary, target.Report.Summary
cards := map[string][2]int{
ListVerdictPass: {sa.Pass, sb.Pass}, ListVerdictPartial: {sa.Partial, sb.Partial}, ListVerdictFail: {sa.Fail, sb.Fail},
ListEgressHTTPSAny: {sa.EgressHTTPSAny, sb.EgressHTTPSAny}, ListEgressHTTPSAll: {sa.EgressHTTPSAll, sb.EgressHTTPSAll},
ListIngressSSHAny: {sa.IngressSSHAny, sb.IngressSSHAny}, ListIngressSSHAll: {sa.IngressSSHAll, sb.IngressSSHAll},
}
if len(c.Indicators) != len(cards) {
t.Fatalf("%d indicators", len(c.Indicators))
}
for _, d := range c.Indicators {
if want := cards[d.Key]; d.Base != want[0] || d.Target != want[1] {
t.Errorf("%s: %d -> %d, summaries say %v", d.Key, d.Base, d.Target, want)
}
if d.Delta != d.Target-d.Base || d.Delta != d.New-d.Left+d.Entered-d.Exited {
t.Errorf("%s: delta %d, new %d left %d entered %d exited %d", d.Key, d.Delta, d.New, d.Left, d.Entered, d.Exited)
}
}
want := map[string]IndicatorDiff{
ListVerdictPass: {Base: 3, Target: 4, Delta: 1, New: 2, Left: 1, Entered: 1, Exited: 1},
ListVerdictPartial: {Base: 2, Target: 3, Delta: 1, New: 1, Entered: 1, Exited: 1},
ListVerdictFail: {Base: 1, Target: 0, Delta: -1, Left: 1},
ListEgressHTTPSAny: {Base: 3, Target: 2, Delta: -1, New: 1, Left: 1, Exited: 1},
ListEgressHTTPSAll: {Base: 2, Target: 1, Delta: -1, New: 1, Left: 1, Exited: 1},
ListIngressSSHAny: {Base: 1, Target: 1, Left: 1, Entered: 1},
ListIngressSSHAll: {Base: 1, Target: 0, Delta: -1, Left: 1},
}
for _, d := range c.Indicators {
w := want[d.Key]
w.Key, w.Name = d.Key, d.Name
if d != w || d.Name == "" {
t.Errorf("%s = %+v, want %+v", d.Key, d, w)
}
}
// Verdicts of the common addresses, and of the new and of the left ones.
tr := c.Transitions
if !reflect.DeepEqual(tr.Verdicts, []string{"pass", "partial", "fail"}) ||
!reflect.DeepEqual(tr.Matrix, [][]int{{1, 1, 0}, {1, 1, 0}, {0, 0, 0}}) ||
!reflect.DeepEqual(tr.New, []int{2, 1, 0}) || !reflect.DeepEqual(tr.Left, []int{1, 0, 1}) {
t.Errorf("transitions = %+v", tr)
}
}
func TestCompareLists(t *testing.T) {
base, target := comparedRuns(t)
c := Compare(base, target)
// New and left: the state in the run the address is in; numeric order.
l := mustList(t, c, GroupNew, CompareFilter{})
if want := []string{"Адрес", "Подсеть", "Вердикт", "Egress", "Ingress", "Индикаторы"}; !reflect.DeepEqual(l.Columns, want) {
t.Errorf("new columns: %v", l.Columns)
}
if got := ips(l); !reflect.DeepEqual(got, []string{"10.0.0.11", "10.0.0.12", "10.0.0.13"}) {
t.Errorf("new: %v", got)
}
if want := []string{"10.0.0.11", "10.0.0.0/24", "partial", "0 из 2", "2 из 2", "partial, Egress https: есть провалы, Egress https: все провалены"}; !reflect.DeepEqual(l.Rows[0], want) {
t.Errorf("new row: %v", l.Rows[0])
}
if got := ips(mustList(t, c, GroupNew, CompareFilter{Indicator: ListVerdictPass})); !reflect.DeepEqual(got, []string{"10.0.0.12", "10.0.0.13"}) {
t.Errorf("new, pass: %v", got)
}
if got := ips(mustList(t, c, GroupNew, CompareFilter{Indicator: ListEgressHTTPSAll})); !reflect.DeepEqual(got, []string{"10.0.0.11"}) {
t.Errorf("new, https all: %v", got)
}
l = mustList(t, c, GroupLeft, CompareFilter{})
if got := ips(l); !reflect.DeepEqual(got, []string{"10.0.0.10", "10.0.0.14"}) {
t.Errorf("left: %v", got)
}
if want := "fail"; l.Rows[0][2] != want || l.Rows[0][5] != "fail, Egress https: есть провалы, Egress https: все провалены, Ingress ssh: есть провалы, Ingress ssh: все провалены" {
t.Errorf("left row: %v", l.Rows[0])
}
if got := ips(mustList(t, c, GroupLeft, CompareFilter{Indicator: ListVerdictPass})); !reflect.DeepEqual(got, []string{"10.0.0.14"}) {
t.Errorf("left, pass: %v", got)
}
// Changed: what changed, step by step.
l = mustList(t, c, GroupChanged, CompareFilter{})
if got := ips(l); !reflect.DeepEqual(got, []string{"10.0.0.2", "10.0.0.3"}) {
t.Fatalf("changed: %v", got)
}
if l.Columns[2] != "Вердикт (A → B)" || l.Columns[5] != "Что изменилось" {
t.Errorf("changed columns: %v", l.Columns)
}
if want := []string{"10.0.0.2", "10.0.0.0/24", "partial → pass", "0 из 2 → 2 из 2", "2 из 2 → 2 из 2",
"вердикт partial → pass; вышел из: Egress https: есть провалы, Egress https: все провалены; https: провалены цели −a.test −b.test; валидатор v2 → v5"}; !reflect.DeepEqual(l.Rows[0], want) {
t.Errorf("changed row 1: %v", l.Rows[0])
}
if want := []string{"10.0.0.3", "10.0.0.0/24", "pass → partial", "2 из 2 → 2 из 2", "2 из 2 → 1 из 2",
"вердикт pass → partial; вошёл в: Ingress ssh: есть провалы; ssh: площадки +rxmsk"}; !reflect.DeepEqual(l.Rows[1], want) {
t.Errorf("changed row 2: %v", l.Rows[1])
}
// A filter by indicator keeps the addresses that are in it in either run.
if got := ips(mustList(t, c, GroupChanged, CompareFilter{Indicator: ListIngressSSHAny})); !reflect.DeepEqual(got, []string{"10.0.0.3"}) {
t.Errorf("changed, ssh any: %v", got)
}
if got := ips(mustList(t, c, GroupChanged, CompareFilter{Indicator: ListEgressHTTPSAll})); !reflect.DeepEqual(got, []string{"10.0.0.2"}) {
t.Errorf("changed, https all: %v", got)
}
// Same: another failed target does not change the indicators; numeric order (20 after 1).
l = mustList(t, c, GroupSame, CompareFilter{})
if got := ips(l); !reflect.DeepEqual(got, []string{"10.0.0.1", "10.0.0.20"}) {
t.Fatalf("same: %v", got)
}
if want := []string{"10.0.0.20", "10.0.0.0/24", "partial → partial", "1 из 2 → 1 из 2", "2 из 2 → 2 из 2", "без изменений"}; !reflect.DeepEqual(l.Rows[1], want) {
t.Errorf("same row: %v", l.Rows[1])
}
if got := ips(mustList(t, c, GroupSame, CompareFilter{Indicator: ListEgressHTTPSAny})); !reflect.DeepEqual(got, []string{"10.0.0.20"}) {
t.Errorf("same, https any: %v", got)
}
// Common is changed + same, in address order.
l = mustList(t, c, GroupCommon, CompareFilter{})
if got := ips(l); !reflect.DeepEqual(got, []string{"10.0.0.1", "10.0.0.2", "10.0.0.3", "10.0.0.20"}) || l.Rows[3][5] != "без изменений" {
t.Errorf("common: %v", l.Rows)
}
// Entered and exited need an indicator.
for _, x := range []struct {
group, ind string
want []string
}{
{GroupEntered, ListVerdictPass, []string{"10.0.0.2"}},
{GroupExited, ListVerdictPass, []string{"10.0.0.3"}},
{GroupEntered, ListVerdictPartial, []string{"10.0.0.3"}},
{GroupEntered, ListIngressSSHAny, []string{"10.0.0.3"}},
{GroupExited, ListEgressHTTPSAll, []string{"10.0.0.2"}},
{GroupExited, ListEgressHTTPSAny, []string{"10.0.0.2"}},
{GroupEntered, ListEgressHTTPSAny, []string{}},
{GroupEntered, ListVerdictFail, []string{}},
} {
got := ips(mustList(t, c, x.group, CompareFilter{Indicator: x.ind}))
if !reflect.DeepEqual(got, x.want) {
t.Errorf("%s %s: %v, want %v", x.group, x.ind, got, x.want)
}
}
// A cell of the transition matrix: the verdict in the old and in the new run.
for _, x := range []struct {
group, from, to string
want []string
}{
{GroupCommon, "partial", "pass", []string{"10.0.0.2"}},
{GroupCommon, "pass", "partial", []string{"10.0.0.3"}},
{GroupCommon, "pass", "pass", []string{"10.0.0.1"}},
{GroupCommon, "partial", "partial", []string{"10.0.0.20"}},
{GroupSame, "partial", "partial", []string{"10.0.0.20"}},
{GroupChanged, "pass", "pass", []string{}},
{GroupCommon, "fail", "pass", []string{}},
} {
got := ips(mustList(t, c, x.group, CompareFilter{From: x.from, To: x.to}))
if !reflect.DeepEqual(got, x.want) {
t.Errorf("%s %s -> %s: %v, want %v", x.group, x.from, x.to, got, x.want)
}
}
// The cells of the matrix and the lists add up.
n := 0
for _, from := range verdicts {
for _, to := range verdicts {
n += len(mustList(t, c, GroupCommon, CompareFilter{From: from, To: to}).Rows)
}
}
if n != c.Groups.Common {
t.Errorf("matrix cells hold %d addresses, %d are common", n, c.Groups.Common)
}
// The size of every list equals its number in the report.
for _, d := range c.Indicators {
for group, want := range map[string]int{GroupNew: d.New, GroupLeft: d.Left, GroupEntered: d.Entered, GroupExited: d.Exited} {
if got := len(mustList(t, c, group, CompareFilter{Indicator: d.Key}).Rows); got != want {
t.Errorf("%s %s: %d rows, report says %d", group, d.Key, got, want)
}
}
}
for group, want := range map[string]int{GroupNew: c.Groups.New, GroupLeft: c.Groups.Left, GroupCommon: c.Groups.Common, GroupChanged: c.Groups.Changed, GroupSame: c.Groups.Same} {
if got := len(mustList(t, c, group, CompareFilter{}).Rows); got != want {
t.Errorf("%s: %d rows, report says %d", group, got, want)
}
}
}
func TestCompareListErrors(t *testing.T) {
base, target := comparedRuns(t)
c := Compare(base, target)
for name, x := range map[string]struct {
group string
f CompareFilter
}{
"unknown group": {"nonsense", CompareFilter{}},
"unknown indicator": {GroupNew, CompareFilter{Indicator: "nonsense"}},
"entered needs one": {GroupEntered, CompareFilter{}},
"exited needs one": {GroupExited, CompareFilter{}},
"from without to": {GroupCommon, CompareFilter{From: "pass"}},
"to without from": {GroupCommon, CompareFilter{To: "pass"}},
"unknown verdict": {GroupCommon, CompareFilter{From: "pass", To: "cancelled"}},
"verdicts of new addrs": {GroupNew, CompareFilter{From: "pass", To: "pass"}},
"verdicts of left addrs": {GroupLeft, CompareFilter{From: "pass", To: "pass"}},
} {
l, err := c.List(x.group, x.f)
if _, ok := err.(ErrUnknownList); !ok || l != nil {
t.Errorf("%s: %v %v", name, l, err)
}
}
}
// A run compared with itself: nothing new, nothing left, nothing changed, and
// the empty lists are empty tables, not nil.
func TestCompareWithItself(t *testing.T) {
base, _ := comparedRuns(t)
c := Compare(base, base)
if want := (CompareGroups{Common: 6, Same: 6}); c.Groups != want {
t.Errorf("groups = %+v", c.Groups)
}
for _, d := range c.Indicators {
if d.Delta != 0 || d.New != 0 || d.Left != 0 || d.Entered != 0 || d.Exited != 0 || d.Base != d.Target {
t.Errorf("%+v", d)
}
}
for _, group := range []string{GroupNew, GroupLeft, GroupChanged} {
if l := mustList(t, c, group, CompareFilter{}); l.Rows == nil || len(l.Rows) != 0 {
t.Errorf("%s: %#v", group, l.Rows)
}
}
if !reflect.DeepEqual(c.Transitions.Matrix, [][]int{{3, 0, 0}, {0, 2, 0}, {0, 0, 1}}) {
t.Errorf("matrix = %v", c.Transitions.Matrix)
}
}
+11 -8
View File
@@ -149,17 +149,20 @@ func (an *Analysis) sorted() []*addr {
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)
})
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 {
+48 -1
View File
@@ -456,7 +456,11 @@ func (c *client) GetAnalyticsList(ctx context.Context, runID int64, kind, class
// GetAnalyticsListCSV returns the CSV file of one address table, with the
// file name control-api proposed.
func (c *client) GetAnalyticsListCSV(ctx context.Context, runID int64, kind, class string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+analyticsListPath(runID, kind, class, true), nil)
return c.getCSV(ctx, analyticsListPath(runID, kind, class, true))
}
func (c *client) getCSV(ctx context.Context, path string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return nil, "", fmt.Errorf("build request: %w", err)
}
@@ -480,6 +484,49 @@ func (c *client) GetAnalyticsListCSV(ctx context.Context, runID int64, kind, cla
return body, resp.Header.Get("Content-Disposition"), nil
}
// compareFilter narrows one list of the comparison of two runs.
type compareFilter struct{ Indicator, From, To string }
func analyticsCompareQuery(base, target int64) url.Values {
return url.Values{"base": {strconv.FormatInt(base, 10)}, "target": {strconv.FormatInt(target, 10)}}
}
func analyticsCompareListPath(base, target int64, group string, f compareFilter, csv bool) string {
v := analyticsCompareQuery(base, target)
if f.Indicator != "" {
v.Set("indicator", f.Indicator)
}
if f.From != "" || f.To != "" {
v.Set("from", f.From)
v.Set("to", f.To)
}
if csv {
v.Set("format", "csv")
}
return "/api/v1/admin/analytics/compare/lists/" + url.PathEscape(group) + "?" + v.Encode()
}
// GetAnalyticsCompare returns the comparison of two finished runs (base is the
// older one) as the raw JSON control-api computed.
func (c *client) GetAnalyticsCompare(ctx context.Context, base, target int64) (json.RawMessage, error) {
var out json.RawMessage
err := c.do(ctx, http.MethodGet, "/api/v1/admin/analytics/compare?"+analyticsCompareQuery(base, target).Encode(), nil, &out)
return out, err
}
// GetAnalyticsCompareList returns one address table (JSON) of the comparison.
func (c *client) GetAnalyticsCompareList(ctx context.Context, base, target int64, group string, f compareFilter) (json.RawMessage, error) {
var out json.RawMessage
err := c.do(ctx, http.MethodGet, analyticsCompareListPath(base, target, group, f, false), nil, &out)
return out, err
}
// GetAnalyticsCompareListCSV returns the CSV file of one comparison table,
// with the file name control-api proposed.
func (c *client) GetAnalyticsCompareListCSV(ctx context.Context, base, target int64, group string, f compareFilter) ([]byte, string, error) {
return c.getCSV(ctx, analyticsCompareListPath(base, target, group, f, true))
}
// subnetEntry is one line of the subnet list (GET/PUT /admin/config/subnets).
type subnetEntry struct {
CIDR string `json:"cidr"`
+43 -3
View File
@@ -44,9 +44,14 @@ type fakeControlAPI struct {
// Analytics: the run selector, the report JSON per run, the lists per
// "run/kind[/class]" and the subnet list of /settings.
runs []analyticsRun
reports map[int64]string
lists map[string]string
runs []analyticsRun
reports map[int64]string
lists map[string]string
// compares is the comparison JSON per "base-target", compareLists the lists
// per "base-target/group[/indicator]".
compares map[string]string
compareLists map[string]string
subnets subnetList
analyticsReqs []string
// scanFreeAddresses is what POST /ips/scan "discovers" — tests set it
@@ -560,6 +565,41 @@ func (f *fakeControlAPI) handler() http.Handler {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(l))
})
mux.HandleFunc("GET /api/v1/admin/analytics/compare", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
rep, ok := f.compares[r.URL.Query().Get("base")+"-"+r.URL.Query().Get("target")]
if !ok {
writeAPIErr(w, http.StatusNotFound, "run not found")
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(rep))
})
mux.HandleFunc("GET /api/v1/admin/analytics/compare/lists/{group}", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
q := r.URL.Query()
key := q.Get("base") + "-" + q.Get("target") + "/" + r.PathValue("group")
if ind := q.Get("indicator"); ind != "" {
key += "/" + ind
}
l, ok := f.compareLists[key]
if !ok {
writeAPIErr(w, http.StatusNotFound, "unknown list")
return
}
if q.Get("format") == "csv" {
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="compare_`+r.PathValue("group")+`_run`+q.Get("base")+"-"+q.Get("target")+`.csv"`)
_, _ = w.Write([]byte("Адрес\r\n1.2.3.4\r\n"))
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(l))
})
mux.HandleFunc("GET /api/v1/admin/config/inbound-checks", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
+205
View File
@@ -182,6 +182,211 @@ func (s *Server) handleAnalyticsCSV(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(body)
}
// analyticsComparePageData is the page that compares two finished runs.
type analyticsComparePageData struct {
PageData
RunsA, RunsB []runOption // finished runs; the chosen one of each is selected
HasReport bool
// DataJSON is the page's data for analytics-compare.js (run meta, the
// comparison), HTML-safe JSON.
DataJSON template.JS
}
// compareRunMeta is how analytics-compare.js names one of the two runs.
type compareRunMeta struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
Start string `json:"start"`
End string `json:"end"`
Duration string `json:"duration"`
Label string `json:"label"`
}
type compareMeta struct {
Base compareRunMeta `json:"base"`
Target compareRunMeta `json:"target"`
ListURL string `json:"list_url"`
CSVURL string `json:"csv_url"`
}
func newCompareRunMeta(x analyticsRun) compareRunMeta {
m := compareRunMeta{ID: x.ID, Kind: kindLabel(x.Kind), Start: fmtShort(x.StartedAt), End: "—", Duration: "—", Label: runLabel(x)}
if x.FinalizedAt != nil {
m.End = fmtShort(*x.FinalizedAt)
m.Duration = fmtRunDuration(x.FinalizedAt.Sub(x.StartedAt))
}
return m
}
// runIDParam reads a run id from a query value: set is false for an empty
// value, ok for a positive number.
func runIDParam(v string) (id int64, set, ok bool) {
if v == "" {
return 0, false, true
}
id, err := strconv.ParseInt(v, 10, 64)
return id, true, err == nil && id > 0
}
// handleAnalyticsComparePage compares two finished runs: ?base=A (older) and
// ?target=B (newer). By default B is the newest finished run and A the one
// before it; with only target the run before it is the base, with only base
// the newest other run is the target. The state of the page is its address.
func (s *Server) handleAnalyticsComparePage(w http.ResponseWriter, r *http.Request) {
data := analyticsComparePageData{}
data.ActiveNav = "analytics"
runs, err := s.CA.ListAnalyticsRuns(r.Context())
if err != nil {
data.Banner = bannerFor(err)
s.renderPage(w, r, "analytics_compare_page", data)
return
}
var finished []analyticsRun // newest first
for _, x := range runs {
if x.State == "finalized" && x.Addresses > 0 {
finished = append(finished, x)
}
}
if len(finished) < 2 {
data.Banner = bannerData{Message: "Для сравнения нужно минимум два завершённых запуска с адресами.", Client: true}
s.renderPage(w, r, "analytics_compare_page", data)
return
}
find := func(id int64) *analyticsRun {
for i := range finished {
if finished[i].ID == id {
return &finished[i]
}
}
return nil
}
bid, bset, bok := runIDParam(r.URL.Query().Get("base"))
tid, tset, tok := runIDParam(r.URL.Query().Get("target"))
// The default pair is the newest run as B and the one before it as A.
a, b := &finished[1], &finished[0]
warn := ""
switch {
case !bok || !tok:
warn = "Неверный номер запуска."
case bset && tset && bid == tid:
warn = "Выберите два разных запуска."
case bset || tset:
a, b = nil, nil
if bset {
if a = find(bid); a == nil {
warn = fmt.Sprintf("Запуск %d не найден или ещё не завершён.", bid)
}
}
if tset {
if b = find(tid); b == nil {
warn = fmt.Sprintf("Запуск %d не найден или ещё не завершён.", tid)
}
}
if warn == "" {
for i := range finished { // only one end is given: the other is next to it
switch {
case a == nil && i > 0 && finished[i-1].ID == b.ID:
a = &finished[i]
case b == nil && finished[i].ID != a.ID:
b = &finished[i]
}
}
if a == nil || b == nil {
warn = "Для этого запуска нет второго запуска для сравнения."
}
}
}
selA, selB := finished[1].ID, finished[0].ID
if a != nil {
selA = a.ID
}
if b != nil {
selB = b.ID
}
for _, x := range finished {
opt := runOption{ID: x.ID, Label: runLabel(x)}
optA, optB := opt, opt
optA.Selected, optB.Selected = x.ID == selA, x.ID == selB
data.RunsA = append(data.RunsA, optA)
data.RunsB = append(data.RunsB, optB)
}
if warn != "" {
data.Banner = bannerData{Message: warn, Client: true}
s.renderPage(w, r, "analytics_compare_page", data)
return
}
report, err := s.CA.GetAnalyticsCompare(r.Context(), a.ID, b.ID)
if err != nil {
data.Banner = bannerFor(err)
s.renderPage(w, r, "analytics_compare_page", data)
return
}
payload, err := json.Marshal(struct {
Meta compareMeta `json:"meta"`
Report json.RawMessage `json:"report"`
}{compareMeta{Base: newCompareRunMeta(*a), Target: newCompareRunMeta(*b),
ListURL: "/analytics/compare/lists/", CSVURL: "/analytics/compare/csv/"}, report})
if err != nil {
data.Banner = bannerFor(err)
s.renderPage(w, r, "analytics_compare_page", data)
return
}
data.HasReport = true
data.DataJSON = template.JS(payload)
s.renderPage(w, r, "analytics_compare_page", data)
}
// compareParams reads the two run ids and the list filter of a proxied
// comparison list; it writes the 400 itself when an id is missing.
func compareParams(w http.ResponseWriter, r *http.Request) (base, target int64, f compareFilter, ok bool) {
q := r.URL.Query()
base, _ = strconv.ParseInt(q.Get("base"), 10, 64)
target, _ = strconv.ParseInt(q.Get("target"), 10, 64)
if base <= 0 || target <= 0 {
http.Error(w, "base and target are required", http.StatusBadRequest)
return 0, 0, f, false
}
return base, target, compareFilter{Indicator: q.Get("indicator"), From: q.Get("from"), To: q.Get("to")}, true
}
// handleAnalyticsCompareList proxies one address table of the comparison as JSON.
func (s *Server) handleAnalyticsCompareList(w http.ResponseWriter, r *http.Request) {
base, target, f, ok := compareParams(w, r)
if !ok {
return
}
out, err := s.CA.GetAnalyticsCompareList(r.Context(), base, target, r.PathValue("group"), f)
if err != nil {
writeProxyError(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(out)
}
// handleAnalyticsCompareCSV proxies the CSV file of one comparison table as a download.
func (s *Server) handleAnalyticsCompareCSV(w http.ResponseWriter, r *http.Request) {
base, target, f, ok := compareParams(w, r)
if !ok {
return
}
body, disposition, err := s.CA.GetAnalyticsCompareListCSV(r.Context(), base, target, r.PathValue("group"), f)
if err != nil {
writeProxyError(w, err)
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
if disposition != "" {
w.Header().Set("Content-Disposition", disposition)
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
func writeProxyError(w http.ResponseWriter, err error) {
status := http.StatusBadGateway
if ae, ok := err.(*apiErr); ok && ae.Status >= 400 && ae.Status < 500 {
@@ -148,6 +148,188 @@ func TestAnalyticsListProxyAndCSV(t *testing.T) {
}
}
// compareWith is the minimal comparison JSON the page needs; new is a marker
// that tells the pairs of runs apart in the page source.
func compareWith(newAddrs string) string {
return `{"runs":{"base":{"id":1,"rechecked":0,"addresses":6440},"target":{"id":2,"rechecked":0,"addresses":900}},` +
`"groups":{"new":` + newAddrs + `,"left":2,"common":3,"changed":1,"same":2},"indicators":[],` +
`"transitions":{"verdicts":["pass","partial","fail"],"matrix":[[0,0,0],[0,0,0],[0,0,0]],"new":[0,0,0],"left":[0,0,0]},"cancelled":{"base":0,"target":0}}`
}
// compareFake is analyticsFake with the comparisons of runs 1 and 2 (run 3 is open).
func compareFake(t *testing.T) (*fakeControlAPI, *httptest.Server) {
t.Helper()
fake, ts := analyticsFake(t)
fake.compares = map[string]string{"1-2": compareWith("111"), "2-1": compareWith("222")}
fake.compareLists = map[string]string{
"1-2/changed": `{"group":"changed","columns":["Адрес","Подсеть"],"rows":[["1.2.3.4","1.2.3.0/24"]]}`,
"1-2/new/verdict_pass": `{"group":"new","indicator":"verdict_pass","columns":["Адрес"],"rows":[["5.6.7.8"]]}`,
}
return fake, ts
}
func compareRequests(fake *fakeControlAPI) []string {
var out []string
for _, r := range fake.analyticsReqs {
if strings.Contains(r, "/compare") {
out = append(out, r)
}
}
return out
}
// By default B is the newest finished run and A the one before it; the open
// run is not offered; the page asks control-api for that pair only.
func TestAnalyticsComparePageDefaultPair(t *testing.T) {
fake, ts := compareFake(t)
page := get(t, ts, "/analytics/compare")
for _, want := range []string{
`id="an-base"`, `id="an-target"`, `id="an-swap"`, `id="analytics-data"`, `"new":111`,
`<option value="1" selected>`, `<option value="2" selected>`, // A is run 1, B is run 2
`id="an-dlg"`, `/static/analytics-dialog.js`, `/static/analytics-compare.js`,
"6\u00a0440 адр. · 30% pass",
} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in the page, got:\n%s", want, page)
}
}
if strings.Count(page, `<option value="1" selected>`) != 1 || strings.Count(page, `<option value="2" selected>`) != 1 {
t.Fatalf("one run is chosen in each list:\n%s", page)
}
if strings.Contains(page, `value="3"`) {
t.Fatalf("an open run must not be offered:\n%s", page)
}
if got := compareRequests(fake); len(got) != 1 || !strings.Contains(got[0], "base=1") || !strings.Contains(got[0], "target=2") {
t.Fatalf("expected one request for base=1&target=2, got %v", got)
}
}
// The pair in the address: both ends, only the new one (the base is the run
// before it), only the old one (the target is the newest other run).
func TestAnalyticsComparePageExplicitPair(t *testing.T) {
fake, ts := compareFake(t)
page := get(t, ts, "/analytics/compare?base=2&target=1")
if !strings.Contains(page, `"new":222`) || !strings.Contains(page, `<option value="2" selected>`) {
t.Fatalf("swapped pair:\n%s", page)
}
if page = get(t, ts, "/analytics/compare?target=2"); !strings.Contains(page, `"new":111`) {
t.Fatalf("only target=2 must compare with run 1:\n%s", page)
}
if page = get(t, ts, "/analytics/compare?base=2"); !strings.Contains(page, `"new":222`) {
t.Fatalf("only base=2 must compare with the newest other run, 1:\n%s", page)
}
if page = get(t, ts, "/analytics/compare?target=1"); strings.Contains(page, `id="analytics-data"`) ||
!strings.Contains(page, "нет второго запуска") {
t.Fatalf("run 1 is the oldest, nothing to compare it with:\n%s", page)
}
if got := compareRequests(fake); len(got) != 3 {
t.Fatalf("the refused pair must not reach control-api, got %v", got)
}
}
func TestAnalyticsComparePageWarnings(t *testing.T) {
fake, ts := compareFake(t)
for _, c := range []struct{ query, want string }{
{"base=1&target=1", "Выберите два разных запуска"},
{"base=99&target=1", "Запуск 99 не найден или ещё не завершён"},
// the open run
{"base=1&target=3", "Запуск 3 не найден или ещё не завершён"},
{"base=abc&target=1", "Неверный номер запуска"},
{"base=0&target=1", "Неверный номер запуска"},
} {
page := get(t, ts, "/analytics/compare?"+c.query)
if !strings.Contains(page, c.want) || strings.Contains(page, `id="analytics-data"`) || !strings.Contains(page, `id="an-base"`) {
t.Fatalf("%s: expected the warning %q and the run lists without data, got:\n%s", c.query, c.want, page)
}
}
if got := compareRequests(fake); len(got) != 0 {
t.Fatalf("a bad pair must not reach control-api, got %v", got)
}
// Fewer than two finished runs: nothing to compare.
fake, caURL := newFakeControlAPI(t)
fake.runs = []analyticsRun{fakeRun(3, "open", 120, 40), fakeRun(1, "finalized", 6440, 1962)}
page := get(t, newTestServer(t, caURL), "/analytics/compare")
if !strings.Contains(page, "минимум два завершённых запуска") || strings.Contains(page, `id="an-base"`) {
t.Fatalf("expected the empty state, got:\n%s", page)
}
}
func TestAnalyticsCompareListProxyAndCSV(t *testing.T) {
_, ts := compareFake(t)
fetch := func(path string) (int, http.Header, string) {
t.Helper()
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return resp.StatusCode, resp.Header, string(body)
}
code, _, body := fetch("/analytics/compare/lists/changed?base=1&target=2")
if code != http.StatusOK || !strings.Contains(body, `"1.2.3.4"`) {
t.Fatalf("list: %d %s", code, body)
}
// The indicator and the verdicts travel to control-api.
code, _, body = fetch("/analytics/compare/lists/new?base=1&target=2&indicator=verdict_pass")
if code != http.StatusOK || !strings.Contains(body, `"5.6.7.8"`) {
t.Fatalf("list with an indicator: %d %s", code, body)
}
code, header, body := fetch("/analytics/compare/csv/changed?base=1&target=2")
if code != http.StatusOK || !strings.HasPrefix(header.Get("Content-Type"), "text/csv") ||
!strings.Contains(header.Get("Content-Disposition"), `attachment; filename="compare_changed_run1-2.csv"`) {
t.Fatalf("csv: %d %v %s", code, header, body)
}
for _, c := range []struct {
path string
want int
}{
// no runs
{"/analytics/compare/lists/changed", http.StatusBadRequest},
{"/analytics/compare/lists/changed?base=1", http.StatusBadRequest},
{"/analytics/compare/csv/changed?base=abc&target=2", http.StatusBadRequest},
// control-api says unknown list
{"/analytics/compare/lists/nonsense?base=1&target=2", http.StatusNotFound},
{"/analytics/compare/csv/nonsense?base=1&target=2", http.StatusNotFound},
} {
if code, _, _ := fetch(c.path); code != c.want {
t.Errorf("%s: %d, want %d", c.path, code, c.want)
}
}
}
// The single-run page after the dialog moved to analytics-dialog.js: it still
// carries the dialog, loads the shared script before its own, offers the
// comparison, and both scripts are served.
func TestAnalyticsPageUsesSharedDialog(t *testing.T) {
_, ts := analyticsFake(t)
page := get(t, ts, "/analytics")
iDialog, iPage := strings.Index(page, "/static/analytics-dialog.js"), strings.Index(page, "/static/analytics.js")
if iDialog < 0 || iPage < 0 || iDialog > iPage {
t.Fatalf("analytics-dialog.js must come before analytics.js (%d, %d):\n%s", iDialog, iPage, page)
}
for _, want := range []string{`id="an-dlg"`, `id="an-dlg-tbl"`, `id="an-dlg-csv"`, `id="an-tip"`, `href="/analytics/compare?target=2"`} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in the page, got:\n%s", want, page)
}
}
for path, want := range map[string]string{
"/static/analytics-dialog.js": "window.AnalyticsDialog =",
"/static/analytics.js": "window.AnalyticsDialog",
"/static/analytics-compare.js": "window.AnalyticsDialog",
} {
if body := get(t, ts, path); !strings.Contains(body, want) {
t.Errorf("%s does not contain %q", path, want)
}
}
}
// The sidebar: no window-chrome dots next to the logo, the Analytics entry,
// and the link state, theme toggle and logout above the navigation.
func TestSidebarSessionBlockOnTop(t *testing.T) {
@@ -232,3 +414,15 @@ func TestRegistryDrillDownFromAnalytics(t *testing.T) {
t.Fatalf("a malformed subnet must be ignored: %q", last)
}
}
// The filter of a comparison list goes to control-api as it is.
func TestAnalyticsCompareListPath(t *testing.T) {
got := analyticsCompareListPath(1, 2, "common", compareFilter{Indicator: "verdict_pass", From: "partial", To: "pass"}, true)
want := "/api/v1/admin/analytics/compare/lists/common?base=1&format=csv&from=partial&indicator=verdict_pass&target=2&to=pass"
if got != want {
t.Errorf("path = %q, want %q", got, want)
}
if got := analyticsCompareListPath(3, 4, "new", compareFilter{}, false); got != "/api/v1/admin/analytics/compare/lists/new?base=3&target=4" {
t.Errorf("path = %q", got)
}
}
+3
View File
@@ -32,6 +32,9 @@ func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /analytics", s.handleAnalyticsPage)
mux.HandleFunc("GET /analytics/lists/{kind}", s.handleAnalyticsList)
mux.HandleFunc("GET /analytics/csv/{kind}", s.handleAnalyticsCSV)
mux.HandleFunc("GET /analytics/compare", s.handleAnalyticsComparePage)
mux.HandleFunc("GET /analytics/compare/lists/{group}", s.handleAnalyticsCompareList)
mux.HandleFunc("GET /analytics/compare/csv/{group}", s.handleAnalyticsCompareCSV)
mux.HandleFunc("GET /validators", s.handleValidatorsPage)
mux.HandleFunc("POST /validators", s.handleValidatorCreate)
@@ -0,0 +1,136 @@
/* Comparison of two finished runs: renders the comparison embedded as JSON in
#analytics-data (the groups of addresses, the dynamics of the seven
indicators, the verdict transitions) and opens the address list behind each
number in the shared dialog (analytics-dialog.js). The state of the page is
its address: ?base=A&target=B. */
(function () {
'use strict';
var D = JSON.parse(document.getElementById('analytics-data').textContent);
var R = D.report, M = D.meta, G = R.groups, T = R.transitions;
var A = window.AnalyticsDialog, $ = A.$, fmt = A.fmt, esc = A.esc, tipAttr = A.tipAttr;
function pct(a, b) { return b ? Math.round(a / b * 100) : 0; }
var NAME = {};
R.indicators.forEach(function (d) { NAME[d.key] = d.name; });
/* ---- what each list says about itself ---- */
var GROUP = {
new: ['Новые адреса', 'Есть в запуске B, в A их не было: прибыли в проект и подключились к анализу. Столбцы показывают состояние в B.'],
left: ['Выбывшие адреса', 'Были в запуске A, в B их нет: вышли из состава проекта. Столбцы показывают состояние в A.'],
common: ['Общие адреса', 'Есть в обоих запусках. Столбцы показывают состояние в A → B.'],
changed: ['Изменившиеся адреса', 'Есть в обоих запусках, принадлежность хотя бы к одному из семи индикаторов в A и в B разная.'],
same: ['Адреса без изменений', 'Есть в обоих запусках, принадлежность ко всем индикаторам та же. Другой набор проваленных целей или площадок изменением не считается.'],
entered: ['Вошли в индикатор', 'Общие адреса, которые в A не входили в этот индикатор, а в B входят.'],
exited: ['Вышли из индикатора', 'Общие адреса, которые в A входили в этот индикатор, а в B не входят.']
};
var HINT_ONE = {
2: 'Вердикт адреса, который система выставила при агрегации, а не итог всех проверок.',
3: 'Сколько egress-проверок адреса успешно из всех записанных в последнем цикле запуска. «—» — egress-проверок нет.',
4: 'Сколько ingress-проверок адреса успешно из всех записанных в последнем цикле запуска. «—» — ingress-проверок нет.',
5: 'Индикаторы страницы «Аналитика», в которые входит адрес. Один адрес может входить в несколько; вердикт — ровно в один из трёх.'
};
var HINT_PAIR = {
2: 'Вердикт адреса в запуске A и в запуске B.',
3: 'Сколько egress-проверок адреса успешно из всех записанных в последнем цикле: в A → в B.',
4: 'Сколько ingress-проверок адреса успешно из всех записанных в последнем цикле: в A → в B.',
5: 'Что изменилось между запусками, по шагам: вердикт, вход в индикатор и выход из него, проваленные цели https и площадки ssh, валидатор. У адреса без изменений в индикаторах — «без изменений», даже если набор проваленных целей другой.'
};
function describe(f) {
var g = GROUP[f.group], ind = f.ind ? '«' + (NAME[f.ind] || f.ind) + '»' : '';
var title = g[0] + (f.ind ? ': ' + (NAME[f.ind] || f.ind) : '') + (f.from ? ' · вердикт ' + f.from + ' → ' + f.to : '');
var note = g[1];
if (f.ind && f.group === 'new') note += ' Только входящие в индикатор ' + ind + ' в запуске B.';
if (f.ind && f.group === 'left') note += ' Только входившие в индикатор ' + ind + ' в запуске A.';
if (f.ind && (f.group === 'common' || f.group === 'changed' || f.group === 'same')) note += ' Только входящие в индикатор ' + ind + ' хотя бы в одном из запусков.';
if (f.from) note += ' Только адреса с вердиктом ' + f.from + ' в A и ' + f.to + ' в B.';
return { title: title, note: note, hints: f.group === 'new' || f.group === 'left' ? HINT_ONE : HINT_PAIR };
}
function listURL(root, f) {
return root + encodeURIComponent(f.group) + '?base=' + M.base.id + '&target=' + M.target.id +
(f.ind ? '&indicator=' + encodeURIComponent(f.ind) : '') +
(f.from ? '&from=' + encodeURIComponent(f.from) + '&to=' + encodeURIComponent(f.to) : '');
}
function openList(f) {
var t = describe(f);
A.load(listURL(M.list_url, f), t.title).then(function (l) {
if (!l) return;
A.fill({
title: t.title, note: t.note, hints: t.hints, scope: 'Запуски', runLabel: 'A — ' + M.base.label + '; B — ' + M.target.label,
cols: l.columns, rows: l.rows, csvURL: listURL(M.csv_url, f)
});
});
}
/* ---- the page ---- */
function renderNote() {
function line(name, m, r, cancelled) {
return '<div>' + esc(name + ': запуск ' + m.id + ', ' + m.kind + ', начало ' + m.start + ', завершён ' + m.end + ', длительность ' + m.duration +
', ' + fmt(r.addresses) + ' адр.' + (r.rechecked ? ', перепроверено внутри запуска: ' + fmt(r.rechecked) : '') +
(cancelled ? ', отменено (не участвуют в сравнении): ' + fmt(cancelled) : '') + '.') + '</div>';
}
$('an-cmp-note').innerHTML = line('A', M.base, R.runs.base, R.cancelled.base) + line('B', M.target, R.runs.target, R.cancelled.target);
}
function renderKpis() {
var GO = ' <span class="an-go">список →</span>';
var tiles = [
['Новые', G.new, 'есть в B, не было в A', 'new'],
['Выбывшие', G.left, 'были в A, нет в B', 'left'],
['Общие', G.common, 'есть в обоих запусках', 'common'],
['Изменились', G.changed, pct(G.changed, G.common) + '% общих адресов', 'changed'],
['Без изменений', G.same, pct(G.same, G.common) + '% общих адресов', 'same']
];
$('an-cmp-kpis').innerHTML = tiles.map(function (t) {
var inner = '<div class="an-k">' + t[0] + '</div><div class="an-v">' + fmt(t[1]) + '</div><div class="an-d">' + t[2] + (t[1] ? GO : '') + '</div>';
return t[1]
? '<button type="button" class="an-kpi" data-group="' + t[3] + '" aria-haspopup="dialog">' + inner + '</button>'
: '<div class="an-kpi">' + inner + '</div>';
}).join('');
}
/* a number that opens a list; zero is plain text */
function link(n, attrs, tip) {
return n ? '<button type="button" class="an-lnk" ' + attrs + ' ' + tipAttr(tip) + '>' + fmt(n) + '</button>' : '<span class="an-zero">0</span>';
}
function renderIndicators() {
var COLS = [['new', 'Новые', 'новые адреса, входящие в индикатор'], ['left', 'Выбывшие', 'выбывшие адреса, входившие в индикатор'],
['entered', 'Вошли', 'общие адреса, вошедшие в индикатор'], ['exited', 'Вышли', 'общие адреса, вышедшие из индикатора']];
$('an-cmp-ind').innerHTML = '<thead><tr><th>Индикатор</th><th class="an-r">A</th><th class="an-r">B</th><th class="an-r">Δ</th>' +
COLS.map(function (c) { return '<th class="an-r">' + c[1] + '</th>'; }).join('') + '</tr></thead><tbody>' +
R.indicators.map(function (d) {
var good = d.key === 'verdict_pass' ? d.delta > 0 : d.delta < 0;
var delta = d.delta > 0 ? '+' + fmt(d.delta) : d.delta < 0 ? '−' + fmt(-d.delta) : '0';
return '<tr><td>' + esc(d.name) + '</td><td class="an-num an-r">' + fmt(d.base) + '</td><td class="an-num an-r">' + fmt(d.target) + '</td>' +
'<td class="an-num an-r' + (d.delta ? (good ? ' an-good' : ' an-bad') : '') + '">' + delta + '</td>' +
COLS.map(function (c) {
return '<td class="an-num an-r">' + link(d[c[0]], 'data-group="' + c[0] + '" data-ind="' + d.key + '"', d.name + ': ' + c[2]) + '</td>';
}).join('') + '</tr>';
}).join('') + '</tbody>';
}
function renderTransitions() {
var V = T.verdicts;
$('an-cmp-tr').innerHTML = '<thead><tr><th></th>' + V.map(function (v) { return '<th class="an-r">B: ' + esc(v) + '</th>'; }).join('') + '<th class="an-r">нет в B</th></tr></thead><tbody>' +
V.map(function (from, i) {
return '<tr><td>A: ' + esc(from) + '</td>' + V.map(function (to, j) {
return '<td class="an-num an-r">' + link(T.matrix[i][j], 'data-group="common" data-from="' + from + '" data-to="' + to + '"', 'вердикт ' + from + ' в A → ' + to + ' в B') + '</td>';
}).join('') + '<td class="an-num an-r">' + link(T.left[i], 'data-group="left" data-ind="verdict_' + from + '"', 'выбыли, вердикт в A: ' + from) + '</td></tr>';
}).join('') +
'<tr><td>нет в A</td>' + V.map(function (to, j) {
return '<td class="an-num an-r">' + link(T.new[j], 'data-group="new" data-ind="verdict_' + to + '"', 'новые, вердикт в B: ' + to) + '</td>';
}).join('') + '<td class="an-num an-r">—</td></tr></tbody>';
}
/* ---- wiring ---- */
function onOpen(e) {
var b = e.target.closest('[data-group]');
if (b) openList({ group: b.dataset.group, ind: b.dataset.ind || '', from: b.dataset.from || '', to: b.dataset.to || '' });
}
renderNote(); renderKpis(); renderIndicators(); renderTransitions();
['an-cmp-kpis', 'an-cmp-ind', 'an-cmp-tr'].forEach(function (id) { $(id).addEventListener('click', onOpen); });
})();
@@ -0,0 +1,107 @@
/* The address-list dialog of the analytics pages (markup: the "analytics_dialog"
template) and the tooltip that goes with it. A page loads a list with
AnalyticsDialog.load(url, title), then shows it with AnalyticsDialog.fill(m);
copying and the CSV download work on the list shown last. */
(function () {
'use strict';
function $(id) { return document.getElementById(id); }
function fmt(n) { return Math.round(n).toLocaleString('ru-RU'); }
function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function tipAttr(t) { return 'data-tip="' + esc(t) + '"'; }
var cur = null; // the list shown in the dialog
function csvText(cols, rows) {
function q(v) { return '"' + String(v).replace(/"/g, '""') + '"'; }
return [cols].concat(rows).map(function (r) { return r.map(q).join(','); }).join('\r\n') + '\r\n';
}
function open() {
var d = $('an-dlg');
if (d.open) return;
if (d.showModal) d.showModal(); else d.setAttribute('open', '');
}
/* m: title, note, scope ("Запуск" by default), runLabel, cols, rows, hints
(column index -> text), cellHints (column index -> function of the value),
dist (HTML above the table), csvURL (the file behind "Скачать CSV"). */
function fill(m) {
cur = m;
$('an-dlg-title').textContent = m.title + ' · ' + fmt(m.rows.length);
$('an-dlg-note').textContent = (m.scope || 'Запуск') + ': ' + m.runLabel + '. ' + m.note;
$('an-dlg-dist').hidden = !m.dist;
$('an-dlg-dist').innerHTML = m.dist || '';
var hint = m.hints || {};
$('an-dlg-tbl').innerHTML = '<thead><tr>' + m.cols.map(function (c, i) {
return hint[i] ? '<th ' + tipAttr(hint[i]) + ' tabindex="0">' + esc(c) + '<span class="an-hint" aria-hidden="true">ⓘ</span></th>' : '<th>' + esc(c) + '</th>';
}).join('') + '</tr></thead><tbody>' + m.rows.map(function (x) {
return '<tr>' + x.map(function (v, i) {
var h = m.cellHints && m.cellHints[i] ? m.cellHints[i](v) : '';
return '<td class="' + (i < 2 ? 'an-a' : '') + '"' + (h ? ' ' + tipAttr(h) : '') + '>' + esc(v) + '</td>';
}).join('') + '</tr>';
}).join('') + '</tbody>';
$('an-dlg-msg').textContent = '';
}
/* Opens the dialog with the title and "Загрузка…", fetches the list as JSON;
resolves with it, or with null (and a message in the dialog) on a failure. */
function load(url, title) {
$('an-dlg-title').textContent = title;
$('an-dlg-note').textContent = 'Загрузка…';
$('an-dlg-dist').hidden = true;
$('an-dlg-tbl').innerHTML = '';
$('an-dlg-msg').textContent = '';
cur = null;
open();
return fetch(url, { headers: { Accept: 'application/json' }, credentials: 'same-origin' })
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (l) { return l; })
.catch(function (e) {
$('an-dlg-note').textContent = 'Не удалось загрузить список: ' + e.message;
return null;
});
}
function copyCsv() {
var msg = $('an-dlg-msg');
if (!cur) return;
var text = csvText(cur.cols, cur.rows);
var done = function () { msg.textContent = 'CSV скопирован в буфер обмена.'; };
var fail = function () { msg.textContent = 'Буфер обмена недоступен в этом окне.'; };
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, fail); else fail();
}
function downloadCsv() {
if (!cur) return;
window.location.href = cur.csvURL;
}
$('an-dlg-csv').addEventListener('click', downloadCsv);
$('an-dlg-copy').addEventListener('click', copyCsv);
$('an-dlg-close').addEventListener('click', function () { $('an-dlg').close(); });
$('an-dlg').addEventListener('click', function (e) { if (e.target === $('an-dlg')) $('an-dlg').close(); });
/* tooltip: moved into the open dialog, otherwise the modal's top layer covers it */
var tip = $('an-tip');
function tipHost() { var d = $('an-dlg'); return d && d.open ? d : document.body; }
document.addEventListener('mouseover', function (e) {
var t = e.target.closest('[data-tip]');
if (!t) return;
var h = tipHost();
if (tip.parentNode !== h) h.appendChild(tip);
tip.textContent = t.dataset.tip;
tip.style.opacity = 1;
});
document.addEventListener('mousemove', function (e) {
tip.style.left = Math.max(8, Math.min(e.clientX + 14, window.innerWidth - 376)) + 'px';
tip.style.top = (e.clientY + 16) + 'px';
});
document.addEventListener('mouseout', function (e) { if (e.target.closest('[data-tip]')) tip.style.opacity = 0; });
window.AnalyticsDialog = { $: $, fmt: fmt, esc: esc, tipAttr: tipAttr, load: load, fill: fill };
})();
+6
View File
@@ -107,6 +107,12 @@ button.an-bar-row:focus-visible { outline: 2px solid var(--accent); outline-offs
.an-dist .an-cols20 { display: grid; grid-template-columns: repeat(20, minmax(0, 1fr)); gap: 3px; align-items: end; height: 84px; border-bottom: 1px solid var(--border); }
.an-dist .an-cols20 div { background: var(--an-bar); border-radius: 2px 2px 0 0; min-height: 1px; }
.an-dist .an-lab20 { display: grid; grid-template-columns: repeat(20, minmax(0, 1fr)); gap: 3px; font: 500 9.5px var(--font-mono); color: var(--text-faint); text-align: center; }
/* comparison of two runs: a number that opens a list, and the delta colours */
.an button.an-lnk { font: 500 12.5px var(--font-mono); font-variant-numeric: tabular-nums; color: var(--accent-strong); background: none; border: 0; border-bottom: 1px dashed var(--accent); padding: 0 2px; cursor: pointer; }
.an button.an-lnk:hover { background: var(--surface-alt); }
.an button.an-lnk:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.an-zero { color: var(--text-faint); font: 500 12.5px var(--font-mono); }
.an td.an-good { color: var(--success); font-weight: 700; } .an td.an-bad { color: var(--danger); font-weight: 700; }
#an-tip { position: fixed; z-index: 10; pointer-events: none; background: var(--text); color: var(--bg); font: 500 12px/1.4 var(--font-mono); padding: 6px 9px; border-radius: 4px; max-width: min(360px, calc(100vw - 24px)); white-space: normal; opacity: 0; transition: opacity .08s; }
@media (prefers-reduced-motion: reduce) { #an-tip { transition: none; } }
+7 -94
View File
@@ -1,21 +1,15 @@
/* Analytics page: renders the report of one finished run (embedded as JSON in
#analytics-data) and opens the address lists behind the indicators and the
error classes in a dialog. No other run's data is on the page. */
error classes in the shared dialog (analytics-dialog.js). No other run's data
is on the page. */
(function () {
'use strict';
var D = JSON.parse(document.getElementById('analytics-data').textContent);
var R = D.report, M = D.meta, S = R.summary, Q = R.quality;
function $(id) { return document.getElementById(id); }
function fmt(n) { return Math.round(n).toLocaleString('ru-RU'); }
var A = window.AnalyticsDialog, $ = A.$, fmt = A.fmt, esc = A.esc, tipAttr = A.tipAttr;
function pct(a, b) { return b ? Math.round(a / b * 100) : 0; }
function pct1(a, b) { return b ? (a / b * 100).toLocaleString('ru-RU', { maximumFractionDigits: 1 }) : '0'; }
function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function tipAttr(t) { return 'data-tip="' + esc(t) + '"'; }
function vnum(id) { var m = /(\d+)$/.exec(id); return m ? +m[1] : null; }
function vshort(id) { var n = vnum(id); return n === null ? id : 'v' + n; }
@@ -211,57 +205,11 @@
}
};
var cur = null; // the list shown in the dialog
function listURL(base, kind, cls) {
return base + encodeURIComponent(kind) + '?run=' + M.run_id + (cls ? '&class=' + encodeURIComponent(cls) : '');
}
function csvText(cols, rows) {
function q(v) { return '"' + String(v).replace(/"/g, '""') + '"'; }
return [cols].concat(rows).map(function (r) { return r.map(q).join(','); }).join('\r\n') + '\r\n';
}
function openDialog() {
var d = $('an-dlg');
if (d.open) return;
if (d.showModal) d.showModal(); else d.setAttribute('open', '');
}
function fillDialog(m) {
cur = m;
$('an-dlg-title').textContent = m.title + ' · ' + fmt(m.rows.length);
$('an-dlg-note').textContent = 'Запуск: ' + m.runLabel + '. ' + m.note;
$('an-dlg-dist').hidden = !m.dist;
$('an-dlg-dist').innerHTML = m.dist || '';
var hint = m.hints || {};
$('an-dlg-tbl').innerHTML = '<thead><tr>' + m.cols.map(function (c, i) {
return hint[i] ? '<th ' + tipAttr(hint[i]) + ' tabindex="0">' + esc(c) + '<span class="an-hint" aria-hidden="true">ⓘ</span></th>' : '<th>' + esc(c) + '</th>';
}).join('') + '</tr></thead><tbody>' + m.rows.map(function (x) {
return '<tr>' + x.map(function (v, i) {
var h = m.cellHints && m.cellHints[i] ? m.cellHints[i](v) : '';
return '<td class="' + (i < 2 ? 'an-a' : '') + '"' + (h ? ' ' + tipAttr(h) : '') + '>' + esc(v) + '</td>';
}).join('') + '</tr>';
}).join('') + '</tbody>';
$('an-dlg-msg').textContent = '';
}
function loadList(kind, cls, meta) {
$('an-dlg-title').textContent = meta.title;
$('an-dlg-note').textContent = 'Загрузка…';
$('an-dlg-dist').hidden = true;
$('an-dlg-tbl').innerHTML = '';
$('an-dlg-msg').textContent = '';
cur = null;
openDialog();
return fetch(listURL(M.list_url, kind, cls), { headers: { Accept: 'application/json' }, credentials: 'same-origin' })
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (l) { return l; })
.catch(function (e) {
$('an-dlg-note').textContent = 'Не удалось загрузить список: ' + e.message;
return null;
});
}
function loadList(kind, cls, meta) { return A.load(listURL(M.list_url, kind, cls), meta.title); }
function runLabel() {
var o = document.getElementById('an-run');
@@ -272,7 +220,7 @@
var meta = LISTS[kind];
loadList(kind, '', meta).then(function (l) {
if (!l) return;
fillDialog({ title: meta.title, note: meta.note, hints: meta.hints, cols: l.columns, rows: l.rows, runLabel: runLabel(), file: kind, kind: kind, cls: '' });
A.fill({ title: meta.title, note: meta.note, hints: meta.hints, cols: l.columns, rows: l.rows, runLabel: runLabel(), csvURL: listURL(M.csv_url, kind, '') });
});
}
@@ -292,30 +240,16 @@
}).join('') + '</div>' +
'<div class="an-lab20" style="' + grid + '">' + labels.map(function (lab) { return '<span>' + esc(lab.replace(/^v/, '')) + '</span>'; }).join('') + '</div>' +
'<div class="an-chips"><span class="an-tag an-t-n">всего ' + fmt(rows.length) + '</span><span class="an-tag an-t-ok">вердикт pass: ' + fmt(pass) + '</span><span class="an-tag an-t-warn">вердикт partial: ' + fmt(rows.length - pass) + '</span><span class="an-tag an-t-bad">после вердикта: ' + fmt(late) + '</span><span class="an-tag an-t-n">в вердикте: ' + fmt(rows.length - late) + '</span></div>';
fillDialog({
A.fill({
title: 'Ingress: ' + cls,
note: 'Проваленные проверки этого класса в последнем цикле каждого адреса. Валидатор — тот, к которому был привязан адрес в этом цикле. «После вердикта» — результат пришёл позже агрегации и в вердикт адреса не вошёл.',
cols: l.columns, rows: rows, runLabel: runLabel(), dist: dist, kind: 'error', cls: cls,
cols: l.columns, rows: rows, runLabel: runLabel(), dist: dist, csvURL: listURL(M.csv_url, 'error', cls),
hints: { 3: 'Валидатор, к которому был привязан адрес в этом цикле (берётся из события привязки Floating IP).', 4: VERDICT_HINT, 5: STATUS_HINT },
cellHints: { 4: function (v) { return VERDICT_VAL[v] || ''; }, 5: statusVal }
});
});
}
function copyCsv() {
var msg = $('an-dlg-msg');
if (!cur) return;
var text = csvText(cur.cols, cur.rows);
var done = function () { msg.textContent = 'CSV скопирован в буфер обмена.'; };
var fail = function () { msg.textContent = 'Буфер обмена недоступен в этом окне.'; };
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, fail); else fail();
}
function downloadCsv() {
if (!cur) return;
window.location.href = listURL(M.csv_url, cur.kind, cur.cls);
}
/* ---- wiring ---- */
function renderAll() {
renderSubnets();
@@ -343,25 +277,4 @@
renderTypes(); renderTargets();
});
$('an-allsub').addEventListener('click', function () { state.all = !state.all; renderSubnets(); renderTargets(); });
$('an-dlg-csv').addEventListener('click', downloadCsv);
$('an-dlg-copy').addEventListener('click', copyCsv);
$('an-dlg-close').addEventListener('click', function () { $('an-dlg').close(); });
$('an-dlg').addEventListener('click', function (e) { if (e.target === $('an-dlg')) $('an-dlg').close(); });
/* tooltip: moved into the open dialog, otherwise the modal's top layer covers it */
var tip = $('an-tip');
function tipHost() { var d = $('an-dlg'); return d && d.open ? d : document.body; }
document.addEventListener('mouseover', function (e) {
var t = e.target.closest('[data-tip]');
if (!t) return;
var h = tipHost();
if (tip.parentNode !== h) h.appendChild(tip);
tip.textContent = t.dataset.tip;
tip.style.opacity = 1;
});
document.addEventListener('mousemove', function (e) {
tip.style.left = Math.max(8, Math.min(e.clientX + 14, window.innerWidth - 376)) + 'px';
tip.style.top = (e.clientY + 16) + 'px';
});
document.addEventListener('mouseout', function (e) { if (e.target.closest('[data-tip]')) tip.style.opacity = 0; });
})();
+3 -17
View File
@@ -19,6 +19,7 @@
</div>
{{if .HasRun}}
<script type="application/json" id="analytics-data">{{.DataJSON}}</script>
<script src="/static/analytics-dialog.js"></script>
<script src="/static/analytics.js"></script>
{{end}}
</body>
@@ -46,6 +47,7 @@
{{end}}
</select>
{{if .NextURL}}<a class="an-btn" href="{{.NextURL}}" aria-label="Следующий запуск">▶</a>{{else}}<span class="an-btn" aria-disabled="true" style="opacity:.4">▶</span>{{end}}
{{if .HasRun}}<a class="an-btn" href="/analytics/compare?target={{.RunID}}">Сравнить с другим запуском</a>{{end}}
</div>
{{if .HasRun}}<p class="an-note" id="an-runnote"></p>{{else}}<p class="an-note">Завершённых запусков пока нет: данные появятся, когда все адреса запуска получат итог.</p>{{end}}
</section>
@@ -114,23 +116,7 @@
<p class="an-note">Ровная полоса значит: проблема зависит от подсети адреса, а не от валидатора.</p>
</section>
<dialog id="an-dlg" aria-labelledby="an-dlg-title">
<div class="an-dlg">
<h3 id="an-dlg-title"></h3>
<p class="an-note" id="an-dlg-note"></p>
<div class="an-dist" id="an-dlg-dist" hidden></div>
<div class="an-list"><table id="an-dlg-tbl"></table></div>
<div class="an-foot">
<span class="an-note" id="an-dlg-msg" role="status"></span>
<div class="an-acts">
<button class="an-btn" type="button" id="an-dlg-copy">Копировать</button>
<button class="an-btn an-primary" type="button" id="an-dlg-csv">Скачать CSV</button>
<button class="an-btn" type="button" id="an-dlg-close" autofocus>Закрыть</button>
</div>
</div>
</div>
</dialog>
<div id="an-tip" role="tooltip"></div>
{{template "analytics_dialog"}}
{{end}}
</div>
{{end}}
@@ -0,0 +1,75 @@
{{define "analytics_compare_page"}}
<!doctype html>
<html lang="ru">
<head>{{template "html_head" .}}
<link rel="stylesheet" href="/static/analytics.css">
</head>
<body>
<div class="bg-grid"></div>
<input type="checkbox" id="nav-toggle" class="nav-toggle">
<div class="shell">
{{template "sidebar_nav" .}}
<div>
{{template "topbar_mobile" .}}
<main class="main">
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "analytics_compare_content" .}}
</main>
</div>
</div>
{{if .HasReport}}
<script type="application/json" id="analytics-data">{{.DataJSON}}</script>
<script src="/static/analytics-dialog.js"></script>
<script src="/static/analytics-compare.js"></script>
{{end}}
</body>
</html>
{{end}}
{{define "analytics_compare_content"}}
<div class="an">
<div class="an-head">
<h1 class="an-title">Сравнение запусков</h1>
<p class="an-sub">Динамика между двумя завершёнными запусками. <a href="/analytics">К аналитике одного запуска</a>.</p>
</div>
{{if .RunsA}}
<section class="an-panel" aria-label="Выбор запусков">
<form class="an-runbar" method="get" action="/analytics/compare">
<label for="an-base">Запуск A (старый)</label>
<select id="an-base" name="base">
{{range .RunsA}}<option value="{{.ID}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
{{end}}
</select>
<button class="an-btn" type="button" id="an-swap" onclick="var a = document.getElementById('an-base'), b = document.getElementById('an-target'), v = a.value; a.value = b.value; b.value = v; this.form.submit()">Поменять местами</button>
<label for="an-target">Запуск B (новый)</label>
<select id="an-target" name="target">
{{range .RunsB}}<option value="{{.ID}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
{{end}}
</select>
<button class="an-btn an-primary" type="submit">Сравнить</button>
</form>
{{if .HasReport}}<div class="an-note" id="an-cmp-note"></div>{{end}}
<p class="an-note">«Новые» — адреса, которые есть в B и которых не было в A; «выбывшие» — были в A, в B их нет. Адрес изменился, если его принадлежность к индикаторам в A и B разная. Если запуски разного состава (например, перепроверка части адресов), новых и выбывших будет много: это состав запусков, а не динамика.</p>
</section>
{{end}}
{{if .HasReport}}
<div class="an-kpis" id="an-cmp-kpis"></div>
<section class="an-panel" aria-labelledby="an-h-ind">
<h2 id="an-h-ind">Динамика по индикаторам</h2>
<div class="an-scroll"><table id="an-cmp-ind"></table></div>
<p class="an-note">Δ = B − A = новые − выбывшие + вошли − вышли. Зелёным выделен рост pass и падение остальных индикаторов, красным обратное. Числа в последних четырёх столбцах открывают список адресов.</p>
</section>
<section class="an-panel" aria-labelledby="an-h-tr">
<h2 id="an-h-tr">Переходы вердикта</h2>
<div class="an-scroll"><table id="an-cmp-tr"></table></div>
<p class="an-note">Вердикт адресов, которые есть в обоих запусках: по строкам вердикт в A, по столбцам в B. Строка «нет в A» — новые адреса по вердикту в B, столбец «нет в B» — выбывшие по вердикту в A.</p>
</section>
{{template "analytics_dialog"}}
{{end}}
</div>
{{end}}
@@ -0,0 +1,21 @@
{{/* The address-list dialog and its tooltip, shared by the analytics pages;
analytics-dialog.js drives it. */}}
{{define "analytics_dialog"}}
<dialog id="an-dlg" aria-labelledby="an-dlg-title">
<div class="an-dlg">
<h3 id="an-dlg-title"></h3>
<p class="an-note" id="an-dlg-note"></p>
<div class="an-dist" id="an-dlg-dist" hidden></div>
<div class="an-list"><table id="an-dlg-tbl"></table></div>
<div class="an-foot">
<span class="an-note" id="an-dlg-msg" role="status"></span>
<div class="an-acts">
<button class="an-btn" type="button" id="an-dlg-copy">Копировать</button>
<button class="an-btn an-primary" type="button" id="an-dlg-csv">Скачать CSV</button>
<button class="an-btn" type="button" id="an-dlg-close" autofocus>Закрыть</button>
</div>
</div>
</div>
</dialog>
<div id="an-tip" role="tooltip"></div>
{{end}}
+2 -2
View File
@@ -97,8 +97,8 @@ func TestRouteTableIsClassified(t *testing.T) {
t.Fatalf("admin route %q is %s, want admin", rt.Pattern, rt.Access)
}
}
if counts["admin"] != 39 || counts["agent"] != 5 || counts["open"] != 8 {
t.Fatalf("access counts = %v, want admin=39 agent=5 open=8", counts)
if counts["admin"] != 41 || counts["agent"] != 5 || counts["open"] != 8 {
t.Fatalf("access counts = %v, want admin=41 agent=5 open=8", counts)
}
}
+89 -10
View File
@@ -72,15 +72,21 @@ func (s *Server) handleAnalyticsRuns(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
// analysisFor returns the analysis of a finalized run, from the cache when the
// run's data has not changed since it was computed. It writes the error
// response itself and returns nil when it cannot.
// analysisFor returns the analysis of the run named by the {id} of the path;
// see analysisByID.
func (s *Server) analysisFor(w http.ResponseWriter, r *http.Request) *analytics.Analysis {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "invalid run id")
return nil
}
return s.analysisByID(w, r, id)
}
// analysisByID returns the analysis of a finalized run, from the cache when
// the run's data has not changed since it was computed. It writes the error
// response itself and returns nil when it cannot.
func (s *Server) analysisByID(w http.ResponseWriter, r *http.Request, id int64) *analytics.Analysis {
ctx := r.Context()
run, err := s.DB.GetRun(ctx, id)
if err != nil {
@@ -152,12 +158,6 @@ func (s *Server) handleAnalyticsList(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, list)
return
}
var buf bytes.Buffer
buf.WriteString("\xef\xbb\xbf") // UTF-8 BOM, so Excel opens the file as UTF-8
cw := csv.NewWriter(&buf)
cw.UseCRLF = true
_ = cw.Write(list.Columns)
_ = cw.WriteAll(list.Rows)
name := kind
if kind == analytics.ListError {
if slug := strings.Trim(nonSlug.ReplaceAllString(strings.ToLower(class), "-"), "-"); slug != "" {
@@ -166,12 +166,91 @@ func (s *Server) handleAnalyticsList(w http.ResponseWriter, r *http.Request) {
name += "-class"
}
}
writeCSV(w, list.Columns, list.Rows, fmt.Sprintf("%s_run%s.csv", name, r.PathValue("id")))
}
// writeCSV sends a table as a downloadable CSV file.
func writeCSV(w http.ResponseWriter, columns []string, rows [][]string, filename string) {
var buf bytes.Buffer
buf.WriteString("\xef\xbb\xbf") // UTF-8 BOM, so Excel opens the file as UTF-8
cw := csv.NewWriter(&buf)
cw.UseCRLF = true
_ = cw.Write(columns)
_ = cw.WriteAll(rows)
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s_run%s.csv"`, name, r.PathValue("id")))
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(buf.Bytes())
}
// compareFor loads the two runs named by ?base=A&target=B (the older and the
// newer one) and compares them. It writes the error response itself and
// returns nil when it cannot: 400 for a missing or malformed id or the same
// run twice, 404 for an unknown run, 409 for one that is still open.
func (s *Server) compareFor(w http.ResponseWriter, r *http.Request) (c *analytics.Comparison, base, target int64) {
ids := [2]int64{}
for i, name := range []string{"base", "target"} {
id, err := strconv.ParseInt(r.URL.Query().Get(name), 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "invalid or missing "+name+" run id")
return nil, 0, 0
}
ids[i] = id
}
if ids[0] == ids[1] {
writeError(w, http.StatusBadRequest, "base and target must be different runs")
return nil, 0, 0
}
a := s.analysisByID(w, r, ids[0])
if a == nil {
return nil, 0, 0
}
b := s.analysisByID(w, r, ids[1])
if b == nil {
return nil, 0, 0
}
return analytics.Compare(a, b), ids[0], ids[1]
}
// handleAnalyticsCompare serves the comparison of two finished runs:
// ?base=A (older) &target=B (newer).
func (s *Server) handleAnalyticsCompare(w http.ResponseWriter, r *http.Request) {
if c, _, _ := s.compareFor(w, r); c != nil {
writeJSON(w, http.StatusOK, c)
}
}
// handleAnalyticsCompareList serves the address table of one group of the
// comparison (new|left|common|changed|same|entered|exited), narrowed by
// ?indicator=... and ?from=...&to=... (verdicts), as JSON or, with
// ?format=csv, as a downloadable CSV file.
func (s *Server) handleAnalyticsCompareList(w http.ResponseWriter, r *http.Request) {
c, base, target := s.compareFor(w, r)
if c == nil {
return
}
q := r.URL.Query()
group := r.PathValue("group")
f := analytics.CompareFilter{Indicator: q.Get("indicator"), From: q.Get("from"), To: q.Get("to")}
list, err := c.List(group, f)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
if q.Get("format") != "csv" {
writeJSON(w, http.StatusOK, list)
return
}
name := "compare_" + group
if f.Indicator != "" {
name += "_" + f.Indicator
}
if f.From != "" {
name += "_" + f.From + "-" + f.To
}
writeCSV(w, list.Columns, list.Rows, fmt.Sprintf("%s_run%d-%d.csv", name, base, target))
}
func (s *Server) handleConfigGetSubnets(w http.ResponseWriter, r *http.Request) {
list, err := s.DB.ListSubnets(r.Context())
if err != nil {
+172
View File
@@ -136,6 +136,178 @@ func TestAnalyticsEndpoints(t *testing.T) {
}
}
// secondRun builds the run after finishedRun's and returns its id: 9.9.9.1 is
// not in it, 9.9.9.2 is checked again and passes now, 9.9.9.3 is new and has a
// failed ssh. Against the first run: one new, one left, one changed address.
func secondRun(t *testing.T, d *db.DB) int64 {
t.Helper()
ctx := context.Background()
if _, err := d.SubmitIPsAs(ctx, []string{"9.9.9.2", "9.9.9.3"}, db.RunManual); err != nil {
t.Fatal(err)
}
for _, addr := range []string{"9.9.9.2", "9.9.9.3"} {
ip, err := d.GetIPByAddress(ctx, addr)
if err != nil {
t.Fatal(err)
}
if err := d.SetChecking(ctx, ip.ID, time.Minute); err != nil {
t.Fatal(err)
}
ip, _ = d.GetIP(ctx, ip.ID)
put := func(src, typ, target string, ok bool) {
if _, err := d.UpsertCheckIfOpen(ctx, db.Check{IPID: ip.ID, IPAddress: addr, AttemptNumber: ip.AttemptNumber,
ValidatorID: "vkiplab-v1", Source: src, CheckType: typ, Target: target, Success: ok, Detail: "dial tcp: i/o timeout", CheckedAt: db.Now()}); err != nil {
t.Fatal(err)
}
}
put(db.SourceEgress, "https", "https://a.test", true)
put(db.InboundSource(1), "icmp", addr, true)
sshOK := addr == "9.9.9.2"
put(db.InboundSource(1), "ssh", addr, sshOK)
verdict := db.ResultPass
if !sshOK {
verdict = db.ResultPartial
}
if err := d.FinishIPExpected(ctx, ip.ID, verdict, 3); err != nil {
t.Fatal(err)
}
}
rs, err := d.ListRuns(ctx)
if err != nil || len(rs) != 2 || rs[0].State != db.RunFinalized {
t.Fatalf("expected two finalized runs: %+v %v", rs, err)
}
return rs[0].ID
}
func TestAnalyticsCompareEndpoints(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t)
fc.do(http.MethodPut, "/api/v1/admin/config/sites/1", putSiteRequest{SiteID: "rxmsk"})
a := finishedRun(t, d)
b := secondRun(t, d)
cmp := "/api/v1/admin/analytics/compare"
q := "?base=" + itoa64(a) + "&target=" + itoa64(b)
resp, body := fc.do(http.MethodGet, cmp+q, nil)
var rep struct {
Runs struct {
Base struct{ ID, Addresses int64 } `json:"base"`
Target struct{ ID, Addresses int64 } `json:"target"`
} `json:"runs"`
Groups struct {
New, Left, Common, Changed, Same int
} `json:"groups"`
Indicators []struct {
Key string
Base, Target, Delta, New, Left, Entered, Exited int
} `json:"indicators"`
Transitions struct {
Matrix [][]int `json:"matrix"`
} `json:"transitions"`
}
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &rep) != nil || rep.Runs.Base.ID != a || rep.Runs.Target.ID != b ||
rep.Runs.Base.Addresses != 2 || rep.Runs.Target.Addresses != 2 ||
rep.Groups.New != 1 || rep.Groups.Left != 1 || rep.Groups.Common != 1 || rep.Groups.Changed != 1 || rep.Groups.Same != 0 ||
len(rep.Indicators) != 7 || len(rep.Transitions.Matrix) != 3 || rep.Transitions.Matrix[1][0] != 1 {
t.Fatalf("compare: %d %s", resp.StatusCode, body)
}
for _, ind := range rep.Indicators {
if ind.Delta != ind.New-ind.Left+ind.Entered-ind.Exited || ind.Delta != ind.Target-ind.Base {
t.Errorf("indicator %s: %+v", ind.Key, ind)
}
if ind.Key == "ingress_ssh_any" && (ind.Base != 1 || ind.Target != 1 || ind.New != 1 || ind.Exited != 1) {
t.Errorf("ssh any: %+v", ind)
}
}
// Lists: JSON, then CSV with BOM and the name of the file.
var l struct {
Group string `json:"group"`
Columns []string `json:"columns"`
Rows [][]string `json:"rows"`
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/changed"+q, nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || l.Group != "changed" || len(l.Rows) != 1 ||
l.Rows[0][0] != "9.9.9.2" || !strings.HasPrefix(l.Rows[0][2], "partial → pass") {
t.Fatalf("changed list: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/new"+q+"&indicator=verdict_partial", nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.3" {
t.Fatalf("new list: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/left"+q+"&indicator=verdict_pass", nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.1" {
t.Fatalf("left list: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/common"+q+"&from=partial&to=pass", nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.2" {
t.Fatalf("matrix cell list: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/exited"+q+"&indicator=ingress_ssh_any", nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.2" {
t.Fatalf("exited list: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/changed"+q+"&format=csv", nil)
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(string(body), "\xef\xbb\xbf") || !strings.Contains(string(body), "9.9.9.2") ||
!strings.HasPrefix(resp.Header.Get("Content-Type"), "text/csv") ||
!strings.Contains(resp.Header.Get("Content-Disposition"), `attachment; filename="compare_changed_run`+itoa64(a)+"-"+itoa64(b)+`.csv"`) {
t.Fatalf("csv: %d %v %q", resp.StatusCode, resp.Header, body)
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/new"+q+"&indicator=verdict_partial&format=csv", nil)
if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="compare_new_verdict_partial_run`+itoa64(a)+"-"+itoa64(b)+`.csv"`) {
t.Fatalf("csv with an indicator: %d %v %q", resp.StatusCode, resp.Header, body)
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/common"+q+"&from=partial&to=pass&format=csv", nil)
if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="compare_common_partial-pass_run`+itoa64(a)+"-"+itoa64(b)+`.csv"`) {
t.Fatalf("csv with verdicts: %d %v %q", resp.StatusCode, resp.Header, body)
}
// The order is the caller's: swapped, the new address is the one that left.
swapped := "?base=" + itoa64(b) + "&target=" + itoa64(a)
resp, body = fc.do(http.MethodGet, cmp+"/lists/new"+swapped, nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.1" {
t.Fatalf("swapped: %d %s", resp.StatusCode, body)
}
// An open run cannot be compared.
if _, err := d.SubmitIPs(context.Background(), []string{"9.9.9.9"}); err != nil {
t.Fatal(err)
}
rs, _ := d.ListRuns(context.Background())
open := itoa64(rs[0].ID)
if rs[0].State == db.RunFinalized {
t.Fatalf("expected an open run: %+v", rs[0])
}
ida, idb := itoa64(a), itoa64(b)
for _, c := range []struct {
path string
want int
}{
{cmp, http.StatusBadRequest}, // no ids
{cmp + "?base=" + ida, http.StatusBadRequest},
{cmp + "?target=" + ida, http.StatusBadRequest},
{cmp + "?base=abc&target=" + ida, http.StatusBadRequest},
{cmp + "?base=0&target=" + ida, http.StatusBadRequest},
{cmp + "?base=" + ida + "&target=" + ida, http.StatusBadRequest}, // the same run twice
{cmp + "/lists/changed?base=" + ida + "&target=" + ida, http.StatusBadRequest},
{cmp + "/lists/changed", http.StatusBadRequest},
{cmp + "?base=9999&target=" + ida, http.StatusNotFound},
{cmp + "?base=" + ida + "&target=9999", http.StatusNotFound},
{cmp + "?base=" + ida + "&target=" + open, http.StatusConflict},
{cmp + "/lists/changed?base=" + open + "&target=" + idb, http.StatusConflict},
{cmp + "/lists/nonsense" + q, http.StatusNotFound},
{cmp + "/lists/new" + q + "&indicator=nonsense", http.StatusNotFound},
{cmp + "/lists/entered" + q, http.StatusNotFound}, // an indicator is required
{cmp + "/lists/common" + q + "&from=pass", http.StatusNotFound},
{cmp + "/lists/common" + q + "&from=pass&to=cancelled", http.StatusNotFound},
} {
if resp, body := fc.do(http.MethodGet, c.path, nil); resp.StatusCode != c.want {
t.Errorf("%s: %d %s, want %d", c.path, resp.StatusCode, body, c.want)
}
}
}
// An open run has no analytics yet.
func TestAnalyticsOfOpenRunIsRefused(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t)
+2
View File
@@ -68,6 +68,8 @@ func (s *Server) routeTable() []route {
{"GET /api/v1/admin/analytics/runs", s.handleAnalyticsRuns, accessAdmin},
{"GET /api/v1/admin/analytics/runs/{id}", s.handleAnalyticsRun, accessAdmin},
{"GET /api/v1/admin/analytics/runs/{id}/lists/{kind}", s.handleAnalyticsList, accessAdmin},
{"GET /api/v1/admin/analytics/compare", s.handleAnalyticsCompare, accessAdmin},
{"GET /api/v1/admin/analytics/compare/lists/{group}", s.handleAnalyticsCompareList, accessAdmin},
{"GET /api/v1/admin/config/subnets", s.handleConfigGetSubnets, accessAdmin},
{"PUT /api/v1/admin/config/subnets", s.handleConfigPutSubnets, accessAdmin},
{"GET /api/v1/admin/config/validators", s.handleConfigListValidators, accessAdmin},