feat(nodes): add ?neighbors to list nodes

inlcudes neighbor IDs in node details list
This commit is contained in:
Enot (ded) Skelly
2026-07-02 09:12:23 -07:00
parent ee2fac1b20
commit 4e2ee62bad
14 changed files with 291 additions and 48 deletions
+12 -10
View File
@@ -80,22 +80,23 @@ func (s *Store) SetNodeDefaultScope(ctx context.Context, nodeID uuid.UUID, scope
})
}
func (s *Store) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32) (api.Page[api.NodeSummary], error) {
func (s *Store) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) {
var cursorTS pgtype.Timestamptz
if cursor > 0 {
cursorTS = pgtype.Timestamptz{Time: time.UnixMilli(cursor), Valid: true}
}
iataFilter := strings.Join(iatas, ",")
rows, err := s.q.ListNodes(ctx, sqlc.ListNodesParams{
Column1: nodeType,
Column2: iataFilter,
Column3: tristate(supportsMultibytePaths),
Column4: tristate(supportsMultibyteTraces),
Column5: pubkey,
Column6: name,
Column7: cursorTS,
Limit: limit + 1,
Column9: scope,
Column1: nodeType,
Column2: iataFilter,
Column3: tristate(supportsMultibytePaths),
Column4: tristate(supportsMultibyteTraces),
Column5: pubkey,
Column6: name,
Column7: cursorTS,
Limit: limit + 1,
Column9: scope,
Column10: includeNeighbors,
})
if err != nil {
return api.Page[api.NodeSummary]{}, err
@@ -117,6 +118,7 @@ func (s *Store) ListNodes(ctx context.Context, nodeType int16, iatas []string, s
IsObserver: v.IsObserver,
ObserverID: nullableUUID(v.ObserverID),
KnownNeighborCount: v.KnownNeighborCount,
NeighborIDs: v.NeighborIds,
}
if len(v.Iatas) > 0 {
if err := json.Unmarshal(v.Iatas, &node.IATAs); err != nil {
+60 -3
View File
@@ -134,7 +134,7 @@ func TestListNodes_Pagination(t *testing.T) {
Return(rows, nil)
store := &Store{q: mock}
page, err := store.ListNodes(context.Background(), 0, []string{"YVR"}, nil, nil, nil, "", "", 0, 2)
page, err := store.ListNodes(context.Background(), 0, []string{"YVR"}, nil, nil, nil, "", "", 0, 2, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -167,7 +167,7 @@ func TestListNodes_IATAsUnmarshal(t *testing.T) {
}, nil)
store := &Store{q: mock}
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", 0, 10)
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", 0, 10, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -201,7 +201,7 @@ func TestListNodes_RadioStringFormatting(t *testing.T) {
}, nil)
store := &Store{q: mock}
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", 0, 10)
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", 0, 10, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -369,3 +369,60 @@ func TestGetNodeNeighbors_DBError(t *testing.T) {
t.Fatal("expected error, got nil")
}
}
func TestListNodes_IncludeNeighbors_PassesFlagAndMapsIDs(t *testing.T) {
ctrl := gomock.NewController(t)
mock := mockdb.NewMockQuerier(ctrl)
nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
neighborID := uuid.MustParse("00000000-0000-0000-0000-000000000002")
mock.EXPECT().
ListNodes(gomock.Any(), gomock.Eq(sqlc.ListNodesParams{
Column1: int16(0), Column2: "", Column3: "any", Column4: "any",
Column5: nil, Column6: "", Column7: pgtype.Timestamptz{},
Limit: 11, Column9: "", Column10: true,
})).
Return([]sqlc.ListNodesRow{
{
ID: nodeID,
PublicKey: []byte{0x01},
NeighborIds: []uuid.UUID{neighborID},
},
}, nil)
store := &Store{q: mock}
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", 0, 10, true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(page.Items[0].NeighborIDs) != 1 || page.Items[0].NeighborIDs[0] != neighborID {
t.Errorf("expected NeighborIDs [%s], got %v", neighborID, page.Items[0].NeighborIDs)
}
}
func TestListNodes_ExcludeNeighbors_LeavesIDsNil(t *testing.T) {
ctrl := gomock.NewController(t)
mock := mockdb.NewMockQuerier(ctrl)
nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
mock.EXPECT().
ListNodes(gomock.Any(), gomock.Eq(sqlc.ListNodesParams{
Column1: int16(0), Column2: "", Column3: "any", Column4: "any",
Column5: nil, Column6: "", Column7: pgtype.Timestamptz{},
Limit: 11, Column9: "", Column10: false,
})).
Return([]sqlc.ListNodesRow{
{ID: nodeID, PublicKey: []byte{0x01}, NeighborIds: nil},
}, nil)
store := &Store{q: mock}
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", 0, 10, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if page.Items[0].NeighborIDs != nil {
t.Errorf("expected NeighborIDs to stay nil when includeNeighbors is false, got %v", page.Items[0].NeighborIDs)
}
}
+6 -1
View File
@@ -494,7 +494,12 @@ SELECT n.id, n.public_key, n.node_type, n.name, n.latitude, n.longitude, n.last_
json_agg(json_build_object('iata', ni.iata, 'lastHeard', (extract(epoch from ni.last_heard) * 1000)::bigint) ORDER BY ni.last_heard DESC) FILTER (WHERE ni.iata IS NOT NULL) AS iatas,
EXISTS (SELECT 1 FROM observers o WHERE o.public_key = n.public_key) AS is_observer,
(SELECT o.id FROM observers o WHERE o.public_key = n.public_key LIMIT 1) AS observer_id,
(SELECT COUNT(DISTINCT nn.neighbor_id) FROM node_neighbors nn WHERE nn.node_id = n.id)::bigint AS known_neighbor_count
(SELECT COUNT(DISTINCT nn.neighbor_id) FROM node_neighbors nn WHERE nn.node_id = n.id)::bigint AS known_neighbor_count,
-- CASE short-circuits: the array_agg subquery only runs when $10 is true,
-- so requests that don't ask for neighbor IDs don't pay for it.
(CASE WHEN $10::bool THEN
(SELECT COALESCE(array_agg(DISTINCT nn.neighbor_id), '{}'::uuid[]) FROM node_neighbors nn WHERE nn.node_id = n.id)
ELSE NULL END)::uuid[] AS neighbor_ids
FROM nodes n
LEFT JOIN node_iatas ni ON ni.node_id = n.id
LEFT JOIN transport_scopes ts ON ts.id = n.default_scope_id
+19 -10
View File
@@ -2039,7 +2039,12 @@ SELECT n.id, n.public_key, n.node_type, n.name, n.latitude, n.longitude, n.last_
json_agg(json_build_object('iata', ni.iata, 'lastHeard', (extract(epoch from ni.last_heard) * 1000)::bigint) ORDER BY ni.last_heard DESC) FILTER (WHERE ni.iata IS NOT NULL) AS iatas,
EXISTS (SELECT 1 FROM observers o WHERE o.public_key = n.public_key) AS is_observer,
(SELECT o.id FROM observers o WHERE o.public_key = n.public_key LIMIT 1) AS observer_id,
(SELECT COUNT(DISTINCT nn.neighbor_id) FROM node_neighbors nn WHERE nn.node_id = n.id)::bigint AS known_neighbor_count
(SELECT COUNT(DISTINCT nn.neighbor_id) FROM node_neighbors nn WHERE nn.node_id = n.id)::bigint AS known_neighbor_count,
-- CASE short-circuits: the array_agg subquery only runs when $10 is true,
-- so requests that don't ask for neighbor IDs don't pay for it.
(CASE WHEN $10::bool THEN
(SELECT COALESCE(array_agg(DISTINCT nn.neighbor_id), '{}'::uuid[]) FROM node_neighbors nn WHERE nn.node_id = n.id)
ELSE NULL END)::uuid[] AS neighbor_ids
FROM nodes n
LEFT JOIN node_iatas ni ON ni.node_id = n.id
LEFT JOIN transport_scopes ts ON ts.id = n.default_scope_id
@@ -2066,15 +2071,16 @@ LIMIT $8
`
type ListNodesParams struct {
Column1 interface{} `json:"column_1"`
Column2 string `json:"column_2"`
Column3 string `json:"column_3"`
Column4 string `json:"column_4"`
Column5 []byte `json:"column_5"`
Column6 interface{} `json:"column_6"`
Column7 pgtype.Timestamptz `json:"column_7"`
Limit int32 `json:"limit"`
Column9 string `json:"column_9"`
Column1 interface{} `json:"column_1"`
Column2 string `json:"column_2"`
Column3 string `json:"column_3"`
Column4 string `json:"column_4"`
Column5 []byte `json:"column_5"`
Column6 interface{} `json:"column_6"`
Column7 pgtype.Timestamptz `json:"column_7"`
Limit int32 `json:"limit"`
Column9 string `json:"column_9"`
Column10 bool `json:"column_10"`
}
type ListNodesRow struct {
@@ -2093,6 +2099,7 @@ type ListNodesRow struct {
IsObserver bool `json:"is_observer"`
ObserverID uuid.UUID `json:"observer_id"`
KnownNeighborCount int64 `json:"known_neighbor_count"`
NeighborIds []uuid.UUID `json:"neighbor_ids"`
}
func (q *Queries) ListNodes(ctx context.Context, arg ListNodesParams) ([]ListNodesRow, error) {
@@ -2106,6 +2113,7 @@ func (q *Queries) ListNodes(ctx context.Context, arg ListNodesParams) ([]ListNod
arg.Column7,
arg.Limit,
arg.Column9,
arg.Column10,
)
if err != nil {
return nil, err
@@ -2130,6 +2138,7 @@ func (q *Queries) ListNodes(ctx context.Context, arg ListNodesParams) ([]ListNod
&i.IsObserver,
&i.ObserverID,
&i.KnownNeighborCount,
&i.NeighborIds,
); err != nil {
return nil, err
}
+20
View File
@@ -521,6 +521,12 @@ const docTemplate = `{
"name": "supportsMultibyteTraces",
"in": "query"
},
{
"type": "boolean",
"description": "Include each node's known neighbor IDs (neighborIds field). Bare ?neighbors or ?neighbors=true enables it; omit/false for none",
"name": "neighbors",
"in": "query"
},
{
"type": "integer",
"description": "last_seen epoch ms of last item for pagination",
@@ -2341,6 +2347,13 @@ const docTemplate = `{
"name": {
"type": "string"
},
"neighborIds": {
"description": "only populated when the list request opts in; see ?neighbors=true",
"type": "array",
"items": {
"type": "string"
}
},
"neighbors": {
"type": "array",
"items": {
@@ -2466,6 +2479,13 @@ const docTemplate = `{
"name": {
"type": "string"
},
"neighborIds": {
"description": "only populated when the list request opts in; see ?neighbors=true",
"type": "array",
"items": {
"type": "string"
}
},
"nodeType": {
"description": "1=companion, 2=repeater, 3=room_server, 4=sensor",
"type": "integer"
+20
View File
@@ -519,6 +519,12 @@
"name": "supportsMultibyteTraces",
"in": "query"
},
{
"type": "boolean",
"description": "Include each node's known neighbor IDs (neighborIds field). Bare ?neighbors or ?neighbors=true enables it; omit/false for none",
"name": "neighbors",
"in": "query"
},
{
"type": "integer",
"description": "last_seen epoch ms of last item for pagination",
@@ -2339,6 +2345,13 @@
"name": {
"type": "string"
},
"neighborIds": {
"description": "only populated when the list request opts in; see ?neighbors=true",
"type": "array",
"items": {
"type": "string"
}
},
"neighbors": {
"type": "array",
"items": {
@@ -2464,6 +2477,13 @@
"name": {
"type": "string"
},
"neighborIds": {
"description": "only populated when the list request opts in; see ?neighbors=true",
"type": "array",
"items": {
"type": "string"
}
},
"nodeType": {
"description": "1=companion, 2=repeater, 3=room_server, 4=sensor",
"type": "integer"
+15
View File
@@ -212,6 +212,11 @@ definitions:
type: string
name:
type: string
neighborIds:
description: only populated when the list request opts in; see ?neighbors=true
items:
type: string
type: array
neighbors:
items:
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.NodeNeighbor'
@@ -299,6 +304,11 @@ definitions:
type: number
name:
type: string
neighborIds:
description: only populated when the list request opts in; see ?neighbors=true
items:
type: string
type: array
nodeType:
description: 1=companion, 2=repeater, 3=room_server, 4=sensor
type: integer
@@ -1339,6 +1349,11 @@ paths:
in: query
name: supportsMultibyteTraces
type: boolean
- description: Include each node's known neighbor IDs (neighborIds field). Bare
?neighbors or ?neighbors=true enables it; omit/false for none
in: query
name: neighbors
type: boolean
- description: last_seen epoch ms of last item for pagination
in: query
name: cursor
+16 -1
View File
@@ -46,6 +46,7 @@ func NodesRouter(reader api.Reader) http.Handler {
// @Param pubkey query string false "Exact public key match (hex)"
// @Param supportsMultibytePaths query bool false "Filter by multibyte path support (true/false); omit for no filter"
// @Param supportsMultibyteTraces query bool false "Filter by multibyte trace support (true/false); omit for no filter"
// @Param neighbors query bool false "Include each node's known neighbor IDs (neighborIds field). Bare ?neighbors or ?neighbors=true enables it; omit/false for none"
// @Param cursor query int false "last_seen epoch ms of last item for pagination"
// @Param limit query int false "Max results (default 50)"
// @Success 200 {object} api.Page[api.NodeSummary]
@@ -121,7 +122,21 @@ func listNodes(reader api.Reader) http.HandlerFunc {
}
supportsMultibyteTraces = &b
}
nodes, err := reader.ListNodes(r.Context(), nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, name, scope, cursor, limit)
var includeNeighbors bool
if vals, ok := r.URL.Query()["neighbors"]; ok {
v := vals[0] // bare `?neighbors` (no `=`) parses as ""
if v == "" {
includeNeighbors = true
} else {
b, err := strconv.ParseBool(v)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid neighbors value")
return
}
includeNeighbors = b
}
}
nodes, err := reader.ListNodes(r.Context(), nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, name, scope, cursor, limit, includeNeighbors)
if err != nil {
respondError(w, http.StatusInternalServerError, "internal server error")
return
+58 -1
View File
@@ -7,6 +7,7 @@ import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
@@ -128,7 +129,7 @@ func TestListNodes_OK(t *testing.T) {
nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
r := chi.NewRouter()
r.Get("/nodes", listNodes(stubReader{
listNodes: func(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _ string, _ int64, _ int32) (api.Page[api.NodeSummary], error) {
listNodes: func(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _ string, _ int64, _ int32, _ bool) (api.Page[api.NodeSummary], error) {
return api.Page[api.NodeSummary]{Items: []api.NodeSummary{{ID: nodeID}}}, nil
},
}))
@@ -140,6 +141,62 @@ func TestListNodes_OK(t *testing.T) {
}
}
func TestListNodes_NeighborsParam_PassedThrough(t *testing.T) {
nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
neighborID := uuid.MustParse("00000000-0000-0000-0000-000000000002")
var gotIncludeNeighbors bool
r := chi.NewRouter()
r.Get("/nodes", listNodes(stubReader{
listNodes: func(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _ string, _ int64, _ int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) {
gotIncludeNeighbors = includeNeighbors
return api.Page[api.NodeSummary]{Items: []api.NodeSummary{{ID: nodeID, NeighborIDs: []uuid.UUID{neighborID}}}}, nil
},
}))
req := httptest.NewRequest(http.MethodGet, "/nodes?neighbors=true", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
if !gotIncludeNeighbors {
t.Error("expected neighbors=true query param to be passed through as includeNeighbors=true")
}
if !strings.Contains(w.Body.String(), "neighborIds") {
t.Errorf("expected response body to include neighborIds, got %s", w.Body.String())
}
}
func TestListNodes_NeighborsParam_InvalidValue(t *testing.T) {
r := chi.NewRouter()
r.Get("/nodes", listNodes(stubReader{}))
req := httptest.NewRequest(http.MethodGet, "/nodes?neighbors=notabool", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestListNodes_NeighborsParam_BareFlagMeansTrue(t *testing.T) {
var gotIncludeNeighbors bool
r := chi.NewRouter()
r.Get("/nodes", listNodes(stubReader{
listNodes: func(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _ string, _ int64, _ int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) {
gotIncludeNeighbors = includeNeighbors
return api.Page[api.NodeSummary]{}, nil
},
}))
req := httptest.NewRequest(http.MethodGet, "/nodes?neighbors", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
if !gotIncludeNeighbors {
t.Error("expected bare ?neighbors (no value) to be treated as true")
}
}
func TestGetNode_OK(t *testing.T) {
nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
r := chi.NewRouter()
+44 -3
View File
@@ -31,7 +31,7 @@ type stubReader struct {
getObserverTelemetryBucketed func(ctx context.Context, observerID uuid.UUID, since, until time.Time, bucketHours int32) ([]api.ObserverTelemetryPoint, error)
getObserverScopes func(ctx context.Context, observerID uuid.UUID) ([]string, error)
listObserverAdverts func(ctx context.Context, observerID uuid.UUID, cursor int64, limit int32) (api.Page[api.AdvertObservation], error)
listNodes func(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32) (api.Page[api.NodeSummary], error)
listNodes func(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32, includeNeighbors bool) (api.Page[api.NodeSummary], error)
getNode func(ctx context.Context, nodeID uuid.UUID) (*api.Node, error)
getNodeNeighbors func(ctx context.Context, nodeID uuid.UUID) ([]api.NodeNeighbor, error)
listNodeObservations func(ctx context.Context, nodeID uuid.UUID, cursor int64, limit int32) (api.Page[api.PacketObservationSummary], error)
@@ -65,246 +65,287 @@ func (s stubReader) ListIATAs(ctx context.Context) ([]api.IATA, error) {
}
return nil, nil
}
func (s stubReader) GetIATA(ctx context.Context, iata string) (*api.IATA, error) {
if s.getIATA != nil {
return s.getIATA(ctx, iata)
}
return nil, nil
}
func (s stubReader) ListRegions(ctx context.Context) ([]api.RegionSummary, error) {
if s.listRegions != nil {
return s.listRegions(ctx)
}
return nil, nil
}
func (s stubReader) GetRegion(ctx context.Context, regionID int32) (*api.Region, error) {
if s.getRegion != nil {
return s.getRegion(ctx, regionID)
}
return nil, nil
}
func (s stubReader) GetRegionBySlug(ctx context.Context, slug string) (*api.Region, error) {
if s.getRegionBySlug != nil {
return s.getRegionBySlug(ctx, slug)
}
return nil, nil
}
func (s stubReader) ListChannels(ctx context.Context, limit int32, hash []byte, iata string, cursor int64) (api.Page[api.ChannelSummary], error) {
if s.listChannels != nil {
return s.listChannels(ctx, limit, hash, iata, cursor)
}
return api.Page[api.ChannelSummary]{}, nil
}
func (s stubReader) GetChannel(ctx context.Context, channelID int32) (*api.Channel, error) {
if s.getChannel != nil {
return s.getChannel(ctx, channelID)
}
return nil, nil
}
func (s stubReader) ListChannelMessages(ctx context.Context, channelID *int32, since time.Time, limit int32, iatas []string, scope string, cursor int64) (api.Page[api.ChannelMessage], error) {
if s.listChannelMessages != nil {
return s.listChannelMessages(ctx, channelID, since, limit, iatas, scope, cursor)
}
return api.Page[api.ChannelMessage]{}, nil
}
func (s stubReader) ListChannelMessagesByHash(ctx context.Context, hash []byte, since time.Time, limit int32, iatas []string, scope string, cursor int64) (api.Page[api.ChannelMessage], error) {
if s.listChannelMessagesByHash != nil {
return s.listChannelMessagesByHash(ctx, hash, since, limit, iatas, scope, cursor)
}
return api.Page[api.ChannelMessage]{}, nil
}
func (s stubReader) ListMessagesAfterID(ctx context.Context, afterID int64, iatas []string, scope string, limit int32) ([]api.ChannelMessage, error) {
if s.listMessagesAfterID != nil {
return s.listMessagesAfterID(ctx, afterID, iatas, scope, limit)
}
return nil, nil
}
func (s stubReader) ListObservers(ctx context.Context, iatas []string, observerType, broker, status, name, scope string, cursor int64, limit int32) (api.Page[api.ObserverSummary], error) {
if s.listObservers != nil {
return s.listObservers(ctx, iatas, observerType, broker, status, name, scope, cursor, limit)
}
return api.Page[api.ObserverSummary]{}, nil
}
func (s stubReader) GetObserver(ctx context.Context, observerID uuid.UUID) (*api.Observer, error) {
if s.getObserver != nil {
return s.getObserver(ctx, observerID)
}
return nil, nil
}
func (s stubReader) GetObserverTelemetry(ctx context.Context, observerID uuid.UUID, since, until time.Time, afterID int64) (*api.ObserverTelemetry, error) {
if s.getObserverTelemetry != nil {
return s.getObserverTelemetry(ctx, observerID, since, until, afterID)
}
return nil, nil
}
func (s stubReader) GetObserverTelemetryBucketed(ctx context.Context, observerID uuid.UUID, since, until time.Time, bucketHours int32) ([]api.ObserverTelemetryPoint, error) {
if s.getObserverTelemetryBucketed != nil {
return s.getObserverTelemetryBucketed(ctx, observerID, since, until, bucketHours)
}
return nil, nil
}
func (s stubReader) GetObserverScopes(ctx context.Context, observerID uuid.UUID) ([]string, error) {
if s.getObserverScopes != nil {
return s.getObserverScopes(ctx, observerID)
}
return nil, nil
}
func (s stubReader) ListObserverAdverts(ctx context.Context, observerID uuid.UUID, cursor int64, limit int32) (api.Page[api.AdvertObservation], error) {
if s.listObserverAdverts != nil {
return s.listObserverAdverts(ctx, observerID, cursor, limit)
}
return api.Page[api.AdvertObservation]{}, nil
}
func (s stubReader) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32) (api.Page[api.NodeSummary], error) {
func (s stubReader) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) {
if s.listNodes != nil {
return s.listNodes(ctx, nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, name, scope, cursor, limit)
return s.listNodes(ctx, nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, name, scope, cursor, limit, includeNeighbors)
}
return api.Page[api.NodeSummary]{}, nil
}
func (s stubReader) GetNode(ctx context.Context, nodeID uuid.UUID) (*api.Node, error) {
if s.getNode != nil {
return s.getNode(ctx, nodeID)
}
return nil, nil
}
func (s stubReader) GetNodeNeighbors(ctx context.Context, nodeID uuid.UUID) ([]api.NodeNeighbor, error) {
if s.getNodeNeighbors != nil {
return s.getNodeNeighbors(ctx, nodeID)
}
return nil, nil
}
func (s stubReader) ListNodeObservations(ctx context.Context, nodeID uuid.UUID, cursor int64, limit int32) (api.Page[api.PacketObservationSummary], error) {
if s.listNodeObservations != nil {
return s.listNodeObservations(ctx, nodeID, cursor, limit)
}
return api.Page[api.PacketObservationSummary]{}, nil
}
func (s stubReader) ListPackets(ctx context.Context, payloadType, routeType int16, iatas []string, scope string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) {
if s.listPackets != nil {
return s.listPackets(ctx, payloadType, routeType, iatas, scope, since, until, cursor, limit)
}
return api.Page[api.PacketSummary]{}, nil
}
func (s stubReader) ListPacketsAfterID(ctx context.Context, afterObservationID int64, payloadType, routeType int16, iatas []string, scope string, limit int32) ([]api.PacketSummary, error) {
if s.listPacketsAfterID != nil {
return s.listPacketsAfterID(ctx, afterObservationID, payloadType, routeType, iatas, scope, limit)
}
return nil, nil
}
func (s stubReader) GetPacket(ctx context.Context, packetHash []byte) (*api.Packet, error) {
if s.getPacket != nil {
return s.getPacket(ctx, packetHash)
}
return nil, nil
}
func (s stubReader) GetRadioPresets(ctx context.Context, preset string, iatas []string) ([]api.RadioPreset, error) {
if s.getRadioPresets != nil {
return s.getRadioPresets(ctx, preset, iatas)
}
return nil, nil
}
func (s stubReader) GetStatsOverview(ctx context.Context, iatas []string) (*api.StatsOverview, error) {
if s.getStatsOverview != nil {
return s.getStatsOverview(ctx, iatas)
}
return nil, nil
}
func (s stubReader) GetStatsObservations(ctx context.Context, iatas []string, since time.Time) ([]api.ObservationPoint, error) {
if s.getStatsObservations != nil {
return s.getStatsObservations(ctx, iatas, since)
}
return nil, nil
}
func (s stubReader) GetStatsPayloadBreakdown(ctx context.Context, iatas []string, since time.Time) ([]api.PayloadBreakdownItem, error) {
if s.getStatsPayloadBreakdown != nil {
return s.getStatsPayloadBreakdown(ctx, iatas, since)
}
return nil, nil
}
func (s stubReader) GetStatsTopNodes(ctx context.Context, iatas []string, limit int32) ([]api.TopNode, error) {
if s.getStatsTopNodes != nil {
return s.getStatsTopNodes(ctx, iatas, limit)
}
return nil, nil
}
func (s stubReader) GetStatsTopObservers(ctx context.Context, iatas []string, since time.Time, limit int32) ([]api.TopObserver, error) {
if s.getStatsTopObservers != nil {
return s.getStatsTopObservers(ctx, iatas, since, limit)
}
return nil, nil
}
func (s stubReader) GetScopeStats(ctx context.Context) ([]api.ScopeStats, error) {
if s.getScopeStats != nil {
return s.getScopeStats(ctx)
}
return nil, nil
}
func (s stubReader) GetStatsNodeTypes(ctx context.Context, iatas []string) ([]api.NodeTypeCount, error) {
if s.getStatsNodeTypes != nil {
return s.getStatsNodeTypes(ctx, iatas)
}
return nil, nil
}
func (s stubReader) GetScopeNames(ctx context.Context) ([]string, error) {
if s.getScopeNames != nil {
return s.getScopeNames(ctx)
}
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)
}
return nil, nil
}
func (s stubReader) ListTraceTags(ctx context.Context, iatas []string, scope, traceType string, since, until time.Time, cursor time.Time, limit int32) ([]api.TraceTagSummary, error) {
if s.listTraceTags != nil {
return s.listTraceTags(ctx, iatas, scope, traceType, since, until, cursor, limit)
}
return nil, nil
}
func (s stubReader) GetTraceByTag(ctx context.Context, tag string) (*api.TraceDetail, error) {
if s.getTraceByTag != nil {
return s.getTraceByTag(ctx, tag)
}
return nil, nil
}
func (s stubReader) ListKnownRoutes(ctx context.Context, iata string, hopCount int32, cursor time.Time, limit int32) ([]api.KnownRoute, error) {
if s.listKnownRoutes != nil {
return s.listKnownRoutes(ctx, iata, hopCount, cursor, limit)
}
return nil, nil
}
func (s stubReader) SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash string) ([]api.KnownRoute, error) {
if s.searchKnownRoutes != nil {
return s.searchKnownRoutes(ctx, iata, fromHash, toHash)
}
return nil, nil
}
func (s stubReader) GetKnownRoutesByNode(ctx context.Context, iata string, nodeID uuid.UUID) ([]api.KnownRoute, error) {
if s.getKnownRoutesByNode != nil {
return s.getKnownRoutesByNode(ctx, iata, nodeID)
}
return nil, nil
}
func (s stubReader) GetCrossIATANeighbors(ctx context.Context, nodeID uuid.UUID, iata string) ([]api.NodeNeighbor, error) {
if s.getCrossIATANeighbors != nil {
return s.getCrossIATANeighbors(ctx, nodeID, iata)
}
return nil, nil
}
func (s stubReader) SearchCrossIATARoutes(ctx context.Context, fromHash, fromIATA, toHash, toIATA string) ([]api.CrossIATARoute, error) {
if s.searchCrossIATARoutes != nil {
return s.searchCrossIATARoutes(ctx, fromHash, fromIATA, toHash, toIATA)
}
return nil, nil
}
func (s stubReader) GetNodesByIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]*api.ResolvedNode, error) {
if s.getNodesByIDs != nil {
return s.getNodesByIDs(ctx, ids)
+14 -13
View File
@@ -34,19 +34,20 @@ type NodeIATA struct {
// NodeSummary is the minimal node representation used in list responses.
type NodeSummary struct {
ID uuid.UUID `json:"id"`
PublicKey string `json:"publicKey"` // hex-encoded Ed25519 public key
NodeType int16 `json:"nodeType"` // 1=companion, 2=repeater, 3=room_server, 4=sensor
NodeTypeName string `json:"nodeTypeName"`
Name *string `json:"name,omitempty"`
IsObserver bool `json:"isObserver"` // true if this node is also a known observer
ObserverID *uuid.UUID `json:"observerId,omitempty"` // UUID of the associated observer row, if any
Latitude *float64 `json:"lat,omitempty"` // decimal degrees, from advert AppData
Longitude *float64 `json:"lng,omitempty"` // decimal degrees, from advert AppData
Radio *string `json:"radio,omitempty"` // shorthand: "freqMhz,bwKhz,sf" e.g. "910.5,62.5,7"
IATAs []NodeIATA `json:"iatas"` // IATAs where this node has been heard, with last heard timestamps
DefaultScope *string `json:"defaultScope,omitempty"` // most recently matched transport scope name e.g. "#bc"
KnownNeighborCount int64 `json:"knownNeighborCount"`
ID uuid.UUID `json:"id"`
PublicKey string `json:"publicKey"` // hex-encoded Ed25519 public key
NodeType int16 `json:"nodeType"` // 1=companion, 2=repeater, 3=room_server, 4=sensor
NodeTypeName string `json:"nodeTypeName"`
Name *string `json:"name,omitempty"`
IsObserver bool `json:"isObserver"` // true if this node is also a known observer
ObserverID *uuid.UUID `json:"observerId,omitempty"` // UUID of the associated observer row, if any
Latitude *float64 `json:"lat,omitempty"` // decimal degrees, from advert AppData
Longitude *float64 `json:"lng,omitempty"` // decimal degrees, from advert AppData
Radio *string `json:"radio,omitempty"` // shorthand: "freqMhz,bwKhz,sf" e.g. "910.5,62.5,7"
IATAs []NodeIATA `json:"iatas"` // IATAs where this node has been heard, with last heard timestamps
DefaultScope *string `json:"defaultScope,omitempty"` // most recently matched transport scope name e.g. "#bc"
KnownNeighborCount int64 `json:"knownNeighborCount"`
NeighborIDs []uuid.UUID `json:"neighborIds,omitempty"` // only populated when the list request opts in; see ?neighbors=true
}
// Node is the full node representation including firmware capability flags,
+4 -3
View File
@@ -97,9 +97,10 @@ type Reader interface {
ListObserverAdverts(ctx context.Context, observerID uuid.UUID, cursor int64, limit int32) (Page[AdvertObservation], error)
// ListNodes returns a paginated list of nodes with optional filters.
// Pass 0 for nodeType, nil iatas, nil for pubkey to skip those filters.
// cursor is last_seen epoch ms; pass 0 to start from the beginning.
ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32) (Page[NodeSummary], error)
// When includeNeighbors is true, each NodeSummary's NeighborIDs field is
// populated with the distinct set of neighbor node IDs (across all
// IATAs); otherwise it's left nil to avoid the extra aggregation.
ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32, includeNeighbors bool) (Page[NodeSummary], error)
// GetNode returns full detail for a single node by UUID.
// Returns nil, pgx.ErrNoRows if the node is not found.
+1 -1
View File
@@ -136,7 +136,7 @@ func (s *stubReader) ListMessagesAfterID(_ context.Context, _ int64, _ []string,
return nil, nil
}
func (s *stubReader) ListNodes(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _ string, _ int64, _ int32) (api.Page[api.NodeSummary], error) {
func (s *stubReader) ListNodes(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _ string, _ int64, _ int32, _ bool) (api.Page[api.NodeSummary], error) {
return api.Page[api.NodeSummary]{}, nil
}
+2 -2
View File
@@ -344,8 +344,8 @@ func (cr *CachedReader) ListMessagesAfterID(ctx context.Context, afterID int64,
}
// ListNodes implements [api.Reader].
func (cr *CachedReader) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32) (api.Page[api.NodeSummary], error) {
return cr.inner.ListNodes(ctx, nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, name, scope, cursor, limit)
func (cr *CachedReader) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) {
return cr.inner.ListNodes(ctx, nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, name, scope, cursor, limit, includeNeighbors)
}
// ListNodeObservations implements [api.Reader].