Files
cloud-ip-validator/internal/httpapi/handlers_analytics.go
T
ayurishchevandClaude Sonnet 5.5 068c10ea1c Analytics: compare two finished runs
New page /analytics/compare and API GET /admin/analytics/compare (+ /lists/{group}):
the administrator picks an old (A) and a new (B) run; the report shows the new
addresses (only in B), the ones that left (only in A) and the common ones whose
membership in the seven indicators (pass, partial, fail, egress https any/all,
ingress ssh any/all) differs, with a "what changed" summary per address; the
dynamics of each indicator (delta = new - left + entered - exited) and a verdict
transition matrix. Every number opens a list with CSV. Cancelled addresses are not
part of a run. The list dialog moved to a shared analytics-dialog.js and template;
/analytics got a "compare with another run" button.

Docs, plan and summary in docs/changes/.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:26:37 +03:00

285 lines
8.6 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 the run named by the {id} of the path;
// see analysisByID.
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
}
return s.analysisByID(w, r, id)
}
// analysisByID 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) analysisByID(w http.ResponseWriter, r *http.Request, id int64) *analytics.Analysis {
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
}
name := kind
if kind == analytics.ListError {
if slug := strings.Trim(nonSlug.ReplaceAllString(strings.ToLower(class), "-"), "-"); slug != "" {
name += "-" + slug
} else {
name += "-class"
}
}
writeCSV(w, list.Columns, list.Rows, fmt.Sprintf("%s_run%s.csv", name, r.PathValue("id")))
}
// writeCSV sends a table as a downloadable CSV file.
func writeCSV(w http.ResponseWriter, columns []string, rows [][]string, filename string) {
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(columns)
_ = cw.WriteAll(rows)
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(buf.Bytes())
}
// compareFor loads the two runs named by ?base=A&target=B (the older and the
// newer one) and compares them. It writes the error response itself and
// returns nil when it cannot: 400 for a missing or malformed id or the same
// run twice, 404 for an unknown run, 409 for one that is still open.
func (s *Server) compareFor(w http.ResponseWriter, r *http.Request) (c *analytics.Comparison, base, target int64) {
ids := [2]int64{}
for i, name := range []string{"base", "target"} {
id, err := strconv.ParseInt(r.URL.Query().Get(name), 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "invalid or missing "+name+" run id")
return nil, 0, 0
}
ids[i] = id
}
if ids[0] == ids[1] {
writeError(w, http.StatusBadRequest, "base and target must be different runs")
return nil, 0, 0
}
a := s.analysisByID(w, r, ids[0])
if a == nil {
return nil, 0, 0
}
b := s.analysisByID(w, r, ids[1])
if b == nil {
return nil, 0, 0
}
return analytics.Compare(a, b), ids[0], ids[1]
}
// handleAnalyticsCompare serves the comparison of two finished runs:
// ?base=A (older) &target=B (newer).
func (s *Server) handleAnalyticsCompare(w http.ResponseWriter, r *http.Request) {
if c, _, _ := s.compareFor(w, r); c != nil {
writeJSON(w, http.StatusOK, c)
}
}
// handleAnalyticsCompareList serves the address table of one group of the
// comparison (new|left|common|changed|same|entered|exited), narrowed by
// ?indicator=... and ?from=...&to=... (verdicts), as JSON or, with
// ?format=csv, as a downloadable CSV file.
func (s *Server) handleAnalyticsCompareList(w http.ResponseWriter, r *http.Request) {
c, base, target := s.compareFor(w, r)
if c == nil {
return
}
q := r.URL.Query()
group := r.PathValue("group")
f := analytics.CompareFilter{Indicator: q.Get("indicator"), From: q.Get("from"), To: q.Get("to")}
list, err := c.List(group, f)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
if q.Get("format") != "csv" {
writeJSON(w, http.StatusOK, list)
return
}
name := "compare_" + group
if f.Indicator != "" {
name += "_" + f.Indicator
}
if f.From != "" {
name += "_" + f.From + "-" + f.To
}
writeCSV(w, list.Columns, list.Rows, fmt.Sprintf("%s_run%d-%d.csv", name, base, target))
}
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)
}