Files
cloud-ip-validator/internal/httpapi/handlers_analytics.go
T
ayurishchevandClaude Sonnet 5.5 2f038f8362 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>
2026-10-04 10:00:39 +03:00

206 lines
6.0 KiB
Go

package httpapi
import (
"bytes"
"encoding/csv"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
"cloudipvalidator/internal/analytics"
"cloudipvalidator/internal/db"
)
// analyticsRunDTO is one entry of the run selector.
type analyticsRunDTO struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
State string `json:"state"`
StartedAt time.Time `json:"started_at"`
FinalizedAt *time.Time `json:"finalized_at"`
Addresses int `json:"addresses"`
Pass int `json:"pass"`
Partial int `json:"partial"`
Fail int `json:"fail"`
Cancelled int `json:"cancelled"`
// Total is the number of queue rows of the run, Pending those still being
// processed (only an open run has any).
Total int `json:"total"`
Pending int `json:"pending"`
}
type subnetDTO struct {
CIDR string `json:"cidr"`
Label string `json:"label,omitempty"`
}
type subnetsDTO struct {
Subnets []subnetDTO `json:"subnets"`
}
// analyticsCache keeps the computed analysis of finalized runs. An entry is
// valid while the run's data version (checks written, results) is unchanged;
// the subnet list is part of the key because it changes the grouping.
type analyticsCache struct {
mu sync.Mutex
entries map[int64]analyticsEntry
}
type analyticsEntry struct {
version string
an *analytics.Analysis
}
func (s *Server) handleAnalyticsRuns(w http.ResponseWriter, r *http.Request) {
runs, err := s.DB.ListRuns(r.Context())
if err != nil {
writeDBError(w, err)
return
}
out := make([]analyticsRunDTO, 0, len(runs))
for _, x := range runs {
out = append(out, analyticsRunDTO{
ID: x.ID, Kind: x.Kind, State: x.State, StartedAt: x.StartedAt, FinalizedAt: x.FinalizedAt,
Addresses: x.Addresses, Pass: x.Pass, Partial: x.Partial, Fail: x.Fail, Cancelled: x.Cancelled,
Total: x.Total, Pending: x.Pending,
})
}
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.
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
}
ctx := r.Context()
run, err := s.DB.GetRun(ctx, id)
if err != nil {
writeDBError(w, err)
return nil
}
if run.State != db.RunFinalized {
writeError(w, http.StatusConflict, "run is still open: analytics are available for finished runs")
return nil
}
data, err := s.DB.RunDataVersion(ctx, id)
if err != nil {
writeDBError(w, err)
return nil
}
subnets, err := s.DB.ListSubnets(ctx)
if err != nil {
writeDBError(w, err)
return nil
}
var sb strings.Builder
for _, x := range subnets {
sb.WriteString(x.CIDR + "|" + x.Label + ";")
}
version := data + "#" + sb.String()
s.analytics.mu.Lock()
defer s.analytics.mu.Unlock()
if e, ok := s.analytics.entries[id]; ok && e.version == version {
return e.an
}
an, err := analytics.Load(ctx, s.DB, id)
if err != nil {
writeDBError(w, err)
return nil
}
if s.analytics.entries == nil {
s.analytics.entries = map[int64]analyticsEntry{}
}
s.analytics.entries[id] = analyticsEntry{version: version, an: an}
return an
}
func (s *Server) handleAnalyticsRun(w http.ResponseWriter, r *http.Request) {
if an := s.analysisFor(w, r); an != nil {
writeJSON(w, http.StatusOK, an.Report)
}
}
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),
// 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) {
an := s.analysisFor(w, r)
if an == nil {
return
}
kind, class := r.PathValue("kind"), r.URL.Query().Get("class")
list, err := an.List(kind, class)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
if r.URL.Query().Get("format") != "csv" {
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 != "" {
name += "-" + slug
} else {
name += "-class"
}
}
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.WriteHeader(http.StatusOK)
_, _ = w.Write(buf.Bytes())
}
func (s *Server) handleConfigGetSubnets(w http.ResponseWriter, r *http.Request) {
list, err := s.DB.ListSubnets(r.Context())
if err != nil {
writeDBError(w, err)
return
}
out := subnetsDTO{Subnets: make([]subnetDTO, 0, len(list))}
for _, x := range list {
out.Subnets = append(out.Subnets, subnetDTO{CIDR: x.CIDR, Label: x.Label})
}
writeJSON(w, http.StatusOK, out)
}
// handleConfigPutSubnets replaces the whole subnet list. The list groups the
// addresses on the analytics page; with none configured they group by /24.
func (s *Server) handleConfigPutSubnets(w http.ResponseWriter, r *http.Request) {
var req subnetsDTO
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
return
}
in := make([]db.Subnet, 0, len(req.Subnets))
for _, x := range req.Subnets {
in = append(in, db.Subnet{CIDR: strings.TrimSpace(x.CIDR), Label: x.Label})
}
if err := s.DB.ReplaceSubnets(r.Context(), in); err != nil {
writeDBError(w, err)
return
}
s.handleConfigGetSubnets(w, r)
}