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:
ayurishchevandClaude Sonnet 5.5 committed 2026-10-04 10:00:39 +03:00
1 parent e95b5eb7d5
commit 2f038f8362
15 files changed
+294 -14

No files matched your search

+5 -1
View File
@@ -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++
}
+97
View File
@@ -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])
}
}
+43
View File
@@ -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))
+24 -4
View File
@@ -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 адреса провалена в последнем цикле запуска.',
+2 -1
View File
@@ -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) {
+13 -2
View File
@@ -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,