diff --git a/db/queries/queries.sql b/db/queries/queries.sql index 2e72472..cd66f76 100644 --- a/db/queries/queries.sql +++ b/db/queries/queries.sql @@ -1101,14 +1101,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; -- ============================================================ diff --git a/db/scope_stats_integration_test.go b/db/scope_stats_integration_test.go new file mode 100644 index 0000000..94e1845 --- /dev/null +++ b/db/scope_stats_integration_test.go @@ -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) + } +} diff --git a/db/sqlc/mock/querier.go b/db/sqlc/mock/querier.go index e43434b..a325152 100644 --- a/db/sqlc/mock/querier.go +++ b/db/sqlc/mock/querier.go @@ -608,18 +608,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. diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index 7a64432..9cabece 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -77,9 +77,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 diff --git a/db/sqlc/queries.sql.go b/db/sqlc/queries.sql.go index 998889f..b741ccb 100644 --- a/db/sqlc/queries.sql.go +++ b/db/sqlc/queries.sql.go @@ -1358,12 +1358,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 ` @@ -1374,10 +1397,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 } diff --git a/db/stats.go b/db/stats.go index b8dc915..abceda1 100644 --- a/db/stats.go +++ b/db/stats.go @@ -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 } diff --git a/db/stats_test.go b/db/stats_test.go index 7051870..e584311 100644 --- a/db/stats_test.go +++ b/db/stats_test.go @@ -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) } diff --git a/docs/docs.go b/docs/docs.go index b7cf2ac..6505b41 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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": { diff --git a/docs/swagger.json b/docs/swagger.json index 5182f72..4d47030 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 64962a7..c3fb607 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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: diff --git a/internal/api/handlers/scope_stats_test.go b/internal/api/handlers/scope_stats_test.go new file mode 100644 index 0000000..d63ba21 --- /dev/null +++ b/internal/api/handlers/scope_stats_test.go @@ -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) + } +} diff --git a/internal/api/handlers/stats.go b/internal/api/handlers/stats.go index b59a303..abd6c4d 100644 --- a/internal/api/handlers/stats.go +++ b/internal/api/handlers/stats.go @@ -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 diff --git a/internal/api/handlers/stats_test.go b/internal/api/handlers/stats_test.go index fe08c8b..8225fc8 100644 --- a/internal/api/handlers/stats_test.go +++ b/internal/api/handlers/stats_test.go @@ -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 }, })) diff --git a/internal/api/handlers/stub_reader_test.go b/internal/api/handlers/stub_reader_test.go index 4cd3d87..d87076e 100644 --- a/internal/api/handlers/stub_reader_test.go +++ b/internal/api/handlers/stub_reader_test.go @@ -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 } diff --git a/internal/api/reader.go b/internal/api/reader.go index dc3e1dc..75febdc 100644 --- a/internal/api/reader.go +++ b/internal/api/reader.go @@ -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) diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 68c968b..cada469 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -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 } diff --git a/internal/cache/reader.go b/internal/cache/reader.go index 4c69bd2..ba5ce8e 100644 --- a/internal/cache/reader.go +++ b/internal/cache/reader.go @@ -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) }) } diff --git a/internal/cache/scope_stats_test.go b/internal/cache/scope_stats_test.go new file mode 100644 index 0000000..188a5b1 --- /dev/null +++ b/internal/cache/scope_stats_test.go @@ -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) + } +}