diff --git a/db/queries/queries.sql b/db/queries/queries.sql index 2116ed2..1d3a1b1 100644 --- a/db/queries/queries.sql +++ b/db/queries/queries.sql @@ -651,6 +651,19 @@ WHERE ($1::text = '' OR preset = $1::text) AND ($2::text = '' OR iata = $2::text) ORDER BY preset, iata, source_type; +-- name: GetScopeStats :many +SELECT + ts.name, + COUNT(DISTINCT p.packet_hash) AS packet_count, + COUNT(DISTINCT os.observer_id) AS observer_count, + COUNT(DISTINCT n.id) AS node_count +FROM transport_scopes ts +LEFT JOIN packets p ON p.scope_id = ts.id +LEFT JOIN observer_scopes os ON os.scope_id = ts.id +LEFT JOIN nodes n ON n.default_scope_id = ts.id +GROUP BY ts.name +ORDER BY ts.name; + -- ============================================================ -- REGIONS -- ============================================================ diff --git a/db/sqlc/queries.sql.go b/db/sqlc/queries.sql.go index a9e8a93..ff36144 100644 --- a/db/sqlc/queries.sql.go +++ b/db/sqlc/queries.sql.go @@ -780,6 +780,52 @@ func (q *Queries) GetRegionIATAs(ctx context.Context, regionID int32) ([]string, return items, nil } +const getScopeStats = `-- name: GetScopeStats :many +SELECT + ts.name, + COUNT(DISTINCT p.packet_hash) AS packet_count, + COUNT(DISTINCT os.observer_id) AS observer_count, + COUNT(DISTINCT n.id) AS node_count +FROM transport_scopes ts +LEFT JOIN packets p ON p.scope_id = ts.id +LEFT JOIN observer_scopes os ON os.scope_id = ts.id +LEFT JOIN nodes n ON n.default_scope_id = ts.id +GROUP BY ts.name +ORDER BY ts.name +` + +type GetScopeStatsRow struct { + Name string `json:"name"` + PacketCount int64 `json:"packet_count"` + ObserverCount int64 `json:"observer_count"` + NodeCount int64 `json:"node_count"` +} + +func (q *Queries) GetScopeStats(ctx context.Context) ([]GetScopeStatsRow, error) { + rows, err := q.db.Query(ctx, getScopeStats) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetScopeStatsRow{} + for rows.Next() { + var i GetScopeStatsRow + if err := rows.Scan( + &i.Name, + &i.PacketCount, + &i.ObserverCount, + &i.NodeCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getStatsOverview = `-- name: GetStatsOverview :one SELECT diff --git a/db/store.go b/db/store.go index 7326536..a9f9702 100644 --- a/db/store.go +++ b/db/store.go @@ -1433,6 +1433,24 @@ func (s *Store) GetRadioPresets(ctx context.Context, preset, iata string) ([]api return items, nil } +// GetScopeStats returns aggregate packet, observer and node counts per transport scope. +func (s *Store) GetScopeStats(ctx context.Context) ([]api.ScopeStats, error) { + rows, err := s.q.GetScopeStats(ctx) + if err != nil { + return nil, err + } + items := make([]api.ScopeStats, 0, len(rows)) + for _, r := range rows { + items = append(items, api.ScopeStats{ + Name: r.Name, + PacketCount: r.PacketCount, + ObserverCount: r.ObserverCount, + NodeCount: r.NodeCount, + }) + } + return items, nil +} + func nullableUUID(id uuid.UUID) *uuid.UUID { if id == (uuid.UUID{}) { return nil diff --git a/docs/docs.go b/docs/docs.go index d9319c4..31b0d8f 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1202,6 +1202,34 @@ const docTemplate = `{ } } }, + "/stats/scopes": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Stats" + ], + "summary": "Scope statistics", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_MeshCore-Tower_tower-server_internal_api.ScopeStats" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + }, "/stats/top-nodes": { "get": { "produces": [ @@ -2180,6 +2208,23 @@ const docTemplate = `{ } } }, + "github_com_MeshCore-Tower_tower-server_internal_api.ScopeStats": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "nodeCount": { + "type": "integer" + }, + "observerCount": { + "type": "integer" + }, + "packetCount": { + "type": "integer" + } + } + }, "github_com_MeshCore-Tower_tower-server_internal_api.StatsOverview": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index f1e9774..27a8dfa 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1200,6 +1200,34 @@ } } }, + "/stats/scopes": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Stats" + ], + "summary": "Scope statistics", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_MeshCore-Tower_tower-server_internal_api.ScopeStats" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + }, "/stats/top-nodes": { "get": { "produces": [ @@ -2178,6 +2206,23 @@ } } }, + "github_com_MeshCore-Tower_tower-server_internal_api.ScopeStats": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "nodeCount": { + "type": "integer" + }, + "observerCount": { + "type": "integer" + }, + "packetCount": { + "type": "integer" + } + } + }, "github_com_MeshCore-Tower_tower-server_internal_api.StatsOverview": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index a3a276f..d693f55 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -603,6 +603,17 @@ definitions: description: hex-encoded prefix type: string type: object + github_com_MeshCore-Tower_tower-server_internal_api.ScopeStats: + properties: + name: + type: string + nodeCount: + type: integer + observerCount: + type: integer + packetCount: + type: integer + type: object github_com_MeshCore-Tower_tower-server_internal_api.StatsOverview: properties: activeIatas: @@ -1448,6 +1459,24 @@ paths: summary: Radio preset usage by IATA tags: - Stats + /stats/scopes: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/github_com_MeshCore-Tower_tower-server_internal_api.ScopeStats' + type: array + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/internal_api_handlers.APIError' + summary: Scope statistics + tags: + - Stats /stats/top-nodes: get: parameters: diff --git a/internal/api/handlers/stats.go b/internal/api/handlers/stats.go index b8bcaca..2e8193c 100644 --- a/internal/api/handlers/stats.go +++ b/internal/api/handlers/stats.go @@ -18,6 +18,7 @@ import ( // GET /stats/top-nodes → getStatsTopNodes // GET /stats/top-observers → getStatsTopObservers // GET /stats/radio-presets → getStatsRadioPresets +// GET /stats/scopes → GetStatsScopes // // All endpoints accept an optional iata= filter (case-insensitive). func StatsRouter(reader api.Reader) http.Handler { @@ -28,6 +29,7 @@ func StatsRouter(reader api.Reader) http.Handler { r.Get("/top-nodes", getStatsTopNodes(reader)) r.Get("/top-observers", getStatsTopObservers(reader)) r.Get("/radio-presets", getStatsRadioPresets(reader)) + r.Get("/scopes", getStatsScopes(reader)) return r } @@ -213,3 +215,22 @@ func getStatsRadioPresets(reader api.Reader) http.HandlerFunc { respond(w, http.StatusOK, presets) } } + +// getStatsScopes godoc +// +// @Summary Scope statistics +// @Tags Stats +// @Produce json +// @Success 200 {object} []api.ScopeStats +// @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()) + if err != nil { + respondError(w, http.StatusInternalServerError, "internal server error") + return + } + respond(w, http.StatusOK, stats) + } +} diff --git a/internal/api/reader.go b/internal/api/reader.go index 924dd4f..e9798d7 100644 --- a/internal/api/reader.go +++ b/internal/api/reader.go @@ -42,6 +42,14 @@ type PayloadBreakdownItem struct { Count int64 `json:"count"` } +// ScopeStats represents aggregate statistics for a single transport scope. +type ScopeStats struct { + Name string `json:"name"` + PacketCount int64 `json:"packetCount"` + ObserverCount int64 `json:"observerCount"` + NodeCount int64 `json:"nodeCount"` +} + // TopNode is a node ranked by observation count. type TopNode struct { NodeID uuid.UUID `json:"nodeId"` @@ -459,4 +467,6 @@ type Reader interface { // Pass empty string iata to return stats across all IATAs. // since defines the start of the window; pass zero time for default (last 24h). GetStatsTopObservers(ctx context.Context, iata string, since time.Time, limit int32) ([]TopObserver, error) + // GetScopeStats returns aggregate packet, observer and node counts per transport scope. + GetScopeStats(ctx context.Context) ([]ScopeStats, error) }