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:
ayurishchevandClaude Sonnet 5.5 committed 2026-10-06 14:23:48 +03:00
1 parent 068c10ea1c
commit ded196ec8d
40 files changed
+2545 -188

No files matched your search

+75 -14
View File
@@ -244,8 +244,10 @@ func (c *client) ScanStatus(ctx context.Context) (scanStatusDTO, error) {
type registryQuery struct {
Q string
LastResult string
Run int64 // only addresses with a result in this run
Run int64 // only addresses with a result in this run; the results shown are the run's
Subnet string // only addresses inside this CIDR
Direction string // egress|ingress — only checks of this direction
Protocol string // icmp|tcp|ssh|https|tls — only checks of this protocol
Limit int
Offset int
}
@@ -255,11 +257,20 @@ type registryQuery struct {
// history — survives an address being deleted from the queue and later
// re-added) plus the total number of rows matching the filter.
func (c *client) ListRegistryPage(ctx context.Context, q registryQuery) (registryPage, error) {
v := url.Values{}
v := q.filter()
v.Set("limit", strconv.Itoa(clampLimit(q.Limit)))
if q.Offset > 0 {
v.Set("offset", strconv.Itoa(q.Offset))
}
var out registryPage
err := c.do(ctx, http.MethodGet, "/api/v1/admin/registry?"+v.Encode(), nil, &out)
return out, err
}
// filter is the filter part of the query (everything but the page) as the
// parameters GET /admin/registry and its breakdown endpoints take.
func (q registryQuery) filter() url.Values {
v := url.Values{}
if q.Q != "" {
v.Set("q", q.Q)
}
@@ -272,11 +283,52 @@ func (c *client) ListRegistryPage(ctx context.Context, q registryQuery) (registr
if q.Subnet != "" {
v.Set("subnet", q.Subnet)
}
var out registryPage
err := c.do(ctx, http.MethodGet, "/api/v1/admin/registry?"+v.Encode(), nil, &out)
if q.Direction != "" {
v.Set("direction", q.Direction)
}
if q.Protocol != "" {
v.Set("protocol", q.Protocol)
}
return v
}
// registryBreakdownPath is the path of GET /admin/registry/breakdown, or of its
// list of one row (key) when list is set, as a CSV file when csv is.
func registryBreakdownPath(q registryQuery, key string, list, csv bool) string {
v := q.filter()
p := "/api/v1/admin/registry/breakdown"
if list {
p += "/list"
v.Set("key", key)
if csv {
v.Set("format", "csv")
}
}
return p + "?" + v.Encode()
}
// GetRegistryBreakdown returns the checks of the query's direction and protocol
// per target or site, over the addresses the query selects.
func (c *client) GetRegistryBreakdown(ctx context.Context, q registryQuery) (registryBreakdown, error) {
var out registryBreakdown
err := c.do(ctx, http.MethodGet, registryBreakdownPath(q, "", false, false), nil, &out)
return out, err
}
// GetRegistryBreakdownList returns the table (JSON) of the checks behind one
// row of the breakdown.
func (c *client) GetRegistryBreakdownList(ctx context.Context, q registryQuery, key string) (json.RawMessage, error) {
var out json.RawMessage
err := c.do(ctx, http.MethodGet, registryBreakdownPath(q, key, true, false), nil, &out)
return out, err
}
// GetRegistryBreakdownCSV returns the CSV file of that table, with the file
// name control-api proposed.
func (c *client) GetRegistryBreakdownCSV(ctx context.Context, q registryQuery, key string) ([]byte, string, error) {
return c.getCSV(ctx, registryBreakdownPath(q, key, true, true))
}
// GetRegistryHistory returns one address's registry record plus its full
// retained check history across every cycle still kept.
func (c *client) GetRegistryHistory(ctx context.Context, ip string) (registryHistoryResponse, error) {
@@ -423,19 +475,27 @@ func (c *client) ListAnalyticsRuns(ctx context.Context) ([]analyticsRun, error)
return out, err
}
// GetAnalyticsReport returns the analytics of one finished run as the raw
// JSON control-api computed; the page's script reads it as it is.
func (c *client) GetAnalyticsReport(ctx context.Context, runID int64) (json.RawMessage, error) {
// GetAnalyticsReport returns the analytics of one finished run (of the
// addresses inside subnet, unless it is empty) as the raw JSON control-api
// computed; the page's script reads it as it is.
func (c *client) GetAnalyticsReport(ctx context.Context, runID int64, subnet string) (json.RawMessage, error) {
var out json.RawMessage
err := c.do(ctx, http.MethodGet, "/api/v1/admin/analytics/runs/"+strconv.FormatInt(runID, 10), nil, &out)
p := "/api/v1/admin/analytics/runs/" + strconv.FormatInt(runID, 10)
if subnet != "" {
p += "?" + url.Values{"subnet": {subnet}}.Encode()
}
err := c.do(ctx, http.MethodGet, p, nil, &out)
return out, err
}
func analyticsListPath(runID int64, kind, class string, csv bool) string {
func analyticsListPath(runID int64, kind, class, subnet string, csv bool) string {
v := url.Values{}
if class != "" {
v.Set("class", class)
}
if subnet != "" {
v.Set("subnet", subnet)
}
if csv {
v.Set("format", "csv")
}
@@ -446,17 +506,18 @@ func analyticsListPath(runID int64, kind, class string, csv bool) string {
return p
}
// GetAnalyticsList returns one address table (JSON) of a run.
func (c *client) GetAnalyticsList(ctx context.Context, runID int64, kind, class string) (json.RawMessage, error) {
// GetAnalyticsList returns one address table (JSON) of a run, narrowed to
// subnet unless it is empty.
func (c *client) GetAnalyticsList(ctx context.Context, runID int64, kind, class, subnet string) (json.RawMessage, error) {
var out json.RawMessage
err := c.do(ctx, http.MethodGet, analyticsListPath(runID, kind, class, false), nil, &out)
err := c.do(ctx, http.MethodGet, analyticsListPath(runID, kind, class, subnet, false), nil, &out)
return out, err
}
// GetAnalyticsListCSV returns the CSV file of one address table, with the
// file name control-api proposed.
func (c *client) GetAnalyticsListCSV(ctx context.Context, runID int64, kind, class string) ([]byte, string, error) {
return c.getCSV(ctx, analyticsListPath(runID, kind, class, true))
func (c *client) GetAnalyticsListCSV(ctx context.Context, runID int64, kind, class, subnet string) ([]byte, string, error) {
return c.getCSV(ctx, analyticsListPath(runID, kind, class, subnet, true))
}
func (c *client) getCSV(ctx context.Context, path string) ([]byte, string, error) {