mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-17 04:24:26 +00:00
Re-submission of #1625 (which was merged early, then reverted in #1626) — now with **all three round-1 reviews addressed** so it lands in one hardened state instead of as post-merge follow-ups. ## What Per-node **Reach** view: a standalone page (`#/nodes/{pubkey}/reach`) + a node-detail section + `GET /api/nodes/{pubkey}/reach`. It shows which nodes a node has a **stable two-way RF link** with, derived from raw `path_json` adjacency (a path travels origin→observer, so `[A,B]` ⇒ B heard A). A link is bidirectional when both directions have observations; the **bottleneck** (weaker direction) rates two-way reliability. Nodes are identified only by **unique 2–3 byte** path prefixes (1-byte collides → excluded). ## Review fixes folded in vs #1625 **Performance (Carmack):** hard scan LIMIT (200k) + modest prealloc; `json.Unmarshal` replaced by a single-pass `parsePathTokens` (100k-row scan 2.2M→1.3M allocs, 344→203ms); memoized resolver; size-hinted maps (attribution over 100k rows: 102 allocs); `context.Context` plumbed; cache `RWMutex` + evict-oldest (no full wipe); singleflight dedup; degree/rank from a 60s shared snapshot; bench rewritten (ReportAllocs, 1k/10k/100k, mixed-payload, isolated attribution). **Correctness/safety + tests (Independent + Kent Beck):** pubkey validation → 400; error logging instead of silent swallow (first_seen / degree / marshal→500 / discarded rows); `public_key=?` index use; canonical `PayloadADVERT`; `min()` builtin; documented cache-slice immutability; mux ordering comment. New tests: scanReachRows decode, 3-byte token branch, non-advert first-hop guard, observer SNR aggregation across rows, HTTP-level attribution (asserts non-zero we_hear/they_hear), 400/404/blacklist/cache-hit. **UI / a11y / Tufte:** in-map legend (tiers + thresholds); dropped the colour+width double-encoding (constant width, colour-only); colour-blind glyphs (●●●/●●/●) + tier title beside the bottleneck number; dark-theme `--link-*`; lighter table (horizontal rules, sentence-case headers); map built once + link layer updated in place on toggle (no flicker); time-range no longer flashes a loader; `destroy()` generation guard; statCard escaping; scoped `@media print` to `#nq-report`; `fieldset/legend` + `for/id` toggles; `aria-pressed` / `aria-live` / back-link `aria-label`; "distance (km)" + bottleneck tooltip + no-GPS note; inline styles → CSS; decorative emoji removed. **Docs:** api-spec documents the 5-min cache, 200k scan cap, and 400. ## Testing - `cmd/server` full suite green; reach unit + endpoint + bench all pass. - `eslint public/*.js` (no-undef) and the XSS-sink gate clean. - E2E updated: request status checks + exact (non-tautological) toggle assertions + hard map-render assert. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- ## TDD-history note (Kent Beck gate) This branch carries production + tests together, not a fabricated red→green sequence. That's deliberate: the branch was rebased onto upstream and the intermediate SHAs were squashed, so reconstructing a "failing-test-first" commit after the fact would be theatre, not evidence — and rewriting history to stage it would be dishonest. The behaviour is instead covered by a comprehensive, anti-tautological suite (directional attribution edges, 3-byte token branch, non-advert first-hop guard, observer SNR aggregation, HTTP-level attribution asserting non-zero counts, scan-cap truncation, zero-reach 200-not-404, companion mis-attribution, cache eviction). Requesting maintainer acceptance of the work on test *substance* rather than commit *choreography*; the net-new-UI exemption is not claimed for the server endpoint. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: meshcore-bot <bot@meshcore>
553 lines
15 KiB
Go
553 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"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"`
|
|
RejectedEdgesGeoFar uint64 `json:"rejected_edges_geo_far"` // edges dropped at build time by the geo-implausibility filter (#1228)
|
|
}
|
|
|
|
// ─── 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 {
|
|
opts := BuildOptions{MaxEdgeKm: DefaultMaxEdgeKm}
|
|
if s.cfg != nil {
|
|
opts.EnableLog = s.cfg.DebugAffinity
|
|
opts.MaxEdgeKm = s.cfg.NeighborMaxEdgeKm()
|
|
}
|
|
s.neighborGraph = BuildFromStoreWithOptions(s.store, opts)
|
|
} 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"])
|
|
if s.cfg.IsBlacklisted(pubkey) {
|
|
writeError(w, 404, "Not found")
|
|
return
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Defense-in-depth: deduplicate unresolved prefix entries that match
|
|
// resolved pubkey entries in the same neighbor set (fixes #698).
|
|
entries = dedupPrefixEntries(entries)
|
|
|
|
// 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"))
|
|
|
|
// #1481 P0-1: serve the default-shape request from the atomic-pointer
|
|
// snapshot maintained by the background recomputer (5 min cadence).
|
|
// Default shape: minCount=5, minScore=0.1, no region, no role.
|
|
if minCount == 5 && minScore == 0.1 && region == "" && roleFilter == "" {
|
|
if raw, age, ok := s.loadNeighborGraphCacheBytes(); ok {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("X-Cache-Age-Seconds", cacheAgeSecondsHeader(age))
|
|
w.Write(raw)
|
|
return
|
|
}
|
|
}
|
|
// #1483: also serve the (minCount=1, minScore=0) shape from cache —
|
|
// that's what the analytics UI tab fetches so it can client-side
|
|
// slider over the full edge set. Without this branch the user-
|
|
// visible analytics tab still hit the cold compute path.
|
|
if minCount == 1 && minScore == 0 && region == "" && roleFilter == "" {
|
|
if raw, age, ok := s.loadNeighborGraphCacheBytesUnfiltered(); ok {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("X-Cache-Age-Seconds", cacheAgeSecondsHeader(age))
|
|
w.Write(raw)
|
|
return
|
|
}
|
|
}
|
|
|
|
resp := s.computeNeighborGraphResponseDispatch(minCount, minScore, region, roleFilter)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// computeNeighborGraphResponseDispatch routes to the test-injected
|
|
// function when set, otherwise to the real pipeline. #1483 follow-up.
|
|
func (s *Server) computeNeighborGraphResponseDispatch(minCount int, minScore float64, region, roleFilter string) NeighborGraphResponse {
|
|
if s.computeNeighborGraphResponseFn != nil {
|
|
return s.computeNeighborGraphResponseFn(minCount, minScore, region, roleFilter)
|
|
}
|
|
return s.computeNeighborGraphResponse(minCount, minScore, region, roleFilter)
|
|
}
|
|
|
|
// buildDefaultNeighborGraphResponse builds the default-shape response
|
|
// used by the #1481 P0-1 recomputer. Goes through the dispatch so test
|
|
// hooks can inject failures (#1483 follow-up).
|
|
func (s *Server) buildDefaultNeighborGraphResponse() NeighborGraphResponse {
|
|
return s.computeNeighborGraphResponseDispatch(5, 0.1, "", "")
|
|
}
|
|
|
|
// computeNeighborGraphResponse does the full graph build + filter + score
|
|
// pipeline previously inlined in handleNeighborGraph.
|
|
func (s *Server) computeNeighborGraphResponse(minCount int, minScore float64, region, roleFilter string) NeighborGraphResponse {
|
|
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
|
|
}
|
|
}
|
|
|
|
// Filter blacklisted nodes from graph.
|
|
if s.cfg != nil && (s.cfg.IsBlacklisted(e.NodeA) || s.cfg.IsBlacklisted(e.NodeB)) {
|
|
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))
|
|
}
|
|
|
|
return NeighborGraphResponse{
|
|
Nodes: nodes,
|
|
Edges: filteredEdges,
|
|
Stats: GraphStats{
|
|
TotalNodes: len(nodes),
|
|
TotalEdges: len(filteredEdges),
|
|
AmbiguousEdges: ambiguousCount,
|
|
AvgClusterSize: avgCluster,
|
|
RejectedEdgesGeoFar: atomic.LoadUint64(&graph.RejectedEdgesGeoFar),
|
|
},
|
|
}
|
|
}
|
|
|
|
// ─── 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
|
|
}
|
|
|
|
// Enrich observer-only nodes: if an observer pubkey isn't already in the
|
|
// map (i.e. it's not also a repeater/companion), add it with role "observer".
|
|
if s.db != nil {
|
|
rows, err := s.db.conn.Query("SELECT id, name FROM observers")
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var id, name string
|
|
if rows.Scan(&id, &name) != nil {
|
|
continue
|
|
}
|
|
key := strings.ToLower(id)
|
|
if _, exists := m[key]; !exists {
|
|
m[key] = nodeInfo{PublicKey: id, Name: name, Role: "observer"}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fold in nodes.first_seen so callers (e.g. /api/nodes/{pk}/reach)
|
|
// don't need a per-request single-row SELECT. One bulk scan amortises
|
|
// across the whole map; missing/NULL rows are silently skipped (the
|
|
// node may be observer-only or pre-first_seen-schema).
|
|
fsRows, err := s.db.conn.Query("SELECT LOWER(public_key), COALESCE(first_seen,'') FROM nodes")
|
|
if err == nil {
|
|
defer fsRows.Close()
|
|
for fsRows.Next() {
|
|
var pk, fs string
|
|
if fsRows.Scan(&pk, &fs) != nil {
|
|
continue
|
|
}
|
|
if fs == "" {
|
|
continue
|
|
}
|
|
if entry, ok := m[pk]; ok {
|
|
entry.FirstSeen = fs
|
|
m[pk] = entry
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return m
|
|
}
|
|
|
|
// dedupPrefixEntries merges unresolved prefix entries with resolved pubkey entries
|
|
// where the prefix is a prefix of the resolved pubkey. Defense-in-depth for #698.
|
|
func dedupPrefixEntries(entries []NeighborEntry) []NeighborEntry {
|
|
if len(entries) < 2 {
|
|
return entries
|
|
}
|
|
|
|
// Mark indices of unresolved entries to remove after merging.
|
|
remove := make(map[int]bool)
|
|
|
|
for i := range entries {
|
|
if entries[i].Pubkey != nil {
|
|
continue // only check unresolved (no pubkey)
|
|
}
|
|
prefix := strings.ToLower(entries[i].Prefix)
|
|
if prefix == "" {
|
|
continue
|
|
}
|
|
// Find all resolved entries matching this prefix.
|
|
matchIdx := -1
|
|
matchCount := 0
|
|
for j := range entries {
|
|
if i == j || entries[j].Pubkey == nil {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(strings.ToLower(*entries[j].Pubkey), prefix) {
|
|
matchIdx = j
|
|
matchCount++
|
|
}
|
|
}
|
|
// Only merge when exactly one resolved entry matches — ambiguous
|
|
// prefixes that match multiple resolved neighbors must not be
|
|
// arbitrarily assigned to one of them.
|
|
if matchCount != 1 {
|
|
continue
|
|
}
|
|
j := matchIdx
|
|
|
|
// Merge counts from unresolved into resolved.
|
|
entries[j].Count += entries[i].Count
|
|
|
|
// Preserve higher LastSeen.
|
|
if entries[i].LastSeen > entries[j].LastSeen {
|
|
entries[j].LastSeen = entries[i].LastSeen
|
|
}
|
|
|
|
// Merge observers.
|
|
obsSet := make(map[string]bool)
|
|
for _, o := range entries[j].Observers {
|
|
obsSet[o] = true
|
|
}
|
|
for _, o := range entries[i].Observers {
|
|
obsSet[o] = true
|
|
}
|
|
entries[j].Observers = observerList(obsSet)
|
|
|
|
remove[i] = true
|
|
}
|
|
|
|
if len(remove) == 0 {
|
|
return entries
|
|
}
|
|
|
|
result := make([]NeighborEntry, 0, len(entries)-len(remove))
|
|
for i, e := range entries {
|
|
if !remove[i] {
|
|
result = append(result, e)
|
|
}
|
|
}
|
|
return result
|
|
}
|