mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-16 03:00:29 +00:00
feat(stats): filter scope counts by IATA and region (#142)
* feat(stats): filter scope counts by IATA and region * perf(stats): aggregate region observations before counting scopes
This commit is contained in:
+29
-5
@@ -1102,14 +1102,38 @@ WHERE ($1::text = '' OR preset = $1::text)
|
||||
ORDER BY preset, iata, source_type;
|
||||
|
||||
-- name: GetScopeStats :many
|
||||
-- Count each table on its own; the old cross-join blew up to millions of rows
|
||||
-- before COUNT(DISTINCT) (~10s).
|
||||
-- Aggregate matching observations once, separately from node memberships to avoid
|
||||
-- a cross-join. Empty IATAs keep the original global counts, including associations
|
||||
-- whose observations have expired; the filtered aggregates are empty in that case.
|
||||
WITH observation_counts AS (
|
||||
SELECT p.scope_id,
|
||||
COUNT(DISTINCT p.packet_hash) AS packet_count,
|
||||
COUNT(DISTINCT os.observer_id) AS observer_count
|
||||
FROM packet_observations po
|
||||
JOIN packets p ON p.packet_hash = po.packet_hash
|
||||
LEFT JOIN observer_scopes os ON os.scope_id = p.scope_id AND os.observer_id = po.observer_id
|
||||
WHERE po.iata = ANY(sqlc.arg(iatas)::bpchar[]) AND p.scope_id IS NOT NULL
|
||||
GROUP BY p.scope_id
|
||||
), node_counts AS (
|
||||
SELECT n.default_scope_id AS scope_id, COUNT(*) AS node_count
|
||||
FROM nodes n
|
||||
WHERE n.id IN (SELECT node_id FROM node_iatas WHERE iata = ANY(sqlc.arg(iatas)::bpchar[]))
|
||||
GROUP BY n.default_scope_id
|
||||
)
|
||||
SELECT
|
||||
ts.name,
|
||||
(SELECT COUNT(*) FROM packets p WHERE p.scope_id = ts.id) AS packet_count,
|
||||
(SELECT COUNT(*) FROM observer_scopes os WHERE os.scope_id = ts.id) AS observer_count,
|
||||
(SELECT COUNT(*) FROM nodes n WHERE n.default_scope_id = ts.id) AS node_count
|
||||
CASE WHEN COALESCE(cardinality(sqlc.arg(iatas)::bpchar[]), 0) = 0
|
||||
THEN (SELECT COUNT(*) FROM packets p WHERE p.scope_id = ts.id)
|
||||
ELSE COALESCE(oc.packet_count, 0) END::bigint AS packet_count,
|
||||
CASE WHEN COALESCE(cardinality(sqlc.arg(iatas)::bpchar[]), 0) = 0
|
||||
THEN (SELECT COUNT(*) FROM observer_scopes os WHERE os.scope_id = ts.id)
|
||||
ELSE COALESCE(oc.observer_count, 0) END::bigint AS observer_count,
|
||||
CASE WHEN COALESCE(cardinality(sqlc.arg(iatas)::bpchar[]), 0) = 0
|
||||
THEN (SELECT COUNT(*) FROM nodes n WHERE n.default_scope_id = ts.id)
|
||||
ELSE COALESCE(nc.node_count, 0) END::bigint AS node_count
|
||||
FROM transport_scopes ts
|
||||
LEFT JOIN observation_counts oc ON oc.scope_id = ts.id
|
||||
LEFT JOIN node_counts nc ON nc.scope_id = ts.id
|
||||
ORDER BY ts.name;
|
||||
|
||||
-- ============================================================
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Run on a migrated private database. All fixture tables and writes are rolled back.
|
||||
func TestScopeStatsPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("BEACON_TEST_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set BEACON_TEST_POSTGRES_DSN for PostgreSQL regression")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
conn, err := pgx.Connect(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close(context.Background())
|
||||
tx, err := conn.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
for _, table := range []string{"transport_scopes", "packets", "packet_observations", "observer_scopes", "nodes", "node_iatas"} {
|
||||
if _, err := tx.Exec(ctx, "CREATE TEMP TABLE "+table+" (LIKE public."+table+" INCLUDING ALL) ON COMMIT DROP"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO transport_scopes (id,name,transport_key,key_fingerprint)
|
||||
SELECT i,name,decode(repeat('00',16),'hex'),decode(lpad(to_hex(i),16,'0'),'hex')
|
||||
FROM (VALUES (1,'#a'),(2,'#b'),(3,'#unused')) v(i,name);
|
||||
INSERT INTO packets (packet_hash,scope_id,payload_type,payload_version,route_type,raw_payload,raw_header,first_heard_at,last_heard_at)
|
||||
SELECT decode(lpad(to_hex(i),2,'0'),'hex'),scope_id,4,0,1,'\x00','\x00',NOW(),NOW()
|
||||
FROM (VALUES (1,1),(2,1),(3,1),(4,1),(5,2),(6,2),(7,2),(8,NULL)) v(i,scope_id);
|
||||
INSERT INTO observer_scopes (observer_id,scope_id)
|
||||
SELECT md5(i::text)::uuid,scope_id FROM (VALUES (1,1),(2,1),(4,1),(5,1),(1,2),(2,2),(3,2)) v(i,scope_id);
|
||||
INSERT INTO packet_observations (id,packet_hash,observer_id,iata,heard_at,path_length_byte,hash_size,hop_count)
|
||||
SELECT id,decode(lpad(to_hex(packet),2,'0'),'hex'),md5(observer::text)::uuid,iata,
|
||||
'2026-01-01'::timestamptz+id*interval '1 second',0,1,0
|
||||
FROM (VALUES (1,1,1,'YVR'),(2,1,4,'YVR'),(3,1,2,'YYJ'),(4,2,2,'YVR'),
|
||||
(5,3,1,'YYZ'),(6,5,3,'YVR'),(7,6,1,'YYJ'),(8,7,2,'YYZ'),(9,8,1,'YVR')) v(id,packet,observer,iata);
|
||||
INSERT INTO nodes (id,public_key,node_type,default_scope_id)
|
||||
SELECT md5(i::text)::uuid,decode(lpad(to_hex(i),64,'0'),'hex'),2,scope_id
|
||||
FROM (VALUES (1,1),(2,1),(3,1),(4,1),(5,2),(6,2),(7,NULL)) v(i,scope_id);
|
||||
INSERT INTO node_iatas (node_id,iata)
|
||||
SELECT md5(i::text)::uuid,iata FROM (VALUES (1,'YVR'),(1,'YYJ'),(2,'YVR'),(3,'YYZ'),(5,'YYJ'),(6,'YVR'),(6,'YYJ'),(7,'YVR')) v(i,iata);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := &Store{q: sqlc.New(tx)}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
iatas []string
|
||||
a, b [3]int64 // packets, observers, nodes
|
||||
}{
|
||||
{"global", nil, [3]int64{4, 4, 4}, [3]int64{3, 3, 2}},
|
||||
{"empty filter", []string{}, [3]int64{4, 4, 4}, [3]int64{3, 3, 2}},
|
||||
{"YVR", []string{"YVR"}, [3]int64{2, 3, 2}, [3]int64{1, 1, 1}},
|
||||
{"YYJ", []string{"YYJ"}, [3]int64{1, 1, 1}, [3]int64{1, 1, 2}},
|
||||
{"YYZ", []string{"YYZ"}, [3]int64{1, 1, 1}, [3]int64{1, 1, 0}},
|
||||
{"overlapping IATAs", []string{"YVR", "YYJ"}, [3]int64{2, 3, 2}, [3]int64{2, 2, 2}},
|
||||
{"duplicate IATAs", []string{"YYJ", "YVR", "YYJ"}, [3]int64{2, 3, 2}, [3]int64{2, 2, 2}},
|
||||
{"unknown IATA", []string{"ZZZ"}, [3]int64{}, [3]int64{}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rows, err := store.GetScopeStats(ctx, tc.iatas)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []api.ScopeStats{
|
||||
{Name: "#a", PacketCount: tc.a[0], ObserverCount: tc.a[1], NodeCount: tc.a[2]},
|
||||
{Name: "#b", PacketCount: tc.b[0], ObserverCount: tc.b[1], NodeCount: tc.b[2]},
|
||||
{Name: "#unused"},
|
||||
}
|
||||
if !reflect.DeepEqual(rows, want) {
|
||||
t.Fatalf("counts = %+v, want %+v", rows, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := tx.Exec(ctx, "DELETE FROM transport_scopes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := store.GetScopeStats(ctx, nil)
|
||||
if err != nil || rows == nil || len(rows) != 0 {
|
||||
t.Fatalf("empty roster = %+v, %v", rows, err)
|
||||
}
|
||||
}
|
||||
@@ -653,18 +653,18 @@ func (mr *MockQuerierMockRecorder) GetScopeNames(ctx any) *gomock.Call {
|
||||
}
|
||||
|
||||
// GetScopeStats mocks base method.
|
||||
func (m *MockQuerier) GetScopeStats(ctx context.Context) ([]db.GetScopeStatsRow, error) {
|
||||
func (m *MockQuerier) GetScopeStats(ctx context.Context, iatas []string) ([]db.GetScopeStatsRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetScopeStats", ctx)
|
||||
ret := m.ctrl.Call(m, "GetScopeStats", ctx, iatas)
|
||||
ret0, _ := ret[0].([]db.GetScopeStatsRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetScopeStats indicates an expected call of GetScopeStats.
|
||||
func (mr *MockQuerierMockRecorder) GetScopeStats(ctx any) *gomock.Call {
|
||||
func (mr *MockQuerierMockRecorder) GetScopeStats(ctx, iatas any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetScopeStats", reflect.TypeOf((*MockQuerier)(nil).GetScopeStats), ctx)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetScopeStats", reflect.TypeOf((*MockQuerier)(nil).GetScopeStats), ctx, iatas)
|
||||
}
|
||||
|
||||
// GetScopesByIATAs mocks base method.
|
||||
|
||||
+4
-3
@@ -82,9 +82,10 @@ type Querier interface {
|
||||
GetRegionIATAs(ctx context.Context, regionID int32) ([]string, error)
|
||||
GetScopeByName(ctx context.Context, name string) (GetScopeByNameRow, error)
|
||||
GetScopeNames(ctx context.Context) ([]string, error)
|
||||
// Count each table on its own; the old cross-join blew up to millions of rows
|
||||
// before COUNT(DISTINCT) (~10s).
|
||||
GetScopeStats(ctx context.Context) ([]GetScopeStatsRow, error)
|
||||
// Aggregate matching observations once, separately from node memberships to avoid
|
||||
// a cross-join. Empty IATAs keep the original global counts, including associations
|
||||
// whose observations have expired; the filtered aggregates are empty in that case.
|
||||
GetScopeStats(ctx context.Context, iatas []string) ([]GetScopeStatsRow, error)
|
||||
GetScopesByIATAs(ctx context.Context, dollar_1 []string) ([]GetScopesByIATAsRow, error)
|
||||
// Repeaters/room servers (node_type 2/3) whose current advert-derived clock drift exceeds
|
||||
// the given threshold in magnitude, worst first. Not time-windowed -- reflects each node's
|
||||
|
||||
+31
-7
@@ -1418,12 +1418,35 @@ func (q *Queries) GetScopeNames(ctx context.Context) ([]string, error) {
|
||||
}
|
||||
|
||||
const getScopeStats = `-- name: GetScopeStats :many
|
||||
WITH observation_counts AS (
|
||||
SELECT p.scope_id,
|
||||
COUNT(DISTINCT p.packet_hash) AS packet_count,
|
||||
COUNT(DISTINCT os.observer_id) AS observer_count
|
||||
FROM packet_observations po
|
||||
JOIN packets p ON p.packet_hash = po.packet_hash
|
||||
LEFT JOIN observer_scopes os ON os.scope_id = p.scope_id AND os.observer_id = po.observer_id
|
||||
WHERE po.iata = ANY($1::bpchar[]) AND p.scope_id IS NOT NULL
|
||||
GROUP BY p.scope_id
|
||||
), node_counts AS (
|
||||
SELECT n.default_scope_id AS scope_id, COUNT(*) AS node_count
|
||||
FROM nodes n
|
||||
WHERE n.id IN (SELECT node_id FROM node_iatas WHERE iata = ANY($1::bpchar[]))
|
||||
GROUP BY n.default_scope_id
|
||||
)
|
||||
SELECT
|
||||
ts.name,
|
||||
(SELECT COUNT(*) FROM packets p WHERE p.scope_id = ts.id) AS packet_count,
|
||||
(SELECT COUNT(*) FROM observer_scopes os WHERE os.scope_id = ts.id) AS observer_count,
|
||||
(SELECT COUNT(*) FROM nodes n WHERE n.default_scope_id = ts.id) AS node_count
|
||||
CASE WHEN COALESCE(cardinality($1::bpchar[]), 0) = 0
|
||||
THEN (SELECT COUNT(*) FROM packets p WHERE p.scope_id = ts.id)
|
||||
ELSE COALESCE(oc.packet_count, 0) END::bigint AS packet_count,
|
||||
CASE WHEN COALESCE(cardinality($1::bpchar[]), 0) = 0
|
||||
THEN (SELECT COUNT(*) FROM observer_scopes os WHERE os.scope_id = ts.id)
|
||||
ELSE COALESCE(oc.observer_count, 0) END::bigint AS observer_count,
|
||||
CASE WHEN COALESCE(cardinality($1::bpchar[]), 0) = 0
|
||||
THEN (SELECT COUNT(*) FROM nodes n WHERE n.default_scope_id = ts.id)
|
||||
ELSE COALESCE(nc.node_count, 0) END::bigint AS node_count
|
||||
FROM transport_scopes ts
|
||||
LEFT JOIN observation_counts oc ON oc.scope_id = ts.id
|
||||
LEFT JOIN node_counts nc ON nc.scope_id = ts.id
|
||||
ORDER BY ts.name
|
||||
`
|
||||
|
||||
@@ -1434,10 +1457,11 @@ type GetScopeStatsRow struct {
|
||||
NodeCount int64 `json:"node_count"`
|
||||
}
|
||||
|
||||
// Count each table on its own; the old cross-join blew up to millions of rows
|
||||
// before COUNT(DISTINCT) (~10s).
|
||||
func (q *Queries) GetScopeStats(ctx context.Context) ([]GetScopeStatsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getScopeStats)
|
||||
// Aggregate matching observations once, separately from node memberships to avoid
|
||||
// a cross-join. Empty IATAs keep the original global counts, including associations
|
||||
// whose observations have expired; the filtered aggregates are empty in that case.
|
||||
func (q *Queries) GetScopeStats(ctx context.Context, iatas []string) ([]GetScopeStatsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getScopeStats, iatas)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+2
-2
@@ -243,8 +243,8 @@ func (s *Store) GetRadioPresets(ctx context.Context, preset string, iatas []stri
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetScopeStats(ctx context.Context) ([]api.ScopeStats, error) {
|
||||
rows, err := s.q.GetScopeStats(ctx)
|
||||
func (s *Store) GetScopeStats(ctx context.Context, iatas []string) ([]api.ScopeStats, error) {
|
||||
rows, err := s.q.GetScopeStats(ctx, iatas)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+2
-2
@@ -227,13 +227,13 @@ func TestGetScopeStats(t *testing.T) {
|
||||
mock := mockdb.NewMockQuerier(ctrl)
|
||||
|
||||
mock.EXPECT().
|
||||
GetScopeStats(gomock.Any()).
|
||||
GetScopeStats(gomock.Any(), []string{"YVR"}).
|
||||
Return([]sqlc.GetScopeStatsRow{
|
||||
{Name: "default", PacketCount: 100, ObserverCount: 5, NodeCount: 20},
|
||||
}, nil)
|
||||
|
||||
store := &Store{q: mock}
|
||||
items, err := store.GetScopeStats(context.Background())
|
||||
items, err := store.GetScopeStats(context.Background(), []string{"YVR"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
@@ -1988,6 +1988,7 @@ const docTemplate = `{
|
||||
},
|
||||
"/stats/scopes": {
|
||||
"get": {
|
||||
"description": "Counts each packet, observer and node once per scope. IATA filters use retained observations for packets/observers and node IATA memberships for nodes. Without filters, returns global totals. Scopes with zero matching counts remain listed; an empty region returns an empty array.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1995,6 +1996,32 @@ const docTemplate = `{
|
||||
"Stats"
|
||||
],
|
||||
"summary": "Scope statistics",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Comma-separated IATA codes",
|
||||
"name": "iatas",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Single IATA code; used when iatas is absent",
|
||||
"name": "iata",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Filter by region ID, expands to member IATAs",
|
||||
"name": "regionId",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by region slug, expands to member IATAs; combined with explicit IATAs",
|
||||
"name": "region",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -2005,6 +2032,12 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
|
||||
@@ -1986,6 +1986,7 @@
|
||||
},
|
||||
"/stats/scopes": {
|
||||
"get": {
|
||||
"description": "Counts each packet, observer and node once per scope. IATA filters use retained observations for packets/observers and node IATA memberships for nodes. Without filters, returns global totals. Scopes with zero matching counts remain listed; an empty region returns an empty array.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1993,6 +1994,32 @@
|
||||
"Stats"
|
||||
],
|
||||
"summary": "Scope statistics",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Comma-separated IATA codes",
|
||||
"name": "iatas",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Single IATA code; used when iatas is absent",
|
||||
"name": "iata",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Filter by region ID, expands to member IATAs",
|
||||
"name": "regionId",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by region slug, expands to member IATAs; combined with explicit IATAs",
|
||||
"name": "region",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -2003,6 +2030,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
|
||||
@@ -2509,6 +2509,28 @@ paths:
|
||||
- Stats
|
||||
/stats/scopes:
|
||||
get:
|
||||
description: Counts each packet, observer and node once per scope. IATA filters
|
||||
use retained observations for packets/observers and node IATA memberships
|
||||
for nodes. Without filters, returns global totals. Scopes with zero matching
|
||||
counts remain listed; an empty region returns an empty array.
|
||||
parameters:
|
||||
- description: Comma-separated IATA codes
|
||||
in: query
|
||||
name: iatas
|
||||
type: string
|
||||
- description: Single IATA code; used when iatas is absent
|
||||
in: query
|
||||
name: iata
|
||||
type: string
|
||||
- description: Filter by region ID, expands to member IATAs
|
||||
in: query
|
||||
name: regionId
|
||||
type: integer
|
||||
- description: Filter by region slug, expands to member IATAs; combined with
|
||||
explicit IATAs
|
||||
in: query
|
||||
name: region
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -2518,6 +2540,10 @@ paths:
|
||||
items:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeStats'
|
||||
type: array
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||||
)
|
||||
|
||||
func TestGetStatsScopes_RegionErrors(t *testing.T) {
|
||||
for _, query := range []string{"regionId=invalid", "regionId=2147483648", "regionId=999", "region=missing"} {
|
||||
t.Run(query, func(t *testing.T) {
|
||||
reader := stubReader{
|
||||
getRegion: func(context.Context, int32) (*api.Region, error) {
|
||||
return nil, errors.New("not found")
|
||||
},
|
||||
getRegionBySlug: func(context.Context, string) (*api.Region, error) {
|
||||
return nil, errors.New("not found")
|
||||
},
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
getStatsScopes(reader)(w, httptest.NewRequest(http.MethodGet, "/stats/scopes?"+query, nil))
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("got status %d, want 400", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStatsScopes_Filters(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
query string
|
||||
want []string
|
||||
empty bool
|
||||
}{
|
||||
{"", nil, false},
|
||||
{"iata=yvr", []string{"YVR"}, false},
|
||||
{"iatas=yyj,%20yvr,yyj", []string{"YYJ", "YVR", "YYJ"}, false},
|
||||
{"iatas=YVR&iata=YYZ", []string{"YVR"}, false},
|
||||
{"regionId=7", []string{"YVR", "YYJ"}, false},
|
||||
{"region=west", []string{"YVR", "YYJ"}, false},
|
||||
{"regionId=7®ion=missing", []string{"YVR", "YYJ"}, false},
|
||||
{"iatas=YYZ,YVR®ion=west", []string{"YYZ", "YVR", "YVR", "YYJ"}, false},
|
||||
{"region=empty", nil, true},
|
||||
{"region=empty&iata=YVR", []string{"YVR"}, false},
|
||||
} {
|
||||
t.Run(tc.query, func(t *testing.T) {
|
||||
called := false
|
||||
reader := stubReader{
|
||||
getRegion: func(_ context.Context, id int32) (*api.Region, error) {
|
||||
if id != 7 {
|
||||
t.Fatalf("unexpected region ID %d", id)
|
||||
}
|
||||
return &api.Region{IATAs: []string{"YVR", "YYJ"}}, nil
|
||||
},
|
||||
getRegionBySlug: func(_ context.Context, slug string) (*api.Region, error) {
|
||||
if slug == "empty" {
|
||||
return &api.Region{}, nil
|
||||
}
|
||||
if slug != "west" {
|
||||
t.Fatalf("unexpected region slug %s", slug)
|
||||
}
|
||||
return &api.Region{IATAs: []string{"YVR", "YYJ"}}, nil
|
||||
},
|
||||
getScopeStats: func(_ context.Context, iatas []string) ([]api.ScopeStats, error) {
|
||||
called = true
|
||||
if !slices.Equal(iatas, tc.want) {
|
||||
t.Fatalf("IATAs = %v, want %v", iatas, tc.want)
|
||||
}
|
||||
return []api.ScopeStats{{Name: "#test", PacketCount: 2, ObserverCount: 1, NodeCount: 3}}, nil
|
||||
},
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
getStatsScopes(reader)(w, httptest.NewRequest(http.MethodGet, "/stats/scopes?"+tc.query, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if called == tc.empty {
|
||||
t.Fatalf("reader called = %v, empty region = %v", called, tc.empty)
|
||||
}
|
||||
var rows []api.ScopeStats
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.empty {
|
||||
if rows == nil || len(rows) != 0 {
|
||||
t.Fatalf("empty region returned %s", w.Body.String())
|
||||
}
|
||||
} else if len(rows) != 1 || rows[0].PacketCount != 2 || rows[0].ObserverCount != 1 || rows[0].NodeCount != 3 {
|
||||
t.Fatalf("response changed: %+v", rows)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStatsScopes_ReaderError(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
getStatsScopes(stubReader{getScopeStats: func(context.Context, []string) ([]api.ScopeStats, error) {
|
||||
return nil, errors.New("database unavailable")
|
||||
}})(w, httptest.NewRequest(http.MethodGet, "/stats/scopes?iata=YVR", nil))
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -414,14 +414,33 @@ func getStatsRadioPresets(reader api.Reader) http.HandlerFunc {
|
||||
// getStatsScopes godoc
|
||||
//
|
||||
// @Summary Scope statistics
|
||||
// @Description Counts each packet, observer and node once per scope. IATA filters use retained observations for packets/observers and node IATA memberships for nodes. Without filters, returns global totals. Scopes with zero matching counts remain listed; an empty region returns an empty array.
|
||||
// @Tags Stats
|
||||
// @Produce json
|
||||
// @Param iatas query string false "Comma-separated IATA codes"
|
||||
// @Param iata query string false "Single IATA code; used when iatas is absent"
|
||||
// @Param regionId query int false "Filter by region ID, expands to member IATAs"
|
||||
// @Param region query string false "Filter by region slug, expands to member IATAs; combined with explicit IATAs"
|
||||
// @Success 200 {object} []api.ScopeStats
|
||||
// @Failure 400 {object} handlers.APIError
|
||||
// @Failure 500 {object} handlers.APIError
|
||||
// @Router /stats/scopes [get]
|
||||
func getStatsScopes(reader api.Reader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := reader.GetScopeStats(r.Context())
|
||||
iatas := parseIATAs(r)
|
||||
if regionID := r.URL.Query().Get("regionId"); regionID != "" || r.URL.Query().Get("region") != "" {
|
||||
regionIATAs, err := resolveRegionIATAs(r.Context(), regionID, r.URL.Query().Get("region"), reader)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
iatas = append(iatas, regionIATAs...)
|
||||
if len(iatas) == 0 {
|
||||
respond(w, http.StatusOK, []api.ScopeStats{})
|
||||
return
|
||||
}
|
||||
}
|
||||
stats, err := reader.GetScopeStats(r.Context(), iatas)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
|
||||
@@ -282,7 +282,7 @@ func TestGetStatsRadioPresets_OK(t *testing.T) {
|
||||
func TestGetStatsScopes_OK(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/stats/scopes", getStatsScopes(stubReader{
|
||||
getScopeStats: func(_ context.Context) ([]api.ScopeStats, error) {
|
||||
getScopeStats: func(_ context.Context, _ []string) ([]api.ScopeStats, error) {
|
||||
return []api.ScopeStats{{Name: "#bc", PacketCount: 100}}, nil
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -50,7 +50,7 @@ type stubReader struct {
|
||||
getStatsTopAdvertisers func(ctx context.Context, iatas []string, since time.Time, limit int32) ([]api.TopAdvertiser, error)
|
||||
getStatsClockDrift func(ctx context.Context, iatas []string, limit int32) ([]api.ClockDriftEntry, error)
|
||||
getStatsTopTalkers func(ctx context.Context, iatas []string, since time.Time, limit int32) ([]api.TopTalker, error)
|
||||
getScopeStats func(ctx context.Context) ([]api.ScopeStats, error)
|
||||
getScopeStats func(ctx context.Context, iatas []string) ([]api.ScopeStats, error)
|
||||
getStatsNodeTypes func(ctx context.Context, iatas []string) ([]api.NodeTypeCount, error)
|
||||
getScopeNames func(ctx context.Context) ([]string, error)
|
||||
getScopesByIATAs func(ctx context.Context, iatas []string) ([]api.ScopeSummary, error)
|
||||
@@ -303,9 +303,9 @@ func (s stubReader) GetStatsTopTalkers(ctx context.Context, iatas []string, sinc
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s stubReader) GetScopeStats(ctx context.Context) ([]api.ScopeStats, error) {
|
||||
func (s stubReader) GetScopeStats(ctx context.Context, iatas []string) ([]api.ScopeStats, error) {
|
||||
if s.getScopeStats != nil {
|
||||
return s.getScopeStats(ctx)
|
||||
return s.getScopeStats(ctx, iatas)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -188,7 +188,8 @@ type Reader interface {
|
||||
GetStatsTopTalkers(ctx context.Context, iatas []string, since time.Time, limit int32) ([]TopTalker, error)
|
||||
|
||||
// GetScopeStats returns aggregate packet, observer and node counts per transport scope.
|
||||
GetScopeStats(ctx context.Context) ([]ScopeStats, error)
|
||||
// Pass empty iatas for global totals. IATAs must be uppercase.
|
||||
GetScopeStats(ctx context.Context, iatas []string) ([]ScopeStats, error)
|
||||
|
||||
// GetStatsNodeTypes returns node counts grouped by type, optionally filtered by IATA.
|
||||
GetStatsNodeTypes(ctx context.Context, iatas []string) ([]NodeTypeCount, error)
|
||||
|
||||
Vendored
+4
-2
@@ -47,8 +47,10 @@ func (s *stubReader) GetRegion(_ context.Context, _ int32) (*api.Region, error)
|
||||
func (s *stubReader) GetRegionBySlug(_ context.Context, _ string) (*api.Region, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubReader) GetScopeNames(_ context.Context) ([]string, error) { return nil, nil }
|
||||
func (s *stubReader) GetScopeStats(_ context.Context) ([]api.ScopeStats, error) { return nil, nil }
|
||||
func (s *stubReader) GetScopeNames(_ context.Context) ([]string, error) { return nil, nil }
|
||||
func (s *stubReader) GetScopeStats(_ context.Context, _ []string) ([]api.ScopeStats, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubReader) GetScopesByIATAs(_ context.Context, _ []string) ([]api.ScopeSummary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
Vendored
+10
-3
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -141,9 +142,15 @@ func (cr *CachedReader) GetScopeNames(ctx context.Context) ([]string, error) {
|
||||
}
|
||||
|
||||
// GetScopeStats implements [api.Reader].
|
||||
func (cr *CachedReader) GetScopeStats(ctx context.Context) ([]api.ScopeStats, error) {
|
||||
return getOrSet(ctx, cr.c, keyScopeStats, cr.ttl.Reference, func() ([]api.ScopeStats, error) {
|
||||
return cr.inner.GetScopeStats(ctx)
|
||||
func (cr *CachedReader) GetScopeStats(ctx context.Context, iatas []string) ([]api.ScopeStats, error) {
|
||||
segment := "all"
|
||||
if len(iatas) > 0 {
|
||||
sorted := append([]string(nil), iatas...)
|
||||
sort.Strings(sorted)
|
||||
segment = strings.Join(slices.Compact(sorted), ",")
|
||||
}
|
||||
return getOrSet(ctx, cr.c, keyScopeStats+":"+segment, cr.ttl.Reference, func() ([]api.ScopeStats, error) {
|
||||
return cr.inner.GetScopeStats(ctx, iatas)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Vendored
+59
@@ -0,0 +1,59 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||||
)
|
||||
|
||||
type scopeStatsReader struct {
|
||||
api.Reader
|
||||
calls int64
|
||||
}
|
||||
|
||||
func (r *scopeStatsReader) GetScopeStats(context.Context, []string) ([]api.ScopeStats, error) {
|
||||
r.calls++
|
||||
return []api.ScopeStats{{Name: "#test", PacketCount: r.calls}}, nil
|
||||
}
|
||||
|
||||
func TestScopeStatsCacheSeparatesIATAs(t *testing.T) {
|
||||
c, _ := newTestClient(t)
|
||||
inner := &scopeStatsReader{}
|
||||
reader := NewCachedReader(inner, c, CacheTTLs{Reference: time.Minute})
|
||||
for _, tc := range []struct {
|
||||
iatas []string
|
||||
want int64
|
||||
}{
|
||||
{nil, 1},
|
||||
{[]string{"YVR"}, 2},
|
||||
{[]string{"YYJ"}, 3},
|
||||
{[]string{"YYJ", "YVR"}, 4},
|
||||
{[]string{"YVR", "YYJ", "YVR"}, 4},
|
||||
{[]string{"YVR"}, 2},
|
||||
{[]string{}, 1},
|
||||
{[]string{"ZZZ"}, 5},
|
||||
{[]string{""}, 6},
|
||||
{nil, 1},
|
||||
} {
|
||||
before := slices.Clone(tc.iatas)
|
||||
rows, err := reader.GetScopeStats(context.Background(), tc.iatas)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].PacketCount != tc.want {
|
||||
t.Fatalf("IATAs %v: got %+v, want cached count %d", tc.iatas, rows, tc.want)
|
||||
}
|
||||
if !slices.Equal(before, tc.iatas) {
|
||||
t.Fatalf("caller IATAs mutated: %v -> %v", before, tc.iatas)
|
||||
}
|
||||
}
|
||||
if inner.calls != 6 {
|
||||
t.Fatalf("underlying calls = %d, want 6", inner.calls)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user