diff --git a/cmd/server/routes.go b/cmd/server/routes.go index a8fb3b98..4cf7be67 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -3568,6 +3568,35 @@ func (s *Server) handleScopeStats(w http.ResponseWriter, r *http.Request) { } sort.Slice(repeaters, func(i, j int) bool { return repeaters[i].Count > repeaters[j].Count }) resp.RepeatersByRegion = repeaters + + // Bridge repeaters: invert byRegion into pubkey -> regions and + // keep only repeaters relaying for MORE than one region — the + // mesh's literal backbone nodes connecting separate regional + // communities. Same `names` existence-filter as above. + pubkeyRegions := make(map[string][]string) + for region, pks := range byRegion { + for _, pk := range pks { + if _, ok := names[pk]; !ok { + continue + } + pubkeyRegions[pk] = append(pubkeyRegions[pk], region) + } + } + bridges := make([]BridgeRepeater, 0) + for pk, regions := range pubkeyRegions { + if len(regions) < 2 { + continue + } + sort.Strings(regions) + bridges = append(bridges, BridgeRepeater{Name: names[pk], PublicKey: pk, Regions: regions, Count: len(regions)}) + } + sort.Slice(bridges, func(i, j int) bool { + if bridges[i].Count != bridges[j].Count { + return bridges[i].Count > bridges[j].Count + } + return bridges[i].Name < bridges[j].Name + }) + resp.BridgeRepeaters = bridges } } diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index 925c0c8a..7c3dd04b 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -4424,6 +4424,74 @@ func TestHandleScopeStats_RepeatersByRegion(t *testing.T) { } } +// TestHandleScopeStats_BridgeRepeaters verifies the RepeatersByRegion +// inversion: a repeater that relayed traffic for TWO distinct regions must +// appear in BridgeRepeaters with both region names, while a repeater that +// only ever relayed one region must be excluded. +func TestHandleScopeStats_BridgeRepeaters(t *testing.T) { + srv, _ := setupTestServer(t) + if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil { + t.Fatalf("add scope_name column: %v", err) + } + srv.db.hasScopeName = true + + if _, err := srv.db.conn.Exec( + `INSERT INTO nodes (public_key, name, role) VALUES ('bbbbccdd0011', 'BridgeNode', 'repeater')`, + ); err != nil { + t.Fatalf("seed bridge node: %v", err) + } + if _, err := srv.db.conn.Exec( + `INSERT INTO nodes (public_key, name, role) VALUES ('ccccccdd0011', 'SingleRegionNode', 'repeater')`, + ); err != nil { + t.Fatalf("seed single-region node: %v", err) + } + + pt5 := 5 + txBelgium := &StoreTx{ID: 1, Hash: "tx1", FirstSeen: time.Now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano), PayloadType: &pt5, ScopeName: "#belgium"} + txFrance := &StoreTx{ID: 2, Hash: "tx2", FirstSeen: time.Now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano), PayloadType: &pt5, ScopeName: "#france"} + txBelgium2 := &StoreTx{ID: 3, Hash: "tx3", FirstSeen: time.Now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano), PayloadType: &pt5, ScopeName: "#belgium"} + + srv.store = &PacketStore{ + byPathHop: map[string][]*StoreTx{ + "bbbbccdd0011": {txBelgium, txFrance}, // relayed BOTH regions — a bridge + "ccccccdd0011": {txBelgium2}, // relayed only #belgium — not a bridge + }, + } + + req := httptest.NewRequest("GET", "/api/scope-stats?window=24h", nil) + w := httptest.NewRecorder() + srv.handleScopeStats(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var resp ScopeStatsResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.BridgeRepeaters) != 1 { + t.Fatalf("bridgeRepeaters = %v, want 1 entry", resp.BridgeRepeaters) + } + br := resp.BridgeRepeaters[0] + if br.Name != "BridgeNode" || br.PublicKey != "bbbbccdd0011" || br.Count != 2 { + t.Errorf("bridgeRepeaters[0] = %+v, want {BridgeNode bbbbccdd0011 2 [...]}", br) + } + wantRegions := []string{"#belgium", "#france"} + if len(br.Regions) != len(wantRegions) { + t.Fatalf("bridgeRepeaters[0].Regions = %v, want %v", br.Regions, wantRegions) + } + for i, r := range wantRegions { + if br.Regions[i] != r { + t.Errorf("bridgeRepeaters[0].Regions[%d] = %q, want %q", i, br.Regions[i], r) + } + } + for _, b := range resp.BridgeRepeaters { + if b.PublicKey == "ccccccdd0011" { + t.Errorf("SingleRegionNode should not appear in bridgeRepeaters (only relayed 1 region)") + } + } +} + // TestHandleScopeStats_OriginatingNodesByRegion verifies the complementary // breakdown to RepeatersByRegion: nodes whose OWN default_scope (#899) is a // given region, i.e. nodes actually configured with/running that region diff --git a/cmd/server/types.go b/cmd/server/types.go index 618d44b2..5f0743e2 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -154,6 +154,11 @@ type ScopeStatsResponse struct { // channel chat specifically (payload_type=5), window-scoped like // Summary above — see ChannelScopeStats doc. ChannelMessages *ChannelScopeStats `json:"channelMessages,omitempty"` + // BridgeRepeaters is the RepeatersByRegion data inverted: repeaters + // that have relayed traffic for MORE than one region are the mesh's + // literal backbone nodes connecting separate regional communities. + // All-time, like RepeatersByRegion (same source data, same caveats). + BridgeRepeaters []BridgeRepeater `json:"bridgeRepeaters,omitempty"` } type RepeaterRef struct { @@ -167,6 +172,13 @@ type ScopeRegionRepeaters struct { Repeaters []RepeaterRef `json:"repeaters"` } +type BridgeRepeater struct { + Name string `json:"name"` + PublicKey string `json:"publicKey"` + Regions []string `json:"regions"` + Count int `json:"count"` +} + // ─── Health ──────────────────────────────────────────────────────────────────── type MemoryStats struct { diff --git a/public/analytics.js b/public/analytics.js index 42b282ef..c7bf320c 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -4493,6 +4493,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData = '
' + '
' + '
' + + '
' + '
'; // Attach window-button click listeners (once) @@ -4711,6 +4712,34 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData = 'All-time, not limited to the window above — which repeaters have relayed traffic carrying each region scope. A region carried by only 1 repeater is a single point of failure for that area.', d.repeatersByRegion, 'repeater'); + // Bridge repeaters: RepeatersByRegion inverted — repeaters relaying + // for MORE than one region are the mesh's literal backbone nodes. + var bridgeEl = document.getElementById('scopes-bridges'); + if (bridgeEl) { + var bridges = d.bridgeRepeaters || []; + if (bridges.length > 0) { + var bridgeRows = bridges.map(function(b) { + var regionList = b.regions.map(function(r) { return '' + esc(r) + ''; }).join(', '); + return '' + + '' + esc(b.name) + '' + + '' + b.count + '' + + '' + regionList + '' + + ''; + }).join(''); + bridgeEl.innerHTML = + '

Bridge Repeaters

' + + '

' + + 'All-time — repeaters that have relayed traffic for more than one region. These connect otherwise-separate regional communities; losing one can split the mesh\'s regional coverage.' + + '

' + + '' + + '' + + '' + bridgeRows + '' + + '
Repeater# RegionsRegions
'; + } else { + bridgeEl.innerHTML = ''; + } + } + renderRegionNodeGroups('scopes-origin-nodes', 'Nodes Running This Region', 'All-time — nodes whose OWN configured scope is this region (not just relaying it for others). This is a much smaller, more specific set than "Repeaters by Region" above.', d.originatingNodesByRegion, 'node');