feat: node neighbors

limited to repeaters and room servers

stores high confidence matches from the nodes advert
packet path first hops
This commit is contained in:
Enot (ded) Skelly
2026-06-08 09:36:20 -07:00
parent 533225b46a
commit 7d25b8480f
14 changed files with 901 additions and 12 deletions
+7 -4
View File
@@ -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=<code>&type=<str>&broker=<name>&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
+17
View File
@@ -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
-- ============================================================
+37
View File
@@ -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
}
+23
View File
@@ -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
-- ============================================================
+9
View File
@@ -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"`
+78
View File
@@ -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)
+248
View File
@@ -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": {
+248
View File
@@ -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": {
+163
View File
@@ -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:
+27
View File
@@ -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)
}
}
+23 -8
View File
@@ -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.
+2
View File
@@ -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)
}
+4
View File
@@ -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.
+15
View File
@@ -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)
}