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))
|
||||
|
||||
@@ -23,13 +23,13 @@
|
||||
|
||||
/* ---- indicators ---- */
|
||||
function renderKpis() {
|
||||
var late = Q.late_failed_checks_at_pass;
|
||||
var late = Q.late_failed_checks_at_pass, GO = ' <span class="an-go">список →</span>';
|
||||
var tiles = [
|
||||
['Адресов', fmt(S.addresses), 'последний цикл каждого адреса', '', ''],
|
||||
['pass', fmt(S.pass), pct(S.pass, S.addresses) + '% адресов', '<span class="an-tag an-t-ok">успех</span>', ''],
|
||||
['partial', fmt(S.partial), pct(S.partial, S.addresses) + '% адресов', '<span class="an-tag an-t-warn">частично</span>', ''],
|
||||
['pass', fmt(S.pass), pct(S.pass, S.addresses) + '% адресов', '<span class="an-tag an-t-ok">успех</span>' + GO, 'verdict_pass'],
|
||||
['partial', fmt(S.partial), pct(S.partial, S.addresses) + '% адресов', '<span class="an-tag an-t-warn">частично</span>' + GO, 'verdict_partial'],
|
||||
['fail', fmt(S.fail), S.fail ? pct(S.fail, S.addresses) + '% адресов' : 'ни одной полностью проваленной',
|
||||
S.fail ? '<span class="an-tag an-t-bad">провал</span>' : '<span class="an-tag an-t-n">нет</span>', ''],
|
||||
S.fail ? '<span class="an-tag an-t-bad">провал</span>' + GO : '<span class="an-tag an-t-n">нет</span>', S.fail ? 'verdict_fail' : ''],
|
||||
['Egress OK', pct1(S.egress_ok, S.addresses) + '%', 'все egress-проверки адреса успешны', '', ''],
|
||||
['Ingress OK', pct1(S.ingress_ok, S.addresses) + '%', 'все ingress-проверки адреса успешны', '', ''],
|
||||
['Egress https: есть провалы', fmt(S.egress_https_any_failed), pct1(S.egress_https_any_failed, S.addresses) + '% адресов, хотя бы одна цель недоступна',
|
||||
@@ -168,7 +168,27 @@
|
||||
};
|
||||
function statusVal(v) { return String(v).indexOf('после') >= 0 ? 'Результат пришёл после вердикта адреса и в него не вошёл.' : 'Результат пришёл до вердикта и учтён в нём.'; }
|
||||
|
||||
var VERDICT_NOTE = ' Это вердикт адреса, который система выставила при агрегации, а не итог всех проверок: после него могли прийти ещё результаты, поэтому у адреса pass бывают проваленные проверки. Столбцы Egress и Ingress показывают фактические проверки адреса.';
|
||||
var VERDICT_HINTS = {
|
||||
2: 'Валидатор, с которого шли egress-проверки адреса. «—» — у адреса нет https-проверок.',
|
||||
3: 'Сколько egress-проверок адреса успешно из всех записанных в цикле. «—» — egress-проверок нет.',
|
||||
4: 'Сколько ingress-проверок адреса успешно из всех записанных в цикле (со всех площадок и всех типов). «—» — ingress-проверок нет.',
|
||||
5: 'Сколько проверок записано в цикле из ожидаемых. Меньше ожидаемого значит, что часть результатов не пришла (неполный набор). Если ожидаемое число неизвестно, показано только записанное.'
|
||||
};
|
||||
function verdictList(title, note, reason) {
|
||||
var hints = {};
|
||||
Object.keys(VERDICT_HINTS).forEach(function (k) { hints[k] = VERDICT_HINTS[k]; });
|
||||
if (reason) hints[6] = 'Что не так у адреса: провалены egress-проверки, ingress-проверки или не хватает результатов (неполный набор). Те же названия, что в блоке «Почему partial».';
|
||||
return { title: title, note: note + VERDICT_NOTE, hints: hints };
|
||||
}
|
||||
|
||||
var LISTS = {
|
||||
verdict_pass: verdictList('Адреса с вердиктом pass',
|
||||
'Вердикт pass: адрес за последний цикл запуска получил успех по всем проверкам, полученным к моменту агрегации.'),
|
||||
verdict_partial: verdictList('Адреса с вердиктом partial',
|
||||
'Вердикт partial: часть проверок адреса провалена или результатов не хватило.', true),
|
||||
verdict_fail: verdictList('Адреса с вердиктом fail',
|
||||
'Вердикт fail: все проверки адреса провалены.'),
|
||||
egress_https_any: {
|
||||
title: 'Egress https: адреса с проваленными проверками',
|
||||
note: 'Хотя бы одна egress-проверка https адреса провалена в последнем цикле запуска.',
|
||||
|
||||
@@ -133,7 +133,8 @@ func (s *Server) handleAnalyticsRun(w http.ResponseWriter, r *http.Request) {
|
||||
var nonSlug = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// handleAnalyticsList serves the address table behind one indicator
|
||||
// (kind = egress_https_any|egress_https_all|ingress_ssh_any|ingress_ssh_all)
|
||||
// (kind = egress_https_any|egress_https_all|ingress_ssh_any|ingress_ssh_all),
|
||||
// the addresses of one verdict (kind = verdict_pass|verdict_partial|verdict_fail)
|
||||
// or one ingress error class (kind = error, ?class=...), as JSON or, with
|
||||
// ?format=csv, as a downloadable CSV file.
|
||||
func (s *Server) handleAnalyticsList(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -106,6 +106,16 @@ func TestAnalyticsEndpoints(t *testing.T) {
|
||||
!strings.Contains(resp.Header.Get("Content-Disposition"), `attachment; filename="ingress_ssh_any_run`+itoa64(id)+`.csv"`) {
|
||||
t.Fatalf("csv: %d %v %q", resp.StatusCode, resp.Header, body)
|
||||
}
|
||||
// A verdict list: 9.9.9.2 is the only partial address.
|
||||
resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/verdict_partial", nil)
|
||||
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.2" || l.Columns[len(l.Columns)-1] != "Причина" {
|
||||
t.Fatalf("verdict list: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/verdict_partial?format=csv", nil)
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "9.9.9.2") ||
|
||||
!strings.Contains(resp.Header.Get("Content-Disposition"), `attachment; filename="verdict_partial_run`+itoa64(id)+`.csv"`) {
|
||||
t.Fatalf("verdict csv: %d %v %q", resp.StatusCode, resp.Header, body)
|
||||
}
|
||||
q := url.Values{"class": {"SSH: таймаут"}, "format": {"csv"}}
|
||||
resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/error?"+q.Encode(), nil)
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(resp.Header.Get("Content-Disposition"), "error-ssh_run") {
|
||||
@@ -113,8 +123,9 @@ func TestAnalyticsEndpoints(t *testing.T) {
|
||||
}
|
||||
|
||||
for path, want := range map[string]int{
|
||||
base + "/" + itoa64(id) + "/lists/error": http.StatusNotFound, // class missing
|
||||
base + "/" + itoa64(id) + "/lists/nonsense": http.StatusNotFound,
|
||||
base + "/" + itoa64(id) + "/lists/error": http.StatusNotFound, // class missing
|
||||
base + "/" + itoa64(id) + "/lists/nonsense": http.StatusNotFound,
|
||||
base + "/" + itoa64(id) + "/lists/verdict_cancelled": http.StatusNotFound,
|
||||
base + "/9999": http.StatusNotFound,
|
||||
base + "/abc": http.StatusBadRequest,
|
||||
base + "/9999/lists/ingress_ssh_any": http.StatusNotFound,
|
||||
|
||||
Reference in new issue
Block a user