diff --git a/cmd/server/coverage_test.go b/cmd/server/coverage_test.go index 3c0d34e9..2cbc4c20 100644 --- a/cmd/server/coverage_test.go +++ b/cmd/server/coverage_test.go @@ -2198,6 +2198,53 @@ func TestStoreGetAnalyticsHashSizes(t *testing.T) { }) } +func TestHashSizesDistributionByRepeatersFiltersRole(t *testing.T) { + db := setupRichTestDB(t) + defer db.Close() + store := NewPacketStore(db, nil) + store.Load() + + result := store.GetAnalyticsHashSizes("") + + // distributionByRepeaters should only count repeater nodes. + // Rich test DB: aabbccdd11223344 = repeater (hash size 2), eeff00112233aabb = companion (hash size 3). + dbr, ok := result["distributionByRepeaters"].(map[string]int) + if !ok { + t.Fatal("expected distributionByRepeaters map") + } + // Only the repeater node should be counted. + if dbr["3"] != 0 { + t.Errorf("distributionByRepeaters[3] = %d, want 0 (companion should be excluded)", dbr["3"]) + } + if dbr["2"] != 1 { + t.Errorf("distributionByRepeaters[2] = %d, want 1 (repeater)", dbr["2"]) + } + + // multiByteNodes should include role field for frontend filtering. + mbn, ok := result["multiByteNodes"].([]map[string]interface{}) + if !ok { + t.Fatal("expected multiByteNodes slice") + } + for _, node := range mbn { + if _, hasRole := node["role"]; !hasRole { + t.Errorf("multiByteNodes entry missing 'role' field: %v", node) + } + } + // Verify companion is included in multiByteNodes (it's multi-byte) with correct role. + foundCompanion := false + for _, node := range mbn { + if node["pubkey"] == "eeff00112233aabb" { + foundCompanion = true + if node["role"] != "companion" { + t.Errorf("companion node role = %v, want 'companion'", node["role"]) + } + } + } + if !foundCompanion { + t.Error("expected companion node in multiByteNodes (multi-byte adopters should include all roles)") + } +} + func TestStoreGetAnalyticsSubpaths(t *testing.T) { db := setupRichTestDB(t) defer db.Close() diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index 88be48f9..f1d57cec 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -2451,6 +2451,7 @@ func TestHashAnalyticsZeroHopAdvert(t *testing.T) { pk := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'ZeroHop', 'repeater')", pk) + store.InvalidateNodeCache() decoded := `{"name":"ZeroHop","pubKey":"` + pk + `"}` // header 0x05 → routeType=1 (FLOOD), pathByte=0x00 → hashSize=1 @@ -2504,6 +2505,11 @@ func TestAnalyticsHashSizeSameNameDifferentPubkey(t *testing.T) { pk1 := "aaaa111122223333444455556666777788889999aaaabbbbccccddddeeee1111" pk2 := "aaaa111122223333444455556666777788889999aaaabbbbccccddddeeee2222" + // Insert both nodes as repeaters so they appear in distributionByRepeaters. + db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'SameName', 'repeater')", pk1) + db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'SameName', 'repeater')", pk2) + store.InvalidateNodeCache() + decoded1 := `{"name":"SameName","pubKey":"` + pk1 + `"}` decoded2 := `{"name":"SameName","pubKey":"` + pk2 + `"}` diff --git a/cmd/server/store.go b/cmd/server/store.go index d46fe5e7..06bc81f2 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -4731,7 +4731,13 @@ func (s *PacketStore) computeAnalyticsHashSizes(region string) map[string]interf regionObs = s.resolveRegionObservers(region) } - _, pm := s.getCachedNodesAndPM() + allNodes, pm := s.getCachedNodesAndPM() + + // Build pubkey→role map for filtering by node type. + nodeRoleByPK := make(map[string]string, len(allNodes)) + for _, n := range allNodes { + nodeRoleByPK[n.PublicKey] = n.Role + } distribution := map[string]int{"1": 0, "2": 0, "3": 0} byHour := map[string]map[string]int{} @@ -4808,9 +4814,11 @@ func (s *PacketStore) computeAnalyticsHashSizes(region string) map[string]interf } } if byNode[pk] == nil { + role := nodeRoleByPK[pk] // empty if unknown byNode[pk] = map[string]interface{}{ "hashSize": hashSize, "packets": 0, "lastSeen": tx.FirstSeen, "name": name, + "role": role, } } byNode[pk]["packets"] = byNode[pk]["packets"].(int) + 1 @@ -4902,7 +4910,7 @@ func (s *PacketStore) computeAnalyticsHashSizes(region string) map[string]interf multiByteNodes = append(multiByteNodes, map[string]interface{}{ "name": data["name"], "hashSize": data["hashSize"], "packets": data["packets"], "lastSeen": data["lastSeen"], - "pubkey": pk, + "pubkey": pk, "role": data["role"], }) } } @@ -4910,9 +4918,13 @@ func (s *PacketStore) computeAnalyticsHashSizes(region string) map[string]interf return multiByteNodes[i]["packets"].(int) > multiByteNodes[j]["packets"].(int) }) - // Distribution by repeaters: count unique nodes per hash size + // Distribution by repeaters: count unique REPEATER nodes per hash size distributionByRepeaters := map[string]int{"1": 0, "2": 0, "3": 0} for _, data := range byNode { + role, _ := data["role"].(string) + if !strings.Contains(strings.ToLower(role), "repeater") { + continue + } hs := data["hashSize"].(int) key := strconv.Itoa(hs) distributionByRepeaters[key]++