Registry and Analytics: run, subnet, direction and protocol filters, successes-by-target chart
Registry (/registry):
- filters by run (slice by the address's cycle in that run), subnet
(drop-down of configured subnets), direction (egress/ingress) and
protocol (icmp, tcp, ssh, https, tls); status in scope is computed over
the narrowed checks
- chart "successful checks per target (egress) / site (ingress)" when both
direction and protocol are chosen; a row opens the list of addresses
(dialog, CSV)
- API: direction/protocol parameters and run in GET /admin/registry,
GET /admin/registry/breakdown and /breakdown/list
- subnet filter passes ids as one JSON parameter (SQLite variable limit)
Analytics (/analytics):
- subnet filter recomputes the whole page over the addresses of the run
inside the subnet; only their checks are read; cache per run and subnet
- direction and protocol focus the page; with both set the registry chart
is shown
- subnet parameter in GET /admin/analytics/runs/{id} and lists (JSON, CSV)
Docs: plans and summaries in docs/changes, README, API, USAGE.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
068c10ea1c
commit
ded196ec8d
40 files changed
+2545
-188
No files matched your search
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -42,17 +43,29 @@ type subnetsDTO struct {
|
||||
Subnets []subnetDTO `json:"subnets"`
|
||||
}
|
||||
|
||||
// analyticsCache keeps the computed analysis of finalized runs. An entry is
|
||||
// analyticsCache keeps the computed analysis of finalized runs, per run and
|
||||
// subnet (the subnet narrows the report; "" is the whole run). 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.
|
||||
// the subnet list is part of the version because it changes the grouping. At
|
||||
// most analyticsCacheMax entries are kept, the least recently used one goes
|
||||
// first, so trying many subnets does not grow the memory without limit.
|
||||
type analyticsCache struct {
|
||||
mu sync.Mutex
|
||||
entries map[int64]analyticsEntry
|
||||
entries map[analyticsKey]analyticsEntry
|
||||
clock uint64
|
||||
}
|
||||
|
||||
const analyticsCacheMax = 16
|
||||
|
||||
type analyticsKey struct {
|
||||
run int64
|
||||
subnet string
|
||||
}
|
||||
|
||||
type analyticsEntry struct {
|
||||
version string
|
||||
an *analytics.Analysis
|
||||
used uint64
|
||||
}
|
||||
|
||||
func (s *Server) handleAnalyticsRuns(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -72,21 +85,30 @@ func (s *Server) handleAnalyticsRuns(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// analysisFor returns the analysis of the run named by the {id} of the path;
|
||||
// see analysisByID.
|
||||
// analysisFor returns the analysis of the run named by the {id} of the path,
|
||||
// narrowed to the ?subnet= (a CIDR) when given; 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)
|
||||
var subnet netip.Prefix
|
||||
if v := strings.TrimSpace(r.URL.Query().Get("subnet")); v != "" {
|
||||
if subnet, err = netip.ParsePrefix(v); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid subnet "+strconv.Quote(v)+" (a CIDR such as 203.0.113.0/24 is expected)")
|
||||
return nil
|
||||
}
|
||||
subnet = subnet.Masked()
|
||||
}
|
||||
return s.analysisByID(w, r, id, subnet)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// analysisByID returns the analysis of a finalized run (of the addresses
|
||||
// inside subnet, unless it is the zero prefix), 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, subnet netip.Prefix) *analytics.Analysis {
|
||||
ctx := r.Context()
|
||||
run, err := s.DB.GetRun(ctx, id)
|
||||
if err != nil {
|
||||
@@ -113,23 +135,42 @@ func (s *Server) analysisByID(w http.ResponseWriter, r *http.Request, id int64)
|
||||
}
|
||||
version := data + "#" + sb.String()
|
||||
|
||||
key := analyticsKey{run: id}
|
||||
if subnet.IsValid() {
|
||||
key.subnet = subnet.String()
|
||||
}
|
||||
s.analytics.mu.Lock()
|
||||
defer s.analytics.mu.Unlock()
|
||||
if e, ok := s.analytics.entries[id]; ok && e.version == version {
|
||||
s.analytics.clock++
|
||||
if e, ok := s.analytics.entries[key]; ok && e.version == version {
|
||||
e.used = s.analytics.clock
|
||||
s.analytics.entries[key] = e
|
||||
return e.an
|
||||
}
|
||||
an, err := analytics.Load(ctx, s.DB, id)
|
||||
an, err := analytics.Load(ctx, s.DB, id, subnet)
|
||||
if err != nil {
|
||||
writeDBError(w, err)
|
||||
return nil
|
||||
}
|
||||
if s.analytics.entries == nil {
|
||||
s.analytics.entries = map[int64]analyticsEntry{}
|
||||
s.analytics.entries = map[analyticsKey]analyticsEntry{}
|
||||
}
|
||||
s.analytics.entries[id] = analyticsEntry{version: version, an: an}
|
||||
if _, ok := s.analytics.entries[key]; !ok && len(s.analytics.entries) >= analyticsCacheMax {
|
||||
var oldest analyticsKey
|
||||
least := ^uint64(0)
|
||||
for k, e := range s.analytics.entries {
|
||||
if e.used < least {
|
||||
oldest, least = k, e.used
|
||||
}
|
||||
}
|
||||
delete(s.analytics.entries, oldest)
|
||||
}
|
||||
s.analytics.entries[key] = analyticsEntry{version: version, an: an, used: s.analytics.clock}
|
||||
return an
|
||||
}
|
||||
|
||||
// handleAnalyticsRun serves the report of a finished run, narrowed to
|
||||
// ?subnet= (a CIDR) when given.
|
||||
func (s *Server) handleAnalyticsRun(w http.ResponseWriter, r *http.Request) {
|
||||
if an := s.analysisFor(w, r); an != nil {
|
||||
writeJSON(w, http.StatusOK, an.Report)
|
||||
@@ -138,7 +179,7 @@ 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
|
||||
// handleAnalyticsList serves the address table (of the ?subnet= when given) 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
|
||||
@@ -201,11 +242,11 @@ func (s *Server) compareFor(w http.ResponseWriter, r *http.Request) (c *analytic
|
||||
writeError(w, http.StatusBadRequest, "base and target must be different runs")
|
||||
return nil, 0, 0
|
||||
}
|
||||
a := s.analysisByID(w, r, ids[0])
|
||||
a := s.analysisByID(w, r, ids[0], netip.Prefix{})
|
||||
if a == nil {
|
||||
return nil, 0, 0
|
||||
}
|
||||
b := s.analysisByID(w, r, ids[1])
|
||||
b := s.analysisByID(w, r, ids[1], netip.Prefix{})
|
||||
if b == nil {
|
||||
return nil, 0, 0
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user