add scopes api

This commit is contained in:
Enot (ded) Skelly committed 2026-06-05 12:31:35 -07:00
1 parent 588b3705c9
commit ae14d948d0
10 files changed
+595

No files matched your search

+35
View File
@@ -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
-- ============================================================
+42
View File
@@ -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
}
+113
View File
@@ -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
+111
View File
@@ -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": {
+111
View File
@@ -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": {
+72
View File
@@ -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:
+81
View File
@@ -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)
}
}
+10
View File
@@ -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)
}
+1
View File
@@ -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))
})
+19
View File
@@ -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"`
}