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

+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,