Merge upstream MeshMapper discovery into the 2.0 experiment

This commit is contained in:
n30nex committed 2026-10-01 20:33:12 -04:00
commit 7891ccd687
70 files changed
+4186 -795

No files matched your search

+16 -3
View File
@@ -35,8 +35,9 @@ func ChannelsRouter(reader api.Reader) http.Handler {
// @Tags Channels
// @Produce json
// @Param hash query string false "Single-byte channel hash (hex)"
// @Param iata query string false "Filter by IATA code"
// @Param iatas query string false "Filter by IATA code(s), comma-separated e.g. YOW or YOW,YYZ"
// @Param iata query string false "Filter by IATA code: channels MeshMapper lists there, config channels scoped to a region containing it, and Beacon-wide config channels"
// @Param iatas query string false "Filter by IATA code(s), comma-separated e.g. YOW or YOW,YYZ; same membership rule as iata"
// @Param keyKnown query bool false "Only channels Beacon can (true) or cannot (false) decrypt; omit for all"
// @Param cursor query int false "last_seen epoch ms of last item for pagination; 0 starts from the beginning"
// @Param pageCursor query string false "Opaque nextPageCursor from a previous response; preserves timestamp ties and precision. Cannot be combined with a positive cursor; cursor=0 is allowed."
// @Param limit query int false "Max results (default 50); must be positive, values above 200 are clamped" minimum(1) maximum(200)
@@ -86,7 +87,19 @@ func listChannels(reader api.Reader) http.HandlerFunc {
}
hashHex = h
}
channels, err := reader.ListChannels(r.Context(), limit, hashHex, iatas, cursor, pageCursor)
var keyKnown *bool
if q := r.URL.Query(); q.Has("keyKnown") {
switch q.Get("keyKnown") {
case "true":
keyKnown = new(true)
case "false":
keyKnown = new(false)
default:
respondError(w, http.StatusBadRequest, "keyKnown must be true or false")
return
}
}
channels, err := reader.ListChannels(r.Context(), limit, hashHex, iatas, keyKnown, cursor, pageCursor)
if err != nil {
respondError(w, http.StatusInternalServerError, "internal server error")
return
+52 -3
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
@@ -39,7 +40,7 @@ func TestListChannels_InvalidCursor(t *testing.T) {
func TestListChannels_PageCursor(t *testing.T) {
called := false
r := ChannelsRouter(stubReader{listChannels: func(_ context.Context, limit int32, hash []byte, iatas []string, legacy int64, cursor *api.ChannelCursor) (api.ChannelPage, error) {
r := ChannelsRouter(stubReader{listChannels: func(_ context.Context, limit int32, hash []byte, iatas []string, _ *bool, legacy int64, cursor *api.ChannelCursor) (api.ChannelPage, error) {
called = true
if limit != 2 || legacy != 0 || cursor == nil || cursor.ID != 9 || !cursor.LastSeen.Equal(time.UnixMicro(1700000000000123)) ||
!reflect.DeepEqual(hash, []byte{0xaa}) || !reflect.DeepEqual(iatas, []string{"YOW"}) {
@@ -145,7 +146,7 @@ func TestListChannelMessages_InvalidCursor(t *testing.T) {
func TestListChannels_OK(t *testing.T) {
r := chi.NewRouter()
r.Get("/channels", listChannels(stubReader{
listChannels: func(_ context.Context, _ int32, _ []byte, _ []string, _ int64, _ *api.ChannelCursor) (api.ChannelPage, error) {
listChannels: func(_ context.Context, _ int32, _ []byte, _ []string, _ *bool, _ int64, _ *api.ChannelCursor) (api.ChannelPage, error) {
return api.ChannelPage{Page: api.Page[api.ChannelSummary]{Items: []api.ChannelSummary{{ID: 1, ChannelHash: "ab"}}}}, nil
},
}))
@@ -172,7 +173,7 @@ func TestListChannels_IATAParsing(t *testing.T) {
var got []string
r := chi.NewRouter()
r.Get("/channels", listChannels(stubReader{
listChannels: func(_ context.Context, _ int32, _ []byte, iatas []string, _ int64, _ *api.ChannelCursor) (api.ChannelPage, error) {
listChannels: func(_ context.Context, _ int32, _ []byte, iatas []string, _ *bool, _ int64, _ *api.ChannelCursor) (api.ChannelPage, error) {
got = iatas
return api.ChannelPage{}, nil
},
@@ -190,6 +191,54 @@ func TestListChannels_IATAParsing(t *testing.T) {
}
}
func TestListChannels_KeyKnownParsing(t *testing.T) {
yes, no := true, false
cases := []struct {
name string
query string
code int
want *bool
}{
{"true", "?keyKnown=true", http.StatusOK, &yes},
{"false", "?keyKnown=false", http.StatusOK, &no},
{"absent", "", http.StatusOK, nil},
{"uppercase", "?keyKnown=TRUE", http.StatusBadRequest, nil},
{"numeric", "?keyKnown=1", http.StatusBadRequest, nil},
{"empty", "?keyKnown=", http.StatusBadRequest, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var got *bool
called := false
r := chi.NewRouter()
r.Get("/channels", listChannels(stubReader{
listChannels: func(_ context.Context, _ int32, _ []byte, _ []string, keyKnown *bool, _ int64, _ *api.ChannelCursor) (api.ChannelPage, error) {
called = true
got = keyKnown
return api.ChannelPage{}, nil
},
}))
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/channels"+tc.query, nil))
if w.Code != tc.code {
t.Fatalf("expected %d, got %d", tc.code, w.Code)
}
if tc.code != http.StatusOK {
if called {
t.Error("reader called for invalid keyKnown")
}
if !strings.Contains(w.Body.String(), "keyKnown must be true or false") {
t.Errorf("unexpected error body %s", w.Body.String())
}
return
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("expected keyKnown %v, got %v", tc.want, got)
}
})
}
}
func TestGetChannel_OK(t *testing.T) {
r := chi.NewRouter()
r.Get("/channels/{channelID}", getChannel(stubReader{
+2 -2
View File
@@ -19,7 +19,7 @@ func TestListLimits(t *testing.T) {
var got int32
var calls int
reader := stubReader{
listChannels: func(ctx context.Context, limit int32, hash []byte, iatas []string, cursor int64, pageCursor *api.ChannelCursor) (api.ChannelPage, error) {
listChannels: func(ctx context.Context, limit int32, hash []byte, iatas []string, keyKnown *bool, cursor int64, pageCursor *api.ChannelCursor) (api.ChannelPage, error) {
calls++
got = limit
return api.ChannelPage{}, nil
@@ -113,7 +113,7 @@ func TestListLimits(t *testing.T) {
router.Mount("/messages", MessagesRouter(reader))
router.Mount("/routes", RoutesRouter(reader))
router.Mount("/traces", TracesRouter(reader))
router.Mount("/stats", StatsRouter(reader))
router.Mount("/stats", StatsRouter(reader, nil))
for _, endpoint := range []struct {
path string
defaultLimit int32
@@ -28,7 +28,7 @@ func TestObserverComparisonRejectsInvalidQuery(t *testing.T) {
} {
t.Run(query, func(t *testing.T) {
w := httptest.NewRecorder()
StatsRouter(nil).ServeHTTP(w, httptest.NewRequest("GET", "/observer-comparison?"+query, nil))
StatsRouter(nil, nil).ServeHTTP(w, httptest.NewRequest("GET", "/observer-comparison?"+query, nil))
if w.Code != 400 {
t.Fatalf("status %d; wanted validation error", w.Code)
}
@@ -42,7 +42,7 @@ func TestObserverComparisonHTTP(t *testing.T) {
query := "observerA=" + a.String() + "&observerB=" + b.String() + "&since=0&until=1000"
for _, suffix := range []string{"&since=2", "&observerA=" + b.String(), "&until=253402300800000", "&observerB=00000000-0000-0000-0000-000000000000"} {
w := httptest.NewRecorder()
StatsRouter(nil).ServeHTTP(w, httptest.NewRequest("GET", "/observer-comparison?"+query+suffix, nil))
StatsRouter(nil, nil).ServeHTTP(w, httptest.NewRequest("GET", "/observer-comparison?"+query+suffix, nil))
if w.Code != 400 {
t.Fatalf("invalid query returned %d", w.Code)
}
@@ -80,7 +80,7 @@ func TestObserverComparisonHTTP(t *testing.T) {
},
}
w := httptest.NewRecorder()
StatsRouter(reader).ServeHTTP(w, httptest.NewRequest("GET", "/observer-comparison?"+query+tc.suffix, nil))
StatsRouter(reader, nil).ServeHTTP(w, httptest.NewRequest("GET", "/observer-comparison?"+query+tc.suffix, nil))
if w.Code != tc.status {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
+3 -3
View File
@@ -28,7 +28,7 @@ func (s pathReader) GetPathStats(ctx context.Context, since, until time.Time, ia
func TestPathsRejectInvalidWindow(t *testing.T) {
for _, query := range []string{"", "since=0", "since=x&until=1", "since=-1&until=1", "since=1&until=1", "since=2&until=1", "since=0&until=2592000001", "since=0&since=0&until=1", "since=0&until=1&until=2", "since=253402300799998&until=253402300800000"} {
w := httptest.NewRecorder()
StatsRouter(nil).ServeHTTP(w, httptest.NewRequest("GET", "/paths?"+query, nil))
StatsRouter(nil, nil).ServeHTTP(w, httptest.NewRequest("GET", "/paths?"+query, nil))
if w.Code != 400 {
t.Fatalf("query %q: %d %s", query, w.Code, w.Body.String())
}
@@ -66,7 +66,7 @@ func TestPathsFiltersErrorsAndCancellation(t *testing.T) {
return &api.PathStats{Until: until.UnixMilli(), Receptions: 4, Hashed: 1, Empty: 1, Trace: 1, Unclassified: 1}, tc.err
}}
w := httptest.NewRecorder()
StatsRouter(reader).ServeHTTP(w, httptest.NewRequest("GET", "/paths?since=0&until=2592000000"+tc.query, nil))
StatsRouter(reader, nil).ServeHTTP(w, httptest.NewRequest("GET", "/paths?since=0&until=2592000000"+tc.query, nil))
if w.Code != tc.status || strings.Contains(w.Body.String(), "private detail") {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
@@ -86,7 +86,7 @@ func TestPathsFiltersErrorsAndCancellation(t *testing.T) {
return nil, ctx.Err()
}}
w := httptest.NewRecorder()
StatsRouter(r).ServeHTTP(w, httptest.NewRequest("GET", "/paths?since=0&until=1", nil).WithContext(ctx))
StatsRouter(r, nil).ServeHTTP(w, httptest.NewRequest("GET", "/paths?since=0&until=1", nil).WithContext(ctx))
if !called || w.Body.Len() != 0 {
t.Fatal("cancellation did not reach reader")
}
+4 -4
View File
@@ -11,19 +11,19 @@ import (
// ScopeCatalogueSource supplies immutable, already-cached public metadata.
type ScopeCatalogueSource interface {
Catalogues() []meshmapper.Catalogue
Catalogues() []meshmapper.PublicScopeCatalogue
}
// ScopeCatalogues godoc
// @Summary Cached MeshMapper regional scope catalogues
// @Description Configured sources only. Counts describe the source region, not individual nodes or links. No upstream request is made. An empty list means no configured importer; checkedAt=0 means no successful check. lastError or a past freshUntil marks stale metadata. Manual Beacon scopes and packet evidence are separate.
// @Description Discovered sources only. Counts describe the source region, not individual nodes or links. No upstream request is made. An empty list means no configured importer; checkedAt=0 means no successful check. lastError or a past freshUntil marks stale metadata. Manual Beacon scopes and packet evidence are separate.
// @Tags Scopes
// @Produce json
// @Success 200 {array} meshmapper.Catalogue
// @Success 200 {array} meshmapper.PublicScopeCatalogue
// @Router /scope-catalogues [get]
func ScopeCatalogues(source ScopeCatalogueSource) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
items := []meshmapper.Catalogue{}
items := []meshmapper.PublicScopeCatalogue{}
if source != nil {
items = source.Catalogues()
}
+41 -3
View File
@@ -13,6 +13,7 @@ import (
"testing"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/MeshCore-Beacon/beacon-server/internal/scopestore"
)
func TestGetStatsScopes_RegionErrors(t *testing.T) {
@@ -27,7 +28,7 @@ func TestGetStatsScopes_RegionErrors(t *testing.T) {
},
}
w := httptest.NewRecorder()
getStatsScopes(reader)(w, httptest.NewRequest(http.MethodGet, "/stats/scopes?"+query, nil))
getStatsScopes(reader, nil)(w, httptest.NewRequest(http.MethodGet, "/stats/scopes?"+query, nil))
if w.Code != http.StatusBadRequest {
t.Fatalf("got status %d, want 400", w.Code)
}
@@ -79,7 +80,7 @@ func TestGetStatsScopes_Filters(t *testing.T) {
},
}
w := httptest.NewRecorder()
getStatsScopes(reader)(w, httptest.NewRequest(http.MethodGet, "/stats/scopes?"+tc.query, nil))
getStatsScopes(reader, testScopes(map[string][]string{"YVR": {"#test"}, "YYJ": {"#test"}}))(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())
}
@@ -105,8 +106,45 @@ 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))
}}, nil)(w, httptest.NewRequest(http.MethodGet, "/stats/scopes?iata=YVR", nil))
if w.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", w.Code)
}
}
func testScopes(byIATA map[string][]string) *scopestore.ScopeStore {
s := scopestore.New()
s.SetManualMembers(byIATA)
return s
}
func TestGetStatsScopes_FiltersToRegionMembers(t *testing.T) {
reader := stubReader{getScopeStats: func(context.Context, []string) ([]api.ScopeStats, error) {
return []api.ScopeStats{{Name: "#elsewhere", PacketCount: 9}, {Name: "#on"}, {Name: "#yow", PacketCount: 4}}, nil
}}
members := testScopes(map[string][]string{"YOW": {"#yow", "#on"}})
for _, tc := range []struct {
query string
members ScopeMembership
want []string
}{
{"", members, []string{"#elsewhere", "#on", "#yow"}},
{"iata=YOW", members, []string{"#on", "#yow"}},
{"iata=YUL", members, []string{}},
{"iata=YOW", nil, []string{}},
} {
w := httptest.NewRecorder()
getStatsScopes(reader, tc.members)(w, httptest.NewRequest(http.MethodGet, "/stats/scopes?"+tc.query, nil))
var rows []api.ScopeStats
if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil || rows == nil {
t.Fatalf("%q: body %s, err %v", tc.query, w.Body.String(), err)
}
got := []string{}
for _, row := range rows {
got = append(got, row.Name)
}
if !slices.Equal(got, tc.want) {
t.Fatalf("%q: got %v, want %v", tc.query, got, tc.want)
}
}
}
+23 -15
View File
@@ -10,13 +10,25 @@ import (
"github.com/go-chi/chi/v5"
)
// ScopeMembership lists scopes by configured region and imported catalogue membership.
type ScopeMembership interface {
NamesForIATAs(iatas []string) []string // sorted
}
func scopeNamesFor(scopes ScopeMembership, iatas []string) []string {
if scopes == nil {
return []string{}
}
return scopes.NamesForIATAs(iatas)
}
// ScopesRouter mounts all /scopes routes onto a subrouter.
//
// GET /scopes → listScopes
// GET /scopes/{name} → getScope
func ScopesRouter(reader api.Reader) http.Handler {
func ScopesRouter(reader api.Reader, scopes ScopeMembership) http.Handler {
r := chi.NewRouter()
r.Get("/", listScopes(reader))
r.Get("/", listScopes(reader, scopes))
r.Get("/{name}", getScope(reader))
return r
}
@@ -24,27 +36,28 @@ func ScopesRouter(reader api.Reader) http.Handler {
// listScopes godoc
//
// @Summary List transport scopes
// @Description The unfiltered list includes stored imported names after an importer is disabled or a source is removed. Those historical identities are retained indefinitely; listing them does not establish observed traffic or current source membership.
// @Description Without filters, lists every stored scope name, including imported names retained after an importer is disabled or a source is removed. With IATA or region filters, lists only manual scopes configured for a matching region and imported scopes whose current MeshMapper catalogue includes a matching IATA; observed traffic does not add 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
// @Success 200 {array} string
// @Failure 400 {object} handlers.APIError
// @Failure 500 {object} handlers.APIError
// @Router /scopes [get]
func listScopes(reader api.Reader) http.HandlerFunc {
func listScopes(reader api.Reader, scopes ScopeMembership) 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)
regionID, region := r.URL.Query().Get("regionId"), r.URL.Query().Get("region")
if regionID != "" || region != "" {
regionIATAs, err := resolveRegionIATAs(r.Context(), regionID, region, reader)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
iatas = append(iatas, regionIATAs...)
}
if len(iatas) == 0 {
} else if len(iatas) == 0 {
names, err := reader.GetScopeNames(r.Context())
if err != nil {
respondError(w, http.StatusInternalServerError, "internal server error")
@@ -53,12 +66,7 @@ func listScopes(reader api.Reader) http.HandlerFunc {
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)
respond(w, http.StatusOK, scopeNamesFor(scopes, iatas))
}
}
+40 -1
View File
@@ -5,8 +5,10 @@ package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"testing"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
@@ -19,7 +21,7 @@ func TestListScopes_NoIATAs_OK(t *testing.T) {
getScopeNames: func(_ context.Context) ([]string, error) {
return []string{"#bc", "#west"}, nil
},
}))
}, nil))
req := httptest.NewRequest(http.MethodGet, "/scopes", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -42,3 +44,40 @@ func TestGetScope_OK(t *testing.T) {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestListScopes_FiltersByMembership(t *testing.T) {
reader := stubReader{
getScopeNames: func(context.Context) ([]string, error) {
t.Fatal("filtered list must not fall back to every stored name")
return nil, nil
},
getRegionBySlug: func(_ context.Context, slug string) (*api.Region, error) {
if slug == "empty" {
return &api.Region{}, nil
}
return &api.Region{IATAs: []string{"YOW", "YGK"}}, nil
},
}
members := testScopes(map[string][]string{"YOW": {"#on", "#yow"}, "YGK": {"#on"}, "YUL": {"#qc"}})
for _, tc := range []struct {
query string
members ScopeMembership
want []string
}{
{"iatas=yow", members, []string{"#on", "#yow"}},
{"iatas=YUL,YYZ", members, []string{"#qc"}},
{"region=ottawa", members, []string{"#on", "#yow"}},
{"region=empty", members, []string{}},
{"iatas=YOW", nil, []string{}},
} {
w := httptest.NewRecorder()
listScopes(reader, tc.members)(w, httptest.NewRequest(http.MethodGet, "/scopes?"+tc.query, nil))
var got []string
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil || w.Code != http.StatusOK || got == nil {
t.Fatalf("%q: status %d body %s", tc.query, w.Code, w.Body.String())
}
if !slices.Equal(got, tc.want) {
t.Fatalf("%q: got %v, want %v", tc.query, got, tc.want)
}
}
}
+3 -3
View File
@@ -28,7 +28,7 @@ func (s signalReader) GetSignalStats(ctx context.Context, since, until time.Time
func TestSignalRejectsInvalidWindow(t *testing.T) {
for _, query := range []string{"", "since=0", "until=1", "since=x&until=1", "since=-1&until=1", "since=0&until=0", "since=2&until=1", "since=0&until=2592000001", "since=0&since=0&until=1", "since=0&until=1&until=2", "since=253402300799998&until=253402300800000"} {
w := httptest.NewRecorder()
StatsRouter(nil).ServeHTTP(w, httptest.NewRequest("GET", "/signal?"+query, nil))
StatsRouter(nil, nil).ServeHTTP(w, httptest.NewRequest("GET", "/signal?"+query, nil))
if w.Code != 400 {
t.Fatalf("query %q: %d %s", query, w.Code, w.Body.String())
}
@@ -70,7 +70,7 @@ func TestSignalFiltersAndErrors(t *testing.T) {
return &api.SignalStats{Until: until.UnixMilli(), Receptions: 5, Hourly: []api.SignalHour{}}, tc.err
}}
w := httptest.NewRecorder()
StatsRouter(reader).ServeHTTP(w, httptest.NewRequest("GET", "/signal?since=0&until=2592000000"+tc.query, nil))
StatsRouter(reader, nil).ServeHTTP(w, httptest.NewRequest("GET", "/signal?since=0&until=2592000000"+tc.query, nil))
if w.Code != tc.status || strings.Contains(w.Body.String(), "private detail") {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
@@ -93,7 +93,7 @@ func TestSignalCancellationReachesReader(t *testing.T) {
return nil, ctx.Err()
}}
w := httptest.NewRecorder()
StatsRouter(reader).ServeHTTP(w, httptest.NewRequest("GET", "/signal?since=0&until=1", nil).WithContext(ctx))
StatsRouter(reader, nil).ServeHTTP(w, httptest.NewRequest("GET", "/signal?since=0&until=1", nil).WithContext(ctx))
if !called || w.Body.Len() != 0 {
t.Fatalf("cancellation: called=%v body=%s", called, w.Body.String())
}
+12 -4
View File
@@ -6,6 +6,7 @@ package handlers
import (
"log/slog"
"net/http"
"slices"
"strconv"
"time"
@@ -26,7 +27,7 @@ import (
// GET /stats/scopes → GetStatsScopes
//
// All endpoints accept an optional iata= filter (case-insensitive).
func StatsRouter(reader api.Reader) http.Handler {
func StatsRouter(reader api.Reader, scopes ScopeMembership) http.Handler {
r := chi.NewRouter()
r.Get("/overview", getStatsOverview(reader))
r.Get("/observations", getStatsObservations(reader))
@@ -40,7 +41,7 @@ func StatsRouter(reader api.Reader) http.Handler {
r.Get("/clock-drift", getStatsClockDrift(reader))
r.Get("/top-talkers", getStatsTopTalkers(reader))
r.Get("/radio-presets", getStatsRadioPresets(reader))
r.Get("/scopes", getStatsScopes(reader))
r.Get("/scopes", getStatsScopes(reader, scopes))
r.Get("/node-types", getStatsNodeTypes(reader))
return r
}
@@ -417,7 +418,7 @@ 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.
// @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 for every stored scope. With filters, lists only manual scopes configured for a matching region and imported scopes whose current MeshMapper catalogue includes a matching IATA; those remain listed with zero counts. An empty region returns an empty array.
// @Tags Stats
// @Produce json
// @Param iatas query string false "Comma-separated IATA codes"
@@ -428,7 +429,7 @@ func getStatsRadioPresets(reader api.Reader) http.HandlerFunc {
// @Failure 400 {object} handlers.APIError
// @Failure 500 {object} handlers.APIError
// @Router /stats/scopes [get]
func getStatsScopes(reader api.Reader) http.HandlerFunc {
func getStatsScopes(reader api.Reader, scopes ScopeMembership) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
iatas := parseIATAs(r)
if regionID := r.URL.Query().Get("regionId"); regionID != "" || r.URL.Query().Get("region") != "" {
@@ -448,6 +449,13 @@ func getStatsScopes(reader api.Reader) http.HandlerFunc {
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
if len(iatas) > 0 {
members := scopeNamesFor(scopes, iatas)
stats = slices.DeleteFunc(slices.Clone(stats), func(s api.ScopeStats) bool {
_, found := slices.BinarySearch(members, s.Name)
return !found
})
}
respond(w, http.StatusOK, stats)
}
}
+1 -1
View File
@@ -285,7 +285,7 @@ func TestGetStatsScopes_OK(t *testing.T) {
getScopeStats: func(_ context.Context, _ []string) ([]api.ScopeStats, error) {
return []api.ScopeStats{{Name: "#bc", PacketCount: 100}}, nil
},
}))
}, nil))
req := httptest.NewRequest(http.MethodGet, "/stats/scopes", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
+3 -11
View File
@@ -24,7 +24,7 @@ type stubReader struct {
listRegions func(ctx context.Context) ([]api.RegionSummary, error)
getRegion func(ctx context.Context, regionID int32) (*api.Region, error)
getRegionBySlug func(ctx context.Context, slug string) (*api.Region, error)
listChannels func(ctx context.Context, limit int32, hash []byte, iatas []string, cursor int64, pageCursor *api.ChannelCursor) (api.ChannelPage, error)
listChannels func(ctx context.Context, limit int32, hash []byte, iatas []string, keyKnown *bool, cursor int64, pageCursor *api.ChannelCursor) (api.ChannelPage, error)
getChannel func(ctx context.Context, channelID int32) (*api.Channel, error)
listChannelMessages func(ctx context.Context, channelID *int32, since time.Time, limit int32, iatas []string, scope string, cursor int64) (api.Page[api.ChannelMessage], error)
listChannelMessagesByHash func(ctx context.Context, hash []byte, since time.Time, limit int32, iatas []string, scope string, cursor int64) (api.Page[api.ChannelMessage], error)
@@ -55,7 +55,6 @@ type stubReader struct {
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)
getScopeByName func(ctx context.Context, name string) (*api.ScopeDetail, error)
listTraceTags func(ctx context.Context, iatas []string, scope, traceType string, since, until time.Time, cursor time.Time, limit int32) ([]api.TraceTagSummary, error)
getTraceByTag func(ctx context.Context, tag string) (*api.TraceDetail, error)
@@ -124,9 +123,9 @@ func (s stubReader) GetRegionBySlug(ctx context.Context, slug string) (*api.Regi
return nil, nil
}
func (s stubReader) ListChannels(ctx context.Context, limit int32, hash []byte, iatas []string, cursor int64, pageCursor *api.ChannelCursor) (api.ChannelPage, error) {
func (s stubReader) ListChannels(ctx context.Context, limit int32, hash []byte, iatas []string, keyKnown *bool, cursor int64, pageCursor *api.ChannelCursor) (api.ChannelPage, error) {
if s.listChannels != nil {
return s.listChannels(ctx, limit, hash, iatas, cursor, pageCursor)
return s.listChannels(ctx, limit, hash, iatas, keyKnown, cursor, pageCursor)
}
return api.ChannelPage{}, nil
}
@@ -341,13 +340,6 @@ func (s stubReader) GetScopeNames(ctx context.Context) ([]string, error) {
return nil, nil
}
func (s stubReader) GetScopesByIATAs(ctx context.Context, iatas []string) ([]api.ScopeSummary, error) {
if s.getScopesByIATAs != nil {
return s.getScopesByIATAs(ctx, iatas)
}
return nil, nil
}
func (s stubReader) GetScopeByName(ctx context.Context, name string) (*api.ScopeDetail, error) {
if s.getScopeByName != nil {
return s.getScopeByName(ctx, name)
+2 -6
View File
@@ -53,11 +53,11 @@ type Reader interface {
// ListChannels returns a paginated list of channels ordered by last seen.
// Includes both hashtag-derived and explicit key channels.
// Pass nil hash to skip hash filtering. Pass empty iatas to return all channels;
// IATAs must be uppercase.
// IATAs must be uppercase. Nil keyKnown skips the decryption-key filter.
// cursor is last_seen epoch ms of the last item; pass 0 to start from the beginning.
// pageCursor is the precise timestamp/ID boundary; nil preserves the legacy numeric cursor.
// When non-nil, pageCursor takes precedence over cursor.
ListChannels(ctx context.Context, limit int32, hash []byte, iatas []string, cursor int64, pageCursor *ChannelCursor) (ChannelPage, error)
ListChannels(ctx context.Context, limit int32, hash []byte, iatas []string, keyKnown *bool, cursor int64, pageCursor *ChannelCursor) (ChannelPage, error)
// GetChannel returns full detail for a single channel by its integer ID.
// Returns nil, pgx.ErrNoRows if the channel is not found.
@@ -210,10 +210,6 @@ type Reader interface {
// 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.
+1 -1
View File
@@ -7,7 +7,6 @@ import (
"net/http"
"github.com/MeshCore-Beacon/beacon-server/internal/api/handlers"
"github.com/MeshCore-Beacon/beacon-server/internal/config"
)
@@ -22,6 +21,7 @@ type Options struct {
Server config.ServerConfig
Auth config.AuthConfig
RateLimit config.ResolvedRateLimitConfig
Scopes handlers.ScopeMembership // region-filtered scope lists; nil lists none
// AdminRoutes mounts operator-only subrouters at literal paths such as
// "/accounts". Each feature owns its dependencies, methods and handlers.
// The router applies authentication to every path and method in this group.
+2 -2
View File
@@ -107,9 +107,9 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, opts Options)
r.Mount("/iatas", handlers.IATAsRouter(reader))
r.Mount("/regions", handlers.RegionsRouter(reader))
r.Mount("/routes", handlers.RoutesRouter(reader))
r.Mount("/scopes", handlers.ScopesRouter(reader))
r.Mount("/scopes", handlers.ScopesRouter(reader, opts.Scopes))
r.Mount("/stats", handlers.StatsRouter(reader, opts.Scopes))
r.Get("/scope-catalogues", handlers.ScopeCatalogues(opts.ScopeCatalogues))
r.Mount("/stats", handlers.StatsRouter(reader))
r.Mount("/traces", handlers.TracesRouter(reader))
})
-8
View File
@@ -3,14 +3,6 @@
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"`