mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-17 06:04:17 +00:00
Rebase of #1881 by @SaarMesh-Bot onto current master. Their three commits are preserved, two of them cherry-picked with authorship intact; the sweep itself had to be regenerated. Opened as a new PR rather than force-pushing their branch. Closes #1881 once merged. Addresses parts 1 and 3 of #1859; part 2 landed as #1937. ## Why regenerated rather than merged The sweep in #1881 was cut on 2026-09-02 07:13 and roughly forty PRs landed after it, so it went `CONFLICTING/DIRTY`. Re-running `gofmt` on current master is cheaper and less error-prone than resolving 72 conflicts that are all whitespace. The drift it fixes also grew in the meantime: 66 files now, against 72 then, but spread differently. ## The three commits 1. **`style(#1859)`** — `gofmt -w` across the 14 modules. 66 files. 2. **`test(#1859)`** — @SaarMesh-Bot's fix for the one `go vet` copylocks finding, `cmd/ingestor/coverage_boost_test.go`: the range variable copied a `Config` embedding `sync.Once`. Cherry-picked unchanged. 3. **`ci(#1859)`** — @SaarMesh-Bot's CI step that fails on gofmt drift or vet findings, plus `.git-blame-ignore-revs`. Cherry-picked with one change, noted in the commit message: the ignore file pointed at `04bc80ee`, the sweep commit on their branch, which does not exist on this base and would make `git blame --ignore-revs-file` error. Repointed at `d3a02599`, the sweep here. ## Verification The claim "formatting only" is checked twice rather than asserted: - Every changed file is byte-identical to `gofmt(previous content)`. 0 of 66 deviate. - With line comments and all whitespace stripped, 0 of 66 files differ, so no code outside comments changed. 14 of the 66 also show doc-comment reflow. Since Go 1.19 `gofmt` re-indents indented comment blocks to tabs and inserts a blank comment line before them; the behavior matrix above `resolveHopWithContext` in `cmd/ingestor/path_resolver.go` is a clear example. That is gofmt's own output, not an edit, but it is worth naming because it makes the diff look larger than "whitespace" suggests. The gate was run locally exactly as the workflow runs it: `gofmt` clean, and `go vet` clean in all 14 modules, including `cmd/ingestor` which is what commit 2 fixes. Suites: `cmd/server` ok (80.7s), `internal/packetpath` ok (2.3s), `cmd/ingestor` passes except `TestWriteStatsAtomic_SymlinkAtDestIsReplaced`, which fails identically on bare master with "A required privilege is not held by the client" (Windows symlink privilege on my host, not code). ## Sequencing This should go last in the queue. The sweep touches 66 files, so merging it before the remaining open Go PRs gives each of them a conflict about nothing but formatting. After it lands the gate is active, and any PR with drift fails CI until it runs `gofmt -w`. Excluded from the sweep: the misnamed `Dockerfile.go`, which is a Dockerfile that gofmt cannot parse (the workflow excludes it too), and `docs/DEPLOYMENT.md`, which a case-insensitive filesystem surfaces as a spurious modification against `docs/deployment.md` and is unrelated. --------- Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
570 lines
16 KiB
Go
570 lines
16 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"`
|
|
// CountsByMode breaks Count down by observation hash-prefix mode in bytes
|
|
// (1, 2, 4, 6). Lets the frontend weight confidence by ambiguity rather
|
|
// than treating every sighting as equal evidence. Issue #1638.
|
|
CountsByMode map[int]int `json:"counts_by_mode,omitempty"`
|
|
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()
|
|
opts.PathTrust = s.cfg.PathTrust
|
|
}
|
|
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
|
|
}
|
|
if s.isPubkeyHidden(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,
|
|
CountsByMode: copyCountsByMode(e.CountsByMode),
|
|
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
|
|
}
|
|
// #1181: also drop edges touching a hidden-prefix node.
|
|
if s.isPubkeyHidden(e.NodeA) || s.isPubkeyHidden(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 ───────────────────────────────────────────────────────────────────
|
|
|
|
// copyCountsByMode returns a shallow copy of the per-mode count map so the
|
|
// API response doesn't share state with the live in-memory edge. Returns
|
|
// nil for empty/nil input so omitempty drops the field from legacy payloads.
|
|
func copyCountsByMode(m map[int]int) map[int]int {
|
|
if len(m) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[int]int, len(m))
|
|
for k, v := range m {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
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
|
|
}
|
|
// FirstSeen is folded into getAllNodes (and therefore into the 30s
|
|
// node cache) so callers like /api/nodes/{pk}/reach get the field
|
|
// without a per-request SELECT — fixes #1627 r3 regression.
|
|
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"}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
if entries[i].CountsByMode != nil {
|
|
if entries[j].CountsByMode == nil {
|
|
entries[j].CountsByMode = make(map[int]int)
|
|
}
|
|
for m, c := range entries[i].CountsByMode {
|
|
entries[j].CountsByMode[m] += c
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|