Analytics: make the pass, partial and fail indicators clickable
The three verdict cards on /analytics now open the same dialog as the https/ssh
cards, with the addresses of the run that got this verdict (CSV and copy
included). New list kinds verdict_pass, verdict_partial and verdict_fail in
GET /admin/analytics/runs/{id}/lists/{kind}: address, subnet, validator,
egress and ingress "ok of all", checks stored of expected; partial adds the
reason, the same names as the "Why partial" block. Cancelled addresses are not
listed; the row count equals summary.pass/partial/fail. The fail card stays
inert at zero. addr.incomplete() is shared by the list and the report.
Docs, plan and summary in docs/changes/.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e95b5eb7d5
commit
2f038f8362
15 files changed
+294
-14
No files matched your search
@@ -160,6 +160,10 @@ type addr struct {
|
||||
lateFailed int
|
||||
}
|
||||
|
||||
// incomplete is true when fewer checks are stored than the cycle expected; an
|
||||
// unknown expectation (-1) is never incomplete.
|
||||
func (a *addr) incomplete() bool { return a.res.ExpectedChecks >= 0 && a.stored < a.res.ExpectedChecks }
|
||||
|
||||
// Analysis is a computed report plus the per-address data the lists are cut from.
|
||||
type Analysis struct {
|
||||
Report Report
|
||||
@@ -334,7 +338,7 @@ func Compute(in Input) (*Analysis, error) {
|
||||
|
||||
egFail := a.egress.ok < a.egress.n
|
||||
inFail := a.ingress.ok < a.ingress.n
|
||||
incomplete := a.res.ExpectedChecks >= 0 && a.stored < a.res.ExpectedChecks
|
||||
incomplete := a.incomplete()
|
||||
if incomplete {
|
||||
rep.Quality.Incomplete++
|
||||
}
|
||||
|
||||
@@ -227,3 +227,100 @@ func TestListsAndShortValidator(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerdictLists(t *testing.T) {
|
||||
f := &fixture{}
|
||||
f.addr(1, "10.0.0.10", db.ResultPass, 4) // late failed ssh: pass by verdict, failed by facts
|
||||
f.addr(2, "10.0.0.2", db.ResultPass, 4)
|
||||
f.addr(3, "10.0.0.9", db.ResultPartial, 4) // egress failed, one check missing
|
||||
f.addr(4, "10.0.0.3", db.ResultPartial, -1) // expected number unknown
|
||||
f.addr(5, "10.0.0.5", db.ResultFail, 1) // no egress checks at all
|
||||
f.addr(6, "10.0.0.4", db.ResultFail, 2)
|
||||
f.addr(7, "10.0.0.1", db.ResultCancelled, 4)
|
||||
for _, reg := range []int64{1, 2} {
|
||||
f.check(reg, eg, "https", "https://a.test", true, "vkiplab-v1", "", false)
|
||||
f.check(reg, eg, "https", "https://b.test", true, "vkiplab-v1", "", false)
|
||||
f.check(reg, s1, "icmp", "ip", true, "vkiplab-v1", "", false)
|
||||
}
|
||||
f.check(1, s1, "ssh", "ip", false, "vkiplab-v1", "dial tcp: i/o timeout", true)
|
||||
f.check(2, s1, "ssh", "ip", true, "vkiplab-v1", "", false)
|
||||
f.check(3, eg, "https", "https://a.test", false, "vkiplab-v12", "", false)
|
||||
f.check(3, eg, "https", "https://b.test", false, "vkiplab-v12", "", false)
|
||||
f.check(3, s1, "icmp", "ip", true, "vkiplab-v12", "", false)
|
||||
f.check(4, eg, "https", "https://a.test", true, "vkiplab-v3", "", false)
|
||||
f.check(4, s1, "ssh", "ip", false, "vkiplab-v3", "dial tcp: i/o timeout", false)
|
||||
f.check(5, s1, "icmp", "ip", false, "vkiplab-v5", "", false)
|
||||
f.check(6, eg, "https", "https://a.test", false, "vkiplab-v5", "", false)
|
||||
f.check(6, s1, "icmp", "ip", false, "vkiplab-v5", "", false)
|
||||
f.check(7, eg, "https", "https://a.test", true, "vkiplab-v1", "", false)
|
||||
an := f.compute(t, nil)
|
||||
|
||||
// Every list has as many rows as its number on the card; the cancelled address is in none.
|
||||
s := an.Report.Summary
|
||||
rows := map[string][][]string{}
|
||||
for kind, want := range map[string]int{ListVerdictPass: s.Pass, ListVerdictPartial: s.Partial, ListVerdictFail: s.Fail} {
|
||||
l, err := an.List(kind, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l.Kind != kind || len(l.Rows) != want || want != 2 {
|
||||
t.Fatalf("%s: %d rows, summary %d", kind, len(l.Rows), want)
|
||||
}
|
||||
for _, r := range l.Rows {
|
||||
if r[0] == "10.0.0.1" || len(r) != len(l.Columns) {
|
||||
t.Errorf("%s: bad row %v for columns %v", kind, r, l.Columns)
|
||||
}
|
||||
}
|
||||
rows[kind] = l.Rows
|
||||
}
|
||||
|
||||
// Numeric address order, not text order.
|
||||
if p := rows[ListVerdictPass]; p[0][0] != "10.0.0.2" || p[1][0] != "10.0.0.10" {
|
||||
t.Errorf("pass order: %v", p)
|
||||
}
|
||||
l, _ := an.List(ListVerdictPass, "")
|
||||
if want := []string{"Адрес", "Подсеть", "Валидатор", "Egress", "Ingress", "Проверок в цикле"}; !reflect.DeepEqual(l.Columns, want) {
|
||||
t.Errorf("pass columns: %v", l.Columns)
|
||||
}
|
||||
// Levels count successful of all; the failed late ssh shows in ingress.
|
||||
if want := []string{"10.0.0.2", "10.0.0.0/24", "v1", "2 из 2", "2 из 2", "4 из 4"}; !reflect.DeepEqual(rows[ListVerdictPass][0], want) {
|
||||
t.Errorf("pass row: %v", rows[ListVerdictPass][0])
|
||||
}
|
||||
if r := rows[ListVerdictPass][1]; r[3] != "2 из 2" || r[4] != "1 из 2" || r[5] != "4 из 4" {
|
||||
t.Errorf("pass with a late failure: %v", r)
|
||||
}
|
||||
|
||||
// Partial has the reason of the "why partial" block; a short set shows stored of expected,
|
||||
// an unknown expectation only the stored number.
|
||||
l, _ = an.List(ListVerdictPartial, "")
|
||||
if len(l.Columns) != 7 || l.Columns[6] != "Причина" {
|
||||
t.Errorf("partial columns: %v", l.Columns)
|
||||
}
|
||||
if want := []string{"10.0.0.3", "10.0.0.0/24", "v3", "1 из 1", "0 из 1", "2", "Только ingress"}; !reflect.DeepEqual(l.Rows[0], want) {
|
||||
t.Errorf("partial unknown expected: %v", l.Rows[0])
|
||||
}
|
||||
if want := []string{"10.0.0.9", "10.0.0.0/24", "v12", "0 из 2", "1 из 1", "3 из 4", "Egress и неполный набор"}; !reflect.DeepEqual(l.Rows[1], want) {
|
||||
t.Errorf("partial incomplete: %v", l.Rows[1])
|
||||
}
|
||||
byReason := map[string]int{}
|
||||
for _, r := range l.Rows {
|
||||
byReason[r[6]]++
|
||||
}
|
||||
for _, x := range an.Report.Reasons {
|
||||
if byReason[x.Name] != x.Count {
|
||||
t.Errorf("reason %q: %d rows, %d in the report", x.Name, byReason[x.Name], x.Count)
|
||||
}
|
||||
}
|
||||
|
||||
// A level without checks shows a dash, so does a missing validator.
|
||||
l, _ = an.List(ListVerdictFail, "")
|
||||
if len(l.Columns) != 6 {
|
||||
t.Errorf("fail columns: %v", l.Columns)
|
||||
}
|
||||
if want := []string{"10.0.0.4", "10.0.0.0/24", "v5", "0 из 1", "0 из 1", "2 из 2"}; !reflect.DeepEqual(l.Rows[0], want) {
|
||||
t.Errorf("fail row: %v", l.Rows[0])
|
||||
}
|
||||
if want := []string{"10.0.0.5", "10.0.0.0/24", "—", "—", "0 из 1", "1 из 1"}; !reflect.DeepEqual(l.Rows[1], want) {
|
||||
t.Errorf("fail row without egress: %v", l.Rows[1])
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ const (
|
||||
ListIngressSSHAny = "ingress_ssh_any"
|
||||
ListIngressSSHAll = "ingress_ssh_all"
|
||||
ListError = "error"
|
||||
// Addresses by the verdict the system gave them at aggregation.
|
||||
ListVerdictPass = "verdict_" + db.ResultPass
|
||||
ListVerdictPartial = "verdict_" + db.ResultPartial
|
||||
ListVerdictFail = "verdict_" + db.ResultFail
|
||||
)
|
||||
|
||||
// List is a table of addresses behind one indicator or one error class.
|
||||
@@ -73,6 +77,22 @@ func (an *Analysis) List(kind, class string) (*List, error) {
|
||||
l.Rows = append(l.Rows, []string{a.res.IPAddress, a.subnet, strings.Join(sites, ", "), strings.Join(errs, ", ")})
|
||||
}
|
||||
}
|
||||
case ListVerdictPass, ListVerdictPartial, ListVerdictFail:
|
||||
verdict := strings.TrimPrefix(kind, "verdict_")
|
||||
l.Columns = []string{"Адрес", "Подсеть", "Валидатор", "Egress", "Ingress", "Проверок в цикле"}
|
||||
if kind == ListVerdictPartial {
|
||||
l.Columns = append(l.Columns, "Причина")
|
||||
}
|
||||
for _, a := range an.sorted() {
|
||||
if a.res.Verdict != verdict {
|
||||
continue
|
||||
}
|
||||
row := []string{a.res.IPAddress, a.subnet, orDash(ShortValidator(a.https.validator)), okOf(a.egress), okOf(a.ingress), a.checksCell()}
|
||||
if kind == ListVerdictPartial {
|
||||
row = append(row, reasonName(a.egress.ok < a.egress.n, a.ingress.ok < a.ingress.n, a.incomplete()))
|
||||
}
|
||||
l.Rows = append(l.Rows, row)
|
||||
}
|
||||
case ListError:
|
||||
if class == "" {
|
||||
return nil, ErrUnknownList("error without class")
|
||||
@@ -98,6 +118,29 @@ func (an *Analysis) List(kind, class string) (*List, error) {
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// checksCell is "stored из expected", or just stored when expected is unknown.
|
||||
func (a *addr) checksCell() string {
|
||||
if a.res.ExpectedChecks < 0 {
|
||||
return fmt.Sprint(a.stored)
|
||||
}
|
||||
return fmt.Sprintf("%d из %d", a.stored, a.res.ExpectedChecks)
|
||||
}
|
||||
|
||||
// okOf is "successful из all" of one level, "—" when it has no checks.
|
||||
func okOf(t typeStat) string {
|
||||
if t.n == 0 {
|
||||
return "—"
|
||||
}
|
||||
return fmt.Sprintf("%d из %d", t.ok, t.n)
|
||||
}
|
||||
|
||||
func orDash(s string) string {
|
||||
if s == "" {
|
||||
return "—"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// sorted returns the non-cancelled addresses in numeric address order.
|
||||
func (an *Analysis) sorted() []*addr {
|
||||
out := make([]*addr, 0, len(an.addrs))
|
||||
|
||||
Reference in new issue
Block a user