mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-09-01 16:48:19 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
-- ============================================================
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user