From 7d25b8480f3edde4eec8609dea3842a35c305641 Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Mon, 8 Jun 2026 09:35:42 -0700 Subject: [PATCH] feat: node neighbors limited to repeaters and room servers stores high confidence matches from the nodes advert packet path first hops --- README.md | 11 +- db/migrations/001_schema.sql | 17 +++ db/nodes.go | 37 +++++ db/queries/queries.sql | 23 +++ db/sqlc/models.go | 9 ++ db/sqlc/queries.sql.go | 78 ++++++++++ docs/docs.go | 248 ++++++++++++++++++++++++++++++++ docs/swagger.json | 248 ++++++++++++++++++++++++++++++++ docs/swagger.yaml | 163 +++++++++++++++++++++ internal/api/handlers/nodes.go | 27 ++++ internal/api/nodes.go | 31 ++-- internal/api/reader.go | 2 + internal/ingest/ingest.go | 4 + internal/ingest/side_effects.go | 15 ++ 14 files changed, 901 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4b753a3..a01af84 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,7 @@ GET /api/v1/packets?iata=YOW&afterId=12345&limit=100 | `GET` | `/messages/backfill` | Backfill messages after a given message ID | | `GET` | `/nodes` | List nodes | | `GET` | `/nodes/{nodeId}` | Get node detail | +| `GET` | `/nodes/{nodeId}/neighbors` | List neighboring nodes observed in the mesh | | `GET` | `/nodes/{nodeId}/observations` | List observations for a node | | `GET` | `/observers` | List observers (optional: `?iata=&type=&broker=&status=online\|offline`) | | `GET` | `/observers/{observerId}` | Get observer detail including broker last-seen timestamps | @@ -466,10 +467,12 @@ AIRPORTS_CSV=/path/to/airports.csv go run ./internal/iatadb/gen details - [x] Propagation time calculation - [x] Trace route resolution via path hashes (resolvedRoute on packet detail) -- [x] Trace packets: trace tag storage, list and detail endpoints with resolved - routes -- [x] Known routes: fully resolved paths stored at ingest, list and search - endpoints +- [x] REST API: Trace packets: trace tag storage, list and detail endpoints with + resolved routes +- [x] REST API: Known routes: fully resolved paths stored at ingest, list and + search endpoints +- [x] Node neighbor detection and storage from advert path resolution +- [x] REST API: Node neighbors endpoint ### Future diff --git a/db/migrations/001_schema.sql b/db/migrations/001_schema.sql index c62cead..154f80e 100644 --- a/db/migrations/001_schema.sql +++ b/db/migrations/001_schema.sql @@ -353,6 +353,23 @@ CREATE TABLE known_routes ( CREATE INDEX idx_known_routes_iata ON known_routes(iata); CREATE INDEX idx_known_routes_hop_count ON known_routes(iata, hop_count); +-- ============================================================ +-- NEIGHBORS +-- ============================================================ + +CREATE TABLE node_neighbors ( + node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + neighbor_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + iata CHAR(3) NOT NULL REFERENCES iata_codes(iata) ON DELETE CASCADE, + first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + observation_count BIGINT NOT NULL DEFAULT 1, + PRIMARY KEY (node_id, neighbor_id, iata) +); + +CREATE INDEX idx_node_neighbors_node ON node_neighbors(node_id, iata); +CREATE INDEX idx_node_neighbors_neighbor ON node_neighbors(neighbor_id, iata); + -- ============================================================ -- MATERIALIZED VIEWS -- ============================================================ diff --git a/db/nodes.go b/db/nodes.go index 09569f9..61e50d7 100644 --- a/db/nodes.go +++ b/db/nodes.go @@ -50,6 +50,14 @@ func (s *Store) UpsertNodeShortID(ctx context.Context, nodeID uuid.UUID, iata st }) } +func (s *Store) UpsertNodeNeighbor(ctx context.Context, nodeID, neighborID uuid.UUID, iata string) error { + return s.q.UpsertNodeNeighbor(ctx, sqlc.UpsertNodeNeighborParams{ + NodeID: nodeID, + NeighborID: neighborID, + Iata: iata, + }) +} + func (s *Store) SetNodeCapability(ctx context.Context, nodeID uuid.UUID, paths, traces bool) error { var errs []error if paths { @@ -155,6 +163,12 @@ func (s *Store) GetNode(ctx context.Context, nodeID uuid.UUID) (*api.Node, error LastSeen: row.LastSeen.Time.UnixMilli(), Metadata: row.Metadata, } + neighbors, err := s.GetNodeNeighbors(ctx, nodeID) + if err != nil { + log.Printf("store: GetNodeNeighbors failed for %s: %v", nodeID, err) + neighbors = []api.NodeNeighbor{} + } + node.Neighbors = neighbors if len(row.Iatas) > 0 { if err := json.Unmarshal(row.Iatas, &node.IATAs); err != nil { log.Printf("store: failed to unmarshal node iatas: %v", err) @@ -171,3 +185,26 @@ func (s *Store) GetNode(ctx context.Context, nodeID uuid.UUID) (*api.Node, error } return node, nil } + +func (s *Store) GetNodeNeighbors(ctx context.Context, nodeID uuid.UUID) ([]api.NodeNeighbor, error) { + rows, err := s.q.GetNodeNeighbors(ctx, nodeID) + if err != nil { + return nil, err + } + items := make([]api.NodeNeighbor, 0, len(rows)) + for _, r := range rows { + items = append(items, api.NodeNeighbor{ + ID: r.ID, + Name: r.Name, + NodeType: r.NodeType, + NodeTypeName: api.NodeTypeName(r.NodeType), + Latitude: r.Latitude, + Longitude: r.Longitude, + IATA: r.Iata, + ObservationCount: r.ObservationCount, + FirstSeen: r.FirstSeen.Time.UnixMilli(), + LastSeen: r.LastSeen.Time.UnixMilli(), + }) + } + return items, nil +} diff --git a/db/queries/queries.sql b/db/queries/queries.sql index 15e7ecf..ac51b59 100644 --- a/db/queries/queries.sql +++ b/db/queries/queries.sql @@ -854,6 +854,29 @@ WHERE iata = $1 AND array_position(hash_prefix, $2::bytea) < array_position(hash_prefix, $3::bytea) ORDER BY hop_count ASC, last_seen DESC; +-- ============================================================ +-- NEIGHBORS +-- ============================================================ + +-- name: UpsertNodeNeighbor :exec +-- Records or updates a neighbor relationship between two nodes observed in the same IATA. +-- node_id is the advertising node, neighbor_id is the first-hop forwarder. +INSERT INTO node_neighbors (node_id, neighbor_id, iata, observation_count) +VALUES ($1, $2, $3, 1) +ON CONFLICT (node_id, neighbor_id, iata) DO UPDATE SET + last_seen = NOW(), + observation_count = node_neighbors.observation_count + 1; + +-- name: GetNodeNeighbors :many +-- Returns the neighbors of a node with details, ordered by most recently seen. +SELECT + n.id, n.name, n.node_type, n.latitude, n.longitude, + nn.iata, nn.observation_count, nn.first_seen, nn.last_seen +FROM node_neighbors nn +JOIN nodes n ON n.id = nn.neighbor_id +WHERE nn.node_id = $1 +ORDER BY nn.last_seen DESC; + -- ============================================================ -- HELPERS -- ============================================================ diff --git a/db/sqlc/models.go b/db/sqlc/models.go index 22fc413..c454801 100644 --- a/db/sqlc/models.go +++ b/db/sqlc/models.go @@ -112,6 +112,15 @@ type NodeIata struct { ObservationCount *int64 `json:"observation_count"` } +type NodeNeighbor struct { + NodeID uuid.UUID `json:"node_id"` + NeighborID uuid.UUID `json:"neighbor_id"` + Iata string `json:"iata"` + FirstSeen pgtype.Timestamptz `json:"first_seen"` + LastSeen pgtype.Timestamptz `json:"last_seen"` + ObservationCount int64 `json:"observation_count"` +} + type NodeShortID struct { NodeID uuid.UUID `json:"node_id"` Iata string `json:"iata"` diff --git a/db/sqlc/queries.sql.go b/db/sqlc/queries.sql.go index 6056074..ac8eb81 100644 --- a/db/sqlc/queries.sql.go +++ b/db/sqlc/queries.sql.go @@ -361,6 +361,59 @@ func (q *Queries) GetNodeIATAs(ctx context.Context, nodeID uuid.UUID) ([]string, return items, nil } +const getNodeNeighbors = `-- name: GetNodeNeighbors :many +SELECT + n.id, n.name, n.node_type, n.latitude, n.longitude, + nn.iata, nn.observation_count, nn.first_seen, nn.last_seen +FROM node_neighbors nn +JOIN nodes n ON n.id = nn.neighbor_id +WHERE nn.node_id = $1 +ORDER BY nn.last_seen DESC +` + +type GetNodeNeighborsRow struct { + ID uuid.UUID `json:"id"` + Name *string `json:"name"` + NodeType int16 `json:"node_type"` + Latitude *float64 `json:"latitude"` + Longitude *float64 `json:"longitude"` + Iata string `json:"iata"` + ObservationCount int64 `json:"observation_count"` + FirstSeen pgtype.Timestamptz `json:"first_seen"` + LastSeen pgtype.Timestamptz `json:"last_seen"` +} + +// Returns the neighbors of a node with details, ordered by most recently seen. +func (q *Queries) GetNodeNeighbors(ctx context.Context, nodeID uuid.UUID) ([]GetNodeNeighborsRow, error) { + rows, err := q.db.Query(ctx, getNodeNeighbors, nodeID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetNodeNeighborsRow{} + for rows.Next() { + var i GetNodeNeighborsRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.NodeType, + &i.Latitude, + &i.Longitude, + &i.Iata, + &i.ObservationCount, + &i.FirstSeen, + &i.LastSeen, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getObserverBrokers = `-- name: GetObserverBrokers :many SELECT broker_name, last_seen, last_packet_at FROM observer_brokers @@ -3090,6 +3143,31 @@ func (q *Queries) UpsertNodeIATA(ctx context.Context, arg UpsertNodeIATAParams) return err } +const upsertNodeNeighbor = `-- name: UpsertNodeNeighbor :exec + +INSERT INTO node_neighbors (node_id, neighbor_id, iata, observation_count) +VALUES ($1, $2, $3, 1) +ON CONFLICT (node_id, neighbor_id, iata) DO UPDATE SET + last_seen = NOW(), + observation_count = node_neighbors.observation_count + 1 +` + +type UpsertNodeNeighborParams struct { + NodeID uuid.UUID `json:"node_id"` + NeighborID uuid.UUID `json:"neighbor_id"` + Iata string `json:"iata"` +} + +// ============================================================ +// NEIGHBORS +// ============================================================ +// Records or updates a neighbor relationship between two nodes observed in the same IATA. +// node_id is the advertising node, neighbor_id is the first-hop forwarder. +func (q *Queries) UpsertNodeNeighbor(ctx context.Context, arg UpsertNodeNeighborParams) error { + _, err := q.db.Exec(ctx, upsertNodeNeighbor, arg.NodeID, arg.NeighborID, arg.Iata) + return err +} + const upsertNodeShortID = `-- name: UpsertNodeShortID :exec INSERT INTO node_short_ids (node_id, iata, prefix_4) VALUES ($1, $2, $3) diff --git a/docs/docs.go b/docs/docs.go index 95da09f..2bb42d1 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -596,6 +596,49 @@ const docTemplate = `{ } } }, + "/nodes/{nodeId}/neighbors": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Nodes" + ], + "summary": "List neighbors for a node", + "parameters": [ + { + "type": "string", + "description": "Node UUID", + "name": "nodeId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.NodeNeighbor" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + }, "/nodes/{nodeId}/observations": { "get": { "produces": [ @@ -1207,6 +1250,117 @@ const docTemplate = `{ } } }, + "/routes": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Routes" + ], + "summary": "List known routes", + "parameters": [ + { + "type": "string", + "description": "Filter by IATA code", + "name": "iata", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by exact hop count", + "name": "hopCount", + "in": "query" + }, + { + "type": "integer", + "description": "Route ID of last item for pagination", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + }, + "/routes/search": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Routes" + ], + "summary": "Search known routes by source and destination hash", + "parameters": [ + { + "type": "string", + "description": "IATA code to search within", + "name": "iata", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Source node hash prefix (hex)", + "name": "from", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Destination node hash prefix (hex)", + "name": "to", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + }, "/scopes": { "get": { "produces": [ @@ -1847,6 +2001,34 @@ const docTemplate = `{ } } }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute": { + "type": "object", + "properties": { + "firstSeen": { + "description": "epoch ms", + "type": "integer" + }, + "hopCount": { + "type": "integer" + }, + "hops": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop" + } + }, + "iata": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "lastSeen": { + "description": "epoch ms", + "type": "integer" + } + } + }, "github_com_MeshCore-Beacon_beacon-server_internal_api.Node": { "type": "object", "properties": { @@ -1902,6 +2084,12 @@ const docTemplate = `{ "name": { "type": "string" }, + "neighbors": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.NodeNeighbor" + } + }, "nodeType": { "description": "1=companion, 2=repeater, 3=room_server, 4=sensor", "type": "integer" @@ -1943,6 +2131,43 @@ const docTemplate = `{ } } }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.NodeNeighbor": { + "type": "object", + "properties": { + "firstSeen": { + "description": "epoch ms", + "type": "integer" + }, + "iata": { + "type": "string" + }, + "id": { + "type": "string" + }, + "lastSeen": { + "description": "epoch ms", + "type": "integer" + }, + "lat": { + "type": "number" + }, + "lng": { + "type": "number" + }, + "name": { + "type": "string" + }, + "nodeType": { + "type": "integer" + }, + "nodeTypeName": { + "type": "string" + }, + "observationCount": { + "type": "integer" + } + } + }, "github_com_MeshCore-Beacon_beacon-server_internal_api.NodeSummary": { "type": "object", "properties": { @@ -2684,6 +2909,9 @@ const docTemplate = `{ "items": { "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ResolvedNode" } + }, + "snr": { + "type": "number" } } }, @@ -2708,6 +2936,26 @@ const docTemplate = `{ } } }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop": { + "type": "object", + "properties": { + "hashBytes": { + "description": "hex-encoded hash prefix", + "type": "string" + }, + "node": { + "description": "populated when node details are available", + "allOf": [ + { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ResolvedNode" + } + ] + }, + "nodeId": { + "type": "string" + } + } + }, "github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeDetail": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 3b6e94d..933f2fa 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -594,6 +594,49 @@ } } }, + "/nodes/{nodeId}/neighbors": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Nodes" + ], + "summary": "List neighbors for a node", + "parameters": [ + { + "type": "string", + "description": "Node UUID", + "name": "nodeId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.NodeNeighbor" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + }, "/nodes/{nodeId}/observations": { "get": { "produces": [ @@ -1205,6 +1248,117 @@ } } }, + "/routes": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Routes" + ], + "summary": "List known routes", + "parameters": [ + { + "type": "string", + "description": "Filter by IATA code", + "name": "iata", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by exact hop count", + "name": "hopCount", + "in": "query" + }, + { + "type": "integer", + "description": "Route ID of last item for pagination", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + }, + "/routes/search": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Routes" + ], + "summary": "Search known routes by source and destination hash", + "parameters": [ + { + "type": "string", + "description": "IATA code to search within", + "name": "iata", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Source node hash prefix (hex)", + "name": "from", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Destination node hash prefix (hex)", + "name": "to", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + }, "/scopes": { "get": { "produces": [ @@ -1845,6 +1999,34 @@ } } }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute": { + "type": "object", + "properties": { + "firstSeen": { + "description": "epoch ms", + "type": "integer" + }, + "hopCount": { + "type": "integer" + }, + "hops": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop" + } + }, + "iata": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "lastSeen": { + "description": "epoch ms", + "type": "integer" + } + } + }, "github_com_MeshCore-Beacon_beacon-server_internal_api.Node": { "type": "object", "properties": { @@ -1900,6 +2082,12 @@ "name": { "type": "string" }, + "neighbors": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.NodeNeighbor" + } + }, "nodeType": { "description": "1=companion, 2=repeater, 3=room_server, 4=sensor", "type": "integer" @@ -1941,6 +2129,43 @@ } } }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.NodeNeighbor": { + "type": "object", + "properties": { + "firstSeen": { + "description": "epoch ms", + "type": "integer" + }, + "iata": { + "type": "string" + }, + "id": { + "type": "string" + }, + "lastSeen": { + "description": "epoch ms", + "type": "integer" + }, + "lat": { + "type": "number" + }, + "lng": { + "type": "number" + }, + "name": { + "type": "string" + }, + "nodeType": { + "type": "integer" + }, + "nodeTypeName": { + "type": "string" + }, + "observationCount": { + "type": "integer" + } + } + }, "github_com_MeshCore-Beacon_beacon-server_internal_api.NodeSummary": { "type": "object", "properties": { @@ -2682,6 +2907,9 @@ "items": { "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ResolvedNode" } + }, + "snr": { + "type": "number" } } }, @@ -2706,6 +2934,26 @@ } } }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop": { + "type": "object", + "properties": { + "hashBytes": { + "description": "hex-encoded hash prefix", + "type": "string" + }, + "node": { + "description": "populated when node details are available", + "allOf": [ + { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ResolvedNode" + } + ] + }, + "nodeId": { + "type": "string" + } + } + }, "github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeDetail": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 11c8d0d..cab4a34 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -112,6 +112,25 @@ definitions: lon: type: number type: object + github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute: + properties: + firstSeen: + description: epoch ms + type: integer + hopCount: + type: integer + hops: + items: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop' + type: array + iata: + type: string + id: + type: integer + lastSeen: + description: epoch ms + type: integer + type: object github_com_MeshCore-Beacon_beacon-server_internal_api.Node: properties: defaultScope: @@ -152,6 +171,10 @@ definitions: type: string name: type: string + neighbors: + items: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.NodeNeighbor' + type: array nodeType: description: 1=companion, 2=repeater, 3=room_server, 4=sensor type: integer @@ -181,6 +204,31 @@ definitions: description: epoch ms type: integer type: object + github_com_MeshCore-Beacon_beacon-server_internal_api.NodeNeighbor: + properties: + firstSeen: + description: epoch ms + type: integer + iata: + type: string + id: + type: string + lastSeen: + description: epoch ms + type: integer + lat: + type: number + lng: + type: number + name: + type: string + nodeType: + type: integer + nodeTypeName: + type: string + observationCount: + type: integer + type: object github_com_MeshCore-Beacon_beacon-server_internal_api.NodeSummary: properties: defaultScope: @@ -693,6 +741,8 @@ definitions: items: $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ResolvedNode' type: array + snr: + type: number type: object github_com_MeshCore-Beacon_beacon-server_internal_api.ResolvedNode: properties: @@ -708,6 +758,18 @@ definitions: description: hex-encoded prefix used for resolution type: string type: object + github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop: + properties: + hashBytes: + description: hex-encoded hash prefix + type: string + node: + allOf: + - $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ResolvedNode' + description: populated when node details are available + nodeId: + type: string + type: object github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeDetail: properties: iataCount: @@ -1240,6 +1302,34 @@ paths: summary: Get node detail tags: - Nodes + /nodes/{nodeId}/neighbors: + get: + parameters: + - description: Node UUID + in: path + name: nodeId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.NodeNeighbor' + type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_api_handlers.APIError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/internal_api_handlers.APIError' + summary: List neighbors for a node + tags: + - Nodes /nodes/{nodeId}/observations: get: parameters: @@ -1644,6 +1734,79 @@ paths: summary: Get a single region tags: - Regions + /routes: + get: + parameters: + - description: Filter by IATA code + in: query + name: iata + type: string + - description: Filter by exact hop count + in: query + name: hopCount + type: integer + - description: Route ID of last item for pagination + in: query + name: cursor + type: integer + - description: Max results (default 50) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute' + type: array + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/internal_api_handlers.APIError' + summary: List known routes + tags: + - Routes + /routes/search: + get: + parameters: + - description: IATA code to search within + in: query + name: iata + required: true + type: string + - description: Source node hash prefix (hex) + in: query + name: from + required: true + type: string + - description: Destination node hash prefix (hex) + in: query + name: to + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute' + type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_api_handlers.APIError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/internal_api_handlers.APIError' + summary: Search known routes by source and destination hash + tags: + - Routes /scopes: get: parameters: diff --git a/internal/api/handlers/nodes.go b/internal/api/handlers/nodes.go index 907a269..7bdc2e0 100644 --- a/internal/api/handlers/nodes.go +++ b/internal/api/handlers/nodes.go @@ -21,6 +21,7 @@ func NodesRouter(reader api.Reader) http.Handler { r.Route("/{nodeId}", func(r chi.Router) { r.Get("/", getNode(reader)) r.Get("/observations", listNodeObservations(reader)) + r.Get("/neighbors", listNodeNeighbors(reader)) }) return r } @@ -196,3 +197,29 @@ func listNodeObservations(reader api.Reader) http.HandlerFunc { respond(w, http.StatusOK, observations) } } + +// listNodeNeighbors godoc +// +// @Summary List neighbors for a node +// @Tags Nodes +// @Produce json +// @Param nodeId path string true "Node UUID" +// @Success 200 {object} []api.NodeNeighbor +// @Failure 400 {object} handlers.APIError +// @Failure 500 {object} handlers.APIError +// @Router /nodes/{nodeId}/neighbors [get] +func listNodeNeighbors(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + nodeID, err := uuid.Parse(chi.URLParam(r, "nodeId")) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid node ID") + return + } + neighbors, err := reader.GetNodeNeighbors(r.Context(), nodeID) + if err != nil { + respondError(w, http.StatusInternalServerError, "internal server error") + return + } + respond(w, http.StatusOK, neighbors) + } +} diff --git a/internal/api/nodes.go b/internal/api/nodes.go index 02fa83b..cfa7325 100644 --- a/internal/api/nodes.go +++ b/internal/api/nodes.go @@ -7,6 +7,20 @@ import ( "github.com/meshcore-go/meshcore-go" ) +// NodeNeighbor represents a neighboring node relationship observed in a given IATA. +type NodeNeighbor struct { + ID uuid.UUID `json:"id"` + Name *string `json:"name,omitempty"` + NodeType int16 `json:"nodeType"` + NodeTypeName string `json:"nodeTypeName"` + Latitude *float64 `json:"lat,omitempty"` + Longitude *float64 `json:"lng,omitempty"` + IATA string `json:"iata"` + ObservationCount int64 `json:"observationCount"` + FirstSeen int64 `json:"firstSeen"` // epoch ms + LastSeen int64 `json:"lastSeen"` // epoch ms +} + // NodeIATA represents a single IATA code and the last time the node was heard there. type NodeIATA struct { IATA string `json:"iata"` @@ -33,14 +47,15 @@ type NodeSummary struct { // location source, and timing metadata. type Node struct { NodeSummary - LocationSource *string `json:"locationSource,omitempty"` // "advert" or "manual" - LastAdvertAt *int64 `json:"lastAdvertAt,omitempty"` // epoch ms, nil if no advert received - SupportsMultibytePaths bool `json:"supportsMultibytePaths"` // firmware >= 1.14.0; detected via path hash size - SupportsMultibyteTraces bool `json:"supportsMultibyteTraces"` // firmware >= 1.11.0; detected via trace hash size - MinFirmwareVersion *string `json:"minFirmwareVersion,omitempty"` // derived from capability flags - FirstSeen int64 `json:"firstSeen"` // epoch ms - LastSeen int64 `json:"lastSeen"` // epoch ms - Metadata any `json:"metadata,omitempty"` // raw JSONB metadata + LocationSource *string `json:"locationSource,omitempty"` // "advert" or "manual" + LastAdvertAt *int64 `json:"lastAdvertAt,omitempty"` // epoch ms, nil if no advert received + SupportsMultibytePaths bool `json:"supportsMultibytePaths"` // firmware >= 1.14.0; detected via path hash size + SupportsMultibyteTraces bool `json:"supportsMultibyteTraces"` // firmware >= 1.11.0; detected via trace hash size + MinFirmwareVersion *string `json:"minFirmwareVersion,omitempty"` // derived from capability flags + FirstSeen int64 `json:"firstSeen"` // epoch ms + LastSeen int64 `json:"lastSeen"` // epoch ms + Metadata any `json:"metadata,omitempty"` // raw JSONB metadata + Neighbors []NodeNeighbor `json:"neighbors"` } // NodeTypeName returns a human-readable name for a node type integer. diff --git a/internal/api/reader.go b/internal/api/reader.go index 1f3a49b..d536c50 100644 --- a/internal/api/reader.go +++ b/internal/api/reader.go @@ -141,4 +141,6 @@ type Reader interface { ListKnownRoutes(ctx context.Context, iata string, hopCount int32, cursor time.Time, limit int32) ([]KnownRoute, error) // SearchKnownRoutes returns known routes containing a path from source to destination hash. SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash string) ([]KnownRoute, error) + // GetNodeNeighbors returns the neighbors of a node ordered by most recently seen. + GetNodeNeighbors(ctx context.Context, nodeID uuid.UUID) ([]NodeNeighbor, error) } diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index aa51af9..3d41fa7 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -145,6 +145,10 @@ type DB interface { // UpsertKnownRoute stores a fully resolved path where all hops have high confidence. UpsertKnownRoute(ctx context.Context, nodeIDs []uuid.UUID, hashPrefix [][]byte, iata string, hopCount int32) error + + // UpsertNodeNeighbor records or updates a neighbor relationship between two nodes. + // nodeID is the advertising node, neighborID is the first-hop forwarder. + UpsertNodeNeighbor(ctx context.Context, nodeID, neighborID uuid.UUID, iata string) error } // ChannelKeyStore is a read-only view of the channel keys loaded from config. diff --git a/internal/ingest/side_effects.go b/internal/ingest/side_effects.go index 97575af..a744ccc 100644 --- a/internal/ingest/side_effects.go +++ b/internal/ingest/side_effects.go @@ -85,6 +85,21 @@ func (w *Worker) handlePayloadTypeSideEffects(ctx context.Context, packet *meshc log.Printf("ingest[%s]: db: upsert node failed: %v", w.cfg.BrokerName, err) return } + // if the advert was forwarded, the first hop is a neighbor + if packet.PathHashCount() > 0 && (advert.Type() == meshcore.AdvertTypeRepeater || advert.Type() == meshcore.AdvertTypeRoom) { + firstHop := packet.PathHashes() + if len(firstHop) > 0 { + resolved, err := w.db.ResolvePathHashes(ctx, iata, firstHop[:1]) + if err == nil { + key := hex.EncodeToString(firstHop[0]) + if entries := resolved[key]; len(entries) == 1 { + if err := w.db.UpsertNodeNeighbor(ctx, nodeID, entries[0].NodeID, iata); err != nil { + log.Printf("ingest[%s]: failed to upsert node neighbor: %v", w.cfg.BrokerName, err) + } + } + } + } + } if err := w.db.UpsertNodeIATA(ctx, nodeID, iata); err != nil { log.Printf("ingest[%s]: db: upsert node IATA failed: %v", w.cfg.BrokerName, err) }