Files
meshcore-analyzer/cmd/server/neighbor_api.go
T
efiten b587f20d1c feat: add distance column to neighbor table in node details (#617)
Closes #616

## What

Adds a **Distance** column to the neighbor table on the node detail
page.

When both the viewed node and a neighbor have GPS coordinates recorded,
the table shows the haversine distance between them (e.g. `3.2 km`).
When either node lacks GPS, the cell shows `—`.

## Changes

**Backend** (`cmd/server/neighbor_api.go`):
- Added `distance_km *float64` (omitempty) to `NeighborEntry`
- In `handleNodeNeighbors`: look up source node coords from `nodeMap`,
then for each resolved (non-ambiguous) neighbor with GPS, compute
`haversineKm` and set the field

**Frontend** (`public/nodes.js`):
- Added `Distance` column header between Last Seen and Conf
- Cell renders `X.X km` or `—` (muted) when unavailable

**Tests** (`cmd/server/neighbor_api_test.go`):
- `TestNeighborAPI_DistanceKm_WithGPS`: two nodes with real coords →
`distance_km` is positive
- `TestNeighborAPI_DistanceKm_NoGPS`: two nodes at 0,0 → `distance_km`
is nil

## Verification

Test at **https://staging.on8ar.eu** — navigate to any node detail page
and scroll to the Neighbors section. Nodes with GPS coordinates show a
distance; those without show `—`.
2026-04-05 12:33:23 -07:00

374 lines
9.6 KiB
Go

package main
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/gorilla/mux"
)
// ─── Neighbor API response types ───────────────────────────────────────────────
type NeighborResponse struct {
Node string `json:"node"`
Neighbors []NeighborEntry `json:"neighbors"`
TotalObservations int `json:"total_observations"`
}
type NeighborEntry struct {
Pubkey *string `json:"pubkey"`
Prefix string `json:"prefix"`
Name *string `json:"name"`
Role *string `json:"role"`
Count int `json:"count"`
Score float64 `json:"score"`
FirstSeen string `json:"first_seen"`
LastSeen string `json:"last_seen"`
AvgSNR *float64 `json:"avg_snr"`
DistanceKm *float64 `json:"distance_km,omitempty"`
Observers []string `json:"observers"`
Ambiguous bool `json:"ambiguous"`
Unresolved bool `json:"unresolved,omitempty"`
Candidates []CandidateEntry `json:"candidates,omitempty"`
}
type CandidateEntry struct {
Pubkey string `json:"pubkey"`
Name string `json:"name"`
Role string `json:"role"`
}
type NeighborGraphResponse struct {
Nodes []GraphNode `json:"nodes"`
Edges []GraphEdge `json:"edges"`
Stats GraphStats `json:"stats"`
}
type GraphNode struct {
Pubkey string `json:"pubkey"`
Name string `json:"name"`
Role string `json:"role"`
NeighborCount int `json:"neighbor_count"`
}
type GraphEdge struct {
Source string `json:"source"`
Target string `json:"target"`
Weight int `json:"weight"`
Score float64 `json:"score"`
Bidirectional bool `json:"bidirectional"`
AvgSNR *float64 `json:"avg_snr"`
Ambiguous bool `json:"ambiguous"`
}
type GraphStats struct {
TotalNodes int `json:"total_nodes"`
TotalEdges int `json:"total_edges"`
AmbiguousEdges int `json:"ambiguous_edges"`
AvgClusterSize float64 `json:"avg_cluster_size"`
}
// ─── Graph accessor on Server ──────────────────────────────────────────────────
// getNeighborGraph returns the current neighbor graph, rebuilding if stale.
func (s *Server) getNeighborGraph() *NeighborGraph {
s.neighborMu.Lock()
defer s.neighborMu.Unlock()
if s.neighborGraph == nil || s.neighborGraph.IsStale() {
if s.store != nil {
debugLog := s.cfg != nil && s.cfg.DebugAffinity
s.neighborGraph = BuildFromStoreWithLog(s.store, debugLog)
} else {
s.neighborGraph = NewNeighborGraph()
}
}
return s.neighborGraph
}
// ─── Handlers ──────────────────────────────────────────────────────────────────
func (s *Server) handleNodeNeighbors(w http.ResponseWriter, r *http.Request) {
pubkey := strings.ToLower(mux.Vars(r)["pubkey"])
minCount := 1
if v := r.URL.Query().Get("min_count"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
minCount = n
}
}
minScore := 0.0
if v := r.URL.Query().Get("min_score"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
minScore = f
}
}
includeAmbiguous := true
if v := r.URL.Query().Get("include_ambiguous"); v == "false" {
includeAmbiguous = false
}
graph := s.getNeighborGraph()
edges := graph.Neighbors(pubkey)
now := time.Now()
// Build node info lookup for names/roles/coordinates.
nodeMap := s.buildNodeInfoMap()
// Look up the queried node's GPS coordinates for distance computation.
var srcInfo nodeInfo
if nodeMap != nil {
srcInfo = nodeMap[pubkey]
}
var entries []NeighborEntry
totalObs := 0
for _, e := range edges {
score := e.Score(now)
if e.Count < minCount || score < minScore {
continue
}
if e.Ambiguous && !includeAmbiguous {
continue
}
totalObs += e.Count
// Determine the "other" node (neighbor of the queried pubkey).
neighborPK := e.NodeA
if strings.EqualFold(neighborPK, pubkey) {
neighborPK = e.NodeB
}
entry := NeighborEntry{
Prefix: e.Prefix,
Count: e.Count,
Score: score,
FirstSeen: e.FirstSeen.UTC().Format(time.RFC3339),
LastSeen: e.LastSeen.UTC().Format(time.RFC3339),
Ambiguous: e.Ambiguous,
Observers: observerList(e.Observers),
}
if e.SNRCount > 0 {
avg := e.AvgSNR()
entry.AvgSNR = &avg
}
if e.Ambiguous {
if len(e.Candidates) == 0 {
entry.Unresolved = true
}
for _, cpk := range e.Candidates {
ce := CandidateEntry{Pubkey: cpk}
if info, ok := nodeMap[strings.ToLower(cpk)]; ok {
ce.Name = info.Name
ce.Role = info.Role
}
entry.Candidates = append(entry.Candidates, ce)
}
} else if neighborPK != "" {
entry.Pubkey = &neighborPK
if info, ok := nodeMap[strings.ToLower(neighborPK)]; ok {
entry.Name = &info.Name
entry.Role = &info.Role
if srcInfo.HasGPS && info.HasGPS {
d := haversineKm(srcInfo.Lat, srcInfo.Lon, info.Lat, info.Lon)
entry.DistanceKm = &d
}
}
}
entries = append(entries, entry)
}
// Sort by score descending.
sort.Slice(entries, func(i, j int) bool {
return entries[i].Score > entries[j].Score
})
if entries == nil {
entries = []NeighborEntry{}
}
resp := NeighborResponse{
Node: pubkey,
Neighbors: entries,
TotalObservations: totalObs,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func (s *Server) handleNeighborGraph(w http.ResponseWriter, r *http.Request) {
minCount := 5
if v := r.URL.Query().Get("min_count"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
minCount = n
}
}
minScore := 0.1
if v := r.URL.Query().Get("min_score"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
minScore = f
}
}
region := r.URL.Query().Get("region")
roleFilter := strings.ToLower(r.URL.Query().Get("role"))
graph := s.getNeighborGraph()
allEdges := graph.AllEdges()
now := time.Now()
// Resolve region observers if filtering.
var regionObs map[string]bool
if region != "" && s.store != nil {
regionObs = s.store.resolveRegionObservers(region)
}
nodeMap := s.buildNodeInfoMap()
nodeSet := make(map[string]bool)
var filteredEdges []GraphEdge
ambiguousCount := 0
for _, e := range allEdges {
score := e.Score(now)
if e.Count < minCount || score < minScore {
continue
}
// Role filter: at least one endpoint must match the role.
if roleFilter != "" && nodeMap != nil {
aInfo, aOK := nodeMap[strings.ToLower(e.NodeA)]
bInfo, bOK := nodeMap[strings.ToLower(e.NodeB)]
aMatch := aOK && strings.EqualFold(aInfo.Role, roleFilter)
bMatch := bOK && strings.EqualFold(bInfo.Role, roleFilter)
if !aMatch && !bMatch {
continue
}
}
// Region filter: at least one observer must be in the region.
if regionObs != nil {
match := false
for obs := range e.Observers {
if regionObs[obs] {
match = true
break
}
}
if !match {
continue
}
}
ge := GraphEdge{
Source: e.NodeA,
Target: e.NodeB,
Weight: e.Count,
Score: score,
Bidirectional: true,
Ambiguous: e.Ambiguous,
}
if e.SNRCount > 0 {
avg := e.AvgSNR()
ge.AvgSNR = &avg
}
if e.Ambiguous {
ambiguousCount++
// For ambiguous edges, use prefix as target.
if e.NodeB == "" {
ge.Target = "prefix:" + e.Prefix
}
}
filteredEdges = append(filteredEdges, ge)
// Track nodes.
if e.NodeA != "" && !strings.HasPrefix(e.NodeA, "prefix:") {
nodeSet[e.NodeA] = true
}
if e.NodeB != "" && !strings.HasPrefix(e.NodeB, "prefix:") {
nodeSet[e.NodeB] = true
}
}
// Build node list.
// Count neighbors per node from filtered edges.
neighborCounts := make(map[string]int)
for _, ge := range filteredEdges {
neighborCounts[ge.Source]++
neighborCounts[ge.Target]++
}
var nodes []GraphNode
for pk := range nodeSet {
gn := GraphNode{Pubkey: pk, NeighborCount: neighborCounts[pk]}
if info, ok := nodeMap[strings.ToLower(pk)]; ok {
gn.Name = info.Name
gn.Role = info.Role
}
nodes = append(nodes, gn)
}
if filteredEdges == nil {
filteredEdges = []GraphEdge{}
}
if nodes == nil {
nodes = []GraphNode{}
}
avgCluster := 0.0
if len(nodes) > 0 {
avgCluster = float64(len(filteredEdges)*2) / float64(len(nodes))
}
resp := NeighborGraphResponse{
Nodes: nodes,
Edges: filteredEdges,
Stats: GraphStats{
TotalNodes: len(nodes),
TotalEdges: len(filteredEdges),
AmbiguousEdges: ambiguousCount,
AvgClusterSize: avgCluster,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// ─── Helpers ───────────────────────────────────────────────────────────────────
func observerList(m map[string]bool) []string {
if len(m) == 0 {
return []string{}
}
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// buildNodeInfoMap returns a map of lowercase pubkey → nodeInfo for name/role lookups.
func (s *Server) buildNodeInfoMap() map[string]nodeInfo {
if s.store == nil {
return nil
}
nodes, _ := s.store.getCachedNodesAndPM()
m := make(map[string]nodeInfo, len(nodes))
for _, n := range nodes {
m[strings.ToLower(n.PublicKey)] = n
}
return m
}