diff --git a/db/queries/queries.sql b/db/queries/queries.sql index dca462a..6c57cd0 100644 --- a/db/queries/queries.sql +++ b/db/queries/queries.sql @@ -38,6 +38,41 @@ SELECT name, transport_key, key_fingerprint FROM transport_scopes ORDER BY name; -- name: GetTransportScopeByName :one SELECT id FROM transport_scopes WHERE name = $1; +-- name: GetScopeNames :many +SELECT name FROM transport_scopes ORDER BY name; + +-- name: GetScopesByIATAs :many +SELECT + ts.name, + COUNT(DISTINCT os.observer_id) AS observer_count, + COUNT(DISTINCT n.id) AS node_count, + COUNT(DISTINCT po.iata) AS iata_count +FROM transport_scopes ts +LEFT JOIN observer_scopes os ON os.scope_id = ts.id +LEFT JOIN observers o ON o.id = os.observer_id +LEFT JOIN packet_observations po ON po.observer_id = o.id +LEFT JOIN nodes n ON n.default_scope_id = ts.id +WHERE ($1::text = '' OR po.iata = ANY(string_to_array($1::text, ','))) +GROUP BY ts.name +ORDER BY ts.name; + +-- name: GetScopeByName :one +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, + COUNT(DISTINCT po.iata) AS iata_count, + array_remove(array_agg(DISTINCT po.iata ORDER BY po.iata), NULL)::text[] AS iatas +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 observers o ON o.id = os.observer_id +LEFT JOIN packet_observations po ON po.observer_id = o.id +LEFT JOIN nodes n ON n.default_scope_id = ts.id +WHERE ts.name = $1 +GROUP BY ts.name; + -- ============================================================ -- OBSERVERS -- ============================================================ diff --git a/db/scopes.go b/db/scopes.go index bfbe176..4dbc18d 100644 --- a/db/scopes.go +++ b/db/scopes.go @@ -2,8 +2,10 @@ package db import ( "context" + "strings" sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc" + "github.com/MeshCore-Beacon/beacon-server/internal/api" "github.com/MeshCore-Beacon/beacon-server/internal/scopestore" ) @@ -39,3 +41,43 @@ func (s *Store) GetTransportScopes(ctx context.Context) ([]scopestore.Entry, err func (s *Store) GetTransportScopeByName(ctx context.Context, name string) (int32, error) { return s.q.GetTransportScopeByName(ctx, name) } + +// GetScopeNames returns the names of all configured transport scopes. +func (s *Store) GetScopeNames(ctx context.Context) ([]string, error) { + return s.q.GetScopeNames(ctx) +} + +// GetScopesByIATAs returns scope summaries filtered by the given IATA codes. +func (s *Store) GetScopesByIATAs(ctx context.Context, iatas []string) ([]api.ScopeSummary, error) { + iataFilter := strings.Join(iatas, ",") + rows, err := s.q.GetScopesByIATAs(ctx, iataFilter) + if err != nil { + return nil, err + } + items := make([]api.ScopeSummary, 0, len(rows)) + for _, r := range rows { + items = append(items, api.ScopeSummary{ + Name: r.Name, + ObserverCount: r.ObserverCount, + NodeCount: r.NodeCount, + IATACount: r.IataCount, + }) + } + return items, nil +} + +// GetScopeByName returns full detail for a single scope by its normalized name. +func (s *Store) GetScopeByName(ctx context.Context, name string) (*api.ScopeDetail, error) { + row, err := s.q.GetScopeByName(ctx, name) + if err != nil { + return nil, err + } + return &api.ScopeDetail{ + Name: row.Name, + PacketCount: row.PacketCount, + ObserverCount: row.ObserverCount, + NodeCount: row.NodeCount, + IATACount: row.IataCount, + IATAs: row.Iatas, + }, nil +} diff --git a/db/sqlc/queries.sql.go b/db/sqlc/queries.sql.go index b7836d1..8edf342 100644 --- a/db/sqlc/queries.sql.go +++ b/db/sqlc/queries.sql.go @@ -790,6 +790,71 @@ func (q *Queries) GetRegionIATAs(ctx context.Context, regionID int32) ([]string, return items, nil } +const getScopeByName = `-- name: GetScopeByName :one +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, + COUNT(DISTINCT po.iata) AS iata_count, + array_remove(array_agg(DISTINCT po.iata ORDER BY po.iata), NULL)::text[] AS iatas +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 observers o ON o.id = os.observer_id +LEFT JOIN packet_observations po ON po.observer_id = o.id +LEFT JOIN nodes n ON n.default_scope_id = ts.id +WHERE ts.name = $1 +GROUP BY ts.name +` + +type GetScopeByNameRow struct { + Name string `json:"name"` + PacketCount int64 `json:"packet_count"` + ObserverCount int64 `json:"observer_count"` + NodeCount int64 `json:"node_count"` + IataCount int64 `json:"iata_count"` + Iatas []string `json:"iatas"` +} + +func (q *Queries) GetScopeByName(ctx context.Context, name string) (GetScopeByNameRow, error) { + row := q.db.QueryRow(ctx, getScopeByName, name) + var i GetScopeByNameRow + err := row.Scan( + &i.Name, + &i.PacketCount, + &i.ObserverCount, + &i.NodeCount, + &i.IataCount, + &i.Iatas, + ) + return i, err +} + +const getScopeNames = `-- name: GetScopeNames :many +SELECT name FROM transport_scopes ORDER BY name +` + +func (q *Queries) GetScopeNames(ctx context.Context) ([]string, error) { + rows, err := q.db.Query(ctx, getScopeNames) + if err != nil { + return nil, err + } + defer rows.Close() + items := []string{} + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + items = append(items, name) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getScopeStats = `-- name: GetScopeStats :many SELECT ts.name, @@ -836,6 +901,54 @@ func (q *Queries) GetScopeStats(ctx context.Context) ([]GetScopeStatsRow, error) return items, nil } +const getScopesByIATAs = `-- name: GetScopesByIATAs :many +SELECT + ts.name, + COUNT(DISTINCT os.observer_id) AS observer_count, + COUNT(DISTINCT n.id) AS node_count, + COUNT(DISTINCT po.iata) AS iata_count +FROM transport_scopes ts +LEFT JOIN observer_scopes os ON os.scope_id = ts.id +LEFT JOIN observers o ON o.id = os.observer_id +LEFT JOIN packet_observations po ON po.observer_id = o.id +LEFT JOIN nodes n ON n.default_scope_id = ts.id +WHERE ($1::text = '' OR po.iata = ANY(string_to_array($1::text, ','))) +GROUP BY ts.name +ORDER BY ts.name +` + +type GetScopesByIATAsRow struct { + Name string `json:"name"` + ObserverCount int64 `json:"observer_count"` + NodeCount int64 `json:"node_count"` + IataCount int64 `json:"iata_count"` +} + +func (q *Queries) GetScopesByIATAs(ctx context.Context, dollar_1 string) ([]GetScopesByIATAsRow, error) { + rows, err := q.db.Query(ctx, getScopesByIATAs, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetScopesByIATAsRow{} + for rows.Next() { + var i GetScopesByIATAsRow + if err := rows.Scan( + &i.Name, + &i.ObserverCount, + &i.NodeCount, + &i.IataCount, + ); 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/docs/docs.go b/docs/docs.go index 5caf62a..7f907c9 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1207,6 +1207,91 @@ const docTemplate = `{ } } }, + "/scopes": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Scopes" + ], + "summary": "List transport scopes", + "parameters": [ + { + "type": "string", + "description": "Filter by IATA code(s), comma-separated", + "name": "iatas", + "in": "query" + }, + { + "type": "string", + "description": "Filter by region slug", + "name": "region", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by region ID", + "name": "regionId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + }, + "/scopes/{name}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Scopes" + ], + "summary": "Get scope detail by name", + "parameters": [ + { + "type": "string", + "description": "Scope name e.g. %23bc (URL-encoded #bc)", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeDetail" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + }, "/stats/observations": { "get": { "produces": [ @@ -2505,6 +2590,32 @@ const docTemplate = `{ } } }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeDetail": { + "type": "object", + "properties": { + "iataCount": { + "type": "integer" + }, + "iatas": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "nodeCount": { + "type": "integer" + }, + "observerCount": { + "type": "integer" + }, + "packetCount": { + "type": "integer" + } + } + }, "github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeStats": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index c5e5e60..9c710cf 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1205,6 +1205,91 @@ } } }, + "/scopes": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Scopes" + ], + "summary": "List transport scopes", + "parameters": [ + { + "type": "string", + "description": "Filter by IATA code(s), comma-separated", + "name": "iatas", + "in": "query" + }, + { + "type": "string", + "description": "Filter by region slug", + "name": "region", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by region ID", + "name": "regionId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + }, + "/scopes/{name}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Scopes" + ], + "summary": "Get scope detail by name", + "parameters": [ + { + "type": "string", + "description": "Scope name e.g. %23bc (URL-encoded #bc)", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeDetail" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + }, "/stats/observations": { "get": { "produces": [ @@ -2503,6 +2588,32 @@ } } }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeDetail": { + "type": "object", + "properties": { + "iataCount": { + "type": "integer" + }, + "iatas": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "nodeCount": { + "type": "integer" + }, + "observerCount": { + "type": "integer" + }, + "packetCount": { + "type": "integer" + } + } + }, "github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeStats": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index c7c9161..4d24100 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -708,6 +708,23 @@ definitions: description: hex-encoded prefix used for resolution type: string type: object + github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeDetail: + properties: + iataCount: + type: integer + iatas: + items: + type: string + type: array + name: + type: string + nodeCount: + type: integer + observerCount: + type: integer + packetCount: + type: integer + type: object github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeStats: properties: name: @@ -1576,6 +1593,61 @@ paths: summary: Get a single region tags: - Regions + /scopes: + get: + parameters: + - description: Filter by IATA code(s), comma-separated + in: query + name: iatas + type: string + - description: Filter by region slug + in: query + name: region + type: string + - description: Filter by region ID + in: query + name: regionId + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/internal_api_handlers.APIError' + summary: List transport scopes + tags: + - Scopes + /scopes/{name}: + get: + parameters: + - description: 'Scope name e.g. %23bc (URL-encoded #bc)' + in: path + name: name + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeDetail' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_api_handlers.APIError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/internal_api_handlers.APIError' + summary: Get scope detail by name + tags: + - Scopes /stats/observations: get: parameters: diff --git a/internal/api/handlers/scopes.go b/internal/api/handlers/scopes.go new file mode 100644 index 0000000..86329d6 --- /dev/null +++ b/internal/api/handlers/scopes.go @@ -0,0 +1,81 @@ +package handlers + +import ( + "net/http" + + "github.com/MeshCore-Beacon/beacon-server/internal/api" + "github.com/go-chi/chi/v5" +) + +// ScopesRouter mounts all /scopes routes onto a subrouter. +// +// GET /scopes → listScopes +// GET /scopes/{name} → getScope +func ScopesRouter(reader api.Reader) http.Handler { + r := chi.NewRouter() + r.Get("/", listScopes(reader)) + r.Get("/{name}", getScope(reader)) + return r +} + +// listScopes godoc +// +// @Summary List transport scopes +// @Tags Scopes +// @Produce json +// @Param iatas query string false "Filter by IATA code(s), comma-separated" +// @Param region query string false "Filter by region slug" +// @Param regionId query int false "Filter by region ID" +// @Success 200 {object} object +// @Failure 500 {object} handlers.APIError +// @Router /scopes [get] +func listScopes(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + iatas := parseIATAs(r) + if regionIDStr := r.URL.Query().Get("regionId"); regionIDStr != "" || r.URL.Query().Get("region") != "" { + regionIATAs, err := resolveRegionIATAs(r.Context(), r.URL.Query().Get("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 { + names, err := reader.GetScopeNames(r.Context()) + if err != nil { + respondError(w, http.StatusInternalServerError, "internal server error") + return + } + respond(w, http.StatusOK, names) + return + } + scopes, err := reader.GetScopesByIATAs(r.Context(), iatas) + if err != nil { + respondError(w, http.StatusInternalServerError, "internal server error") + return + } + respond(w, http.StatusOK, scopes) + } +} + +// getScope godoc +// +// @Summary Get scope detail by name +// @Tags Scopes +// @Produce json +// @Param name path string true "Scope name e.g. %23bc (URL-encoded #bc)" +// @Success 200 {object} api.ScopeDetail +// @Failure 404 {object} handlers.APIError +// @Failure 500 {object} handlers.APIError +// @Router /scopes/{name} [get] +func getScope(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + scope, err := reader.GetScopeByName(r.Context(), name) + if err != nil { + respondError(w, http.StatusNotFound, "scope not found") + return + } + respond(w, http.StatusOK, scope) + } +} diff --git a/internal/api/reader.go b/internal/api/reader.go index 7a2c7a2..8251070 100644 --- a/internal/api/reader.go +++ b/internal/api/reader.go @@ -123,4 +123,14 @@ type Reader interface { 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) + // GetScopeNames returns the names of all configured transport scopes, ordered alphabetically. + // Use when no geographic filter is applied — returns names only for a lightweight response. + GetScopeNames(ctx context.Context) ([]string, error) + // GetScopesByIATAs returns scope summaries filtered by the given IATA codes, + // including observer, node and IATA counts. Expands region/regionId to IATAs automatically. + GetScopesByIATAs(ctx context.Context, iatas []string) ([]ScopeSummary, error) + // GetScopeByName returns full detail for a single scope by its normalized name (e.g. "#bc"), + // including packet count, observer count, node count, and the list of IATAs it is active in. + // Returns nil if the scope is not found. + GetScopeByName(ctx context.Context, name string) (*ScopeDetail, error) } diff --git a/internal/api/router/router.go b/internal/api/router/router.go index c99a9ac..b38113a 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -71,6 +71,7 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, maxConnsPerIP r.Mount("/messages", handlers.MessagesRouter(reader)) r.Mount("/iatas", handlers.IATAsRouter(reader)) r.Mount("/regions", handlers.RegionsRouter(reader)) + r.Mount("/scopes", handlers.ScopesRouter(reader)) r.Mount("/stats", handlers.StatsRouter(reader)) }) diff --git a/internal/api/scopes.go b/internal/api/scopes.go new file mode 100644 index 0000000..ee07117 --- /dev/null +++ b/internal/api/scopes.go @@ -0,0 +1,19 @@ +package api + +// ScopeSummary is the minimal scope representation used in filtered list responses. +type ScopeSummary struct { + Name string `json:"name"` + ObserverCount int64 `json:"observerCount"` + NodeCount int64 `json:"nodeCount"` + IATACount int64 `json:"iataCount"` +} + +// ScopeDetail is the full scope representation including packet count and IATA list. +type ScopeDetail struct { + Name string `json:"name"` + PacketCount int64 `json:"packetCount"` + ObserverCount int64 `json:"observerCount"` + NodeCount int64 `json:"nodeCount"` + IATACount int64 `json:"iataCount"` + IATAs []string `json:"iatas"` +}