Add the Analytics section: check runs, analytics API and page
Runs (migration 0011): a run groups the cycles of one launch. It opens when an address enters an idle queue, takes everything submitted or re-checked while it is open and is finalized when all its addresses are done; a re-check after that opens a new run, so results of different runs never mix. check_runs, run_results (one result per address and run, with the verdict and the expected and stored check counts), subnets, run_id on ip_queue and checks. Existing data is split into runs at pauses of more than an hour; ingress checks get the validator that held the address (also at write time from now on). Analytics (internal/analytics): figures computed from the stored checks of the latest cycle of each address in the run, as facts next to the verdict: summary, reasons of partial, data quality, subnets, targets and the subnet x target matrix by check type, ingress by site, error classes, validators, and the address lists behind the indicators and error classes. API: analytics runs, report, lists (JSON or CSV), subnet list; run and subnet filters for the registry. Dashboard: /analytics matching the approved mockup (run selector, indicators with address lists and CSV, error-class dialogs, drill-down to the registry), subnet list on /settings. Sidebar: the control-api link state, theme toggle and logout moved to the top, the three dots next to the logo removed, sections grouped. Rebuilt bin/control-api and bin/admin-dashboard to match. Plan, summary and the updated README, API, USAGE, DASHBOARD and ADMIN_CLEANUP docs are in docs/. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
864208238f
commit
b7669c9e41
44 files changed
+4123
-62
No files matched your search
@@ -0,0 +1,204 @@
|
||||
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)
|
||||
// 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)
|
||||
}
|
||||
Reference in new issue
Block a user