feat: bridge-repeaters breakdown on the Scopes analytics tab

Adds bridgeRepeaters to /api/scope-stats: RepeatersByRegion inverted
into pubkey -> regions, keeping only repeaters that have relayed
traffic for MORE than one region. These are the mesh's literal
backbone nodes connecting otherwise-separate regional communities —
losing one is a more consequential failure than losing a
single-region repeater.

Computed inline while building RepeatersByRegion (reuses the same
byRegion map and role-filtered names lookup, no extra queries).

Frontend renders a small table under "Repeaters by Region": repeater
name (linked to its node detail page), region count, and the region
list.
This commit is contained in:
dborup
2026-07-18 12:55:36 +02:00
parent c74a005247
commit 1aed3ee5c8
4 changed files with 138 additions and 0 deletions
+29
View File
@@ -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
}
}
+68
View File
@@ -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
+12
View File
@@ -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 {