diff --git a/cmd/server/db.go b/cmd/server/db.go index 12a1858f..6aa14293 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -2155,6 +2155,39 @@ func (db *DB) GetNodeLocationsByKeys(keys []string) map[string]map[string]interf return result } +// GetNodeNamesByKeys batch-resolves pubkey -> display name for the given +// keys. Missing/unnamed nodes are simply absent from the result map — the +// caller falls back to a truncated pubkey. Used to label repeaters in the +// scope-stats "repeaters by region" breakdown without pulling full node +// rows for a set that's typically small (repeaters that have transported +// at least one scoped packet). +func (db *DB) GetNodeNamesByKeys(keys []string) map[string]string { + result := make(map[string]string) + if len(keys) == 0 { + return result + } + placeholders := make([]string, len(keys)) + args := make([]interface{}, len(keys)) + for i, k := range keys { + placeholders[i] = "?" + args[i] = strings.ToLower(k) + } + query := "SELECT public_key, name FROM nodes WHERE public_key IN (" + strings.Join(placeholders, ",") + ")" + rows, err := db.conn.Query(query, args...) + if err != nil { + return result + } + defer rows.Close() + for rows.Next() { + var pk string + var name sql.NullString + if rows.Scan(&pk, &name) == nil && name.Valid && name.String != "" { + result[strings.ToLower(pk)] = name.String + } + } + return result +} + // QueryMultiNodePackets returns transmissions referencing any of the given pubkeys. func (db *DB) QueryMultiNodePackets(pubkeys []string, limit, offset int, order, since, until string) (*PacketResult, error) { if len(pubkeys) == 0 { diff --git a/cmd/server/routes.go b/cmd/server/routes.go index f2f3fcc4..10baf3dc 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -3519,6 +3519,48 @@ func (s *Server) handleScopeStats(w http.ResponseWriter, r *http.Request) { } } + if s.store != nil { + // windowHours is irrelevant to TransportedScopes (explicitly + // not time-windowed — see RepeaterRelayInfo doc comment) and the + // map is served from the 5-min background-recomputed cache + // regardless of the value passed once warm, so reusing whatever + // handleNodes uses costs nothing extra here. + relayWindow := s.cfg.GetHealthThresholds().RelayActiveHours + relayMap := s.store.GetRepeaterRelayInfoMap(relayWindow) + + byRegion := make(map[string][]string) // region -> pubkeys + pubkeySet := make(map[string]bool) + for pk, info := range relayMap { + for _, region := range info.TransportedScopes { + byRegion[region] = append(byRegion[region], pk) + pubkeySet[pk] = true + } + } + if len(pubkeySet) > 0 { + pubkeys := make([]string, 0, len(pubkeySet)) + for pk := range pubkeySet { + pubkeys = append(pubkeys, pk) + } + names := s.db.GetNodeNamesByKeys(pubkeys) + + repeaters := make([]ScopeRegionRepeaters, 0, len(byRegion)) + for region, pks := range byRegion { + refs := make([]RepeaterRef, 0, len(pks)) + for _, pk := range pks { + name := names[pk] + if name == "" { + name = pk + } + refs = append(refs, RepeaterRef{Name: name, PublicKey: pk}) + } + sort.Slice(refs, func(i, j int) bool { return refs[i].Name < refs[j].Name }) + repeaters = append(repeaters, ScopeRegionRepeaters{Region: region, Count: len(refs), Repeaters: refs}) + } + sort.Slice(repeaters, func(i, j int) bool { return repeaters[i].Count > repeaters[j].Count }) + resp.RepeatersByRegion = repeaters + } + } + s.scopeStatsMu.Lock() if s.scopeStatsCache == nil { s.scopeStatsCache = make(map[string]*ScopeStatsResponse) diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index eacdc687..265f0130 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -4297,6 +4297,58 @@ func TestHandleScopeStats_UnusedRegions(t *testing.T) { } } +// TestHandleScopeStats_RepeatersByRegion verifies the "which repeaters +// transported this region" breakdown, sourced from the same bulk relay-info +// cache the Nodes page uses (GetRepeaterRelayInfoMap / TransportedScopes, +// #1751) and cross-referenced against nodes.name for display. +func TestHandleScopeStats_RepeatersByRegion(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) VALUES ('aabbccdd0011', 'TestRepeater1')`, + ); err != nil { + t.Fatalf("seed node: %v", err) + } + + pt5 := 5 // GRP_TXT — non-advert, so it counts toward TransportedScopes + tx := &StoreTx{ + ID: 1, + Hash: "txhash1", + FirstSeen: time.Now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano), + PayloadType: &pt5, + ScopeName: "#belgium", + } + srv.store = &PacketStore{ + byPathHop: map[string][]*StoreTx{"aabbccdd0011": {tx}}, + } + + 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.RepeatersByRegion) != 1 { + t.Fatalf("repeatersByRegion = %v, want 1 entry", resp.RepeatersByRegion) + } + rbr := resp.RepeatersByRegion[0] + if rbr.Region != "#belgium" || rbr.Count != 1 { + t.Errorf("repeatersByRegion[0] = %+v, want region=#belgium count=1", rbr) + } + if len(rbr.Repeaters) != 1 || rbr.Repeaters[0].Name != "TestRepeater1" || rbr.Repeaters[0].PublicKey != "aabbccdd0011" { + t.Errorf("repeaters = %+v, want [{TestRepeater1 aabbccdd0011}]", rbr.Repeaters) + } +} + func TestHandleScopeStatsInvalidWindow(t *testing.T) { srv, _ := setupTestServer(t) if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil { diff --git a/cmd/server/types.go b/cmd/server/types.go index cd3ae0a4..be1659e1 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -125,6 +125,24 @@ type ScopeStatsResponse struct { // Omitted (both zero-value) when the server config has no hashRegions. ConfiguredRegions int `json:"configuredRegions,omitempty"` UnusedRegions []string `json:"unusedRegions,omitempty"` + // RepeatersByRegion is all-time (not window-scoped), like + // UnusedRegions: for each region that has ever matched a transmission, + // which distinct repeaters/rooms have relayed traffic carrying that + // scope (nodes.go transported_scopes, #1751), sourced from the same + // 5-minute-cached bulk relay-info map the Nodes page uses. Omitted + // when the in-memory store isn't available (DB-only mode). + RepeatersByRegion []ScopeRegionRepeaters `json:"repeatersByRegion,omitempty"` +} + +type RepeaterRef struct { + Name string `json:"name"` + PublicKey string `json:"publicKey"` +} + +type ScopeRegionRepeaters struct { + Region string `json:"region"` + Count int `json:"count"` + Repeaters []RepeaterRef `json:"repeaters"` } // ─── Health ──────────────────────────────────────────────────────────────────── diff --git a/public/analytics.js b/public/analytics.js index 144dd1dd..46cfeacf 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -4490,7 +4490,8 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData = '' + '' + '
' + - '
'; + '
' + + '
'; // Attach window-button click listeners (once) el.querySelectorAll('[data-win]').forEach(function(btn) { @@ -4649,6 +4650,34 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData = utilEl.innerHTML = ''; } } + + // Repeaters by region: all-time (not window-scoped), which repeaters + // have relayed traffic carrying each scope. Sourced from the same + // 5-min-cached bulk relay-info map the Nodes page uses, so this is + // cheap — no new per-request computation. + var repEl = document.getElementById('scopes-repeaters'); + if (repEl) { + var byRegion = d.repeatersByRegion || []; + if (byRegion.length > 0) { + var rows = byRegion.map(function(rbr) { + var links = rbr.repeaters.map(function(rp) { + return '' + esc(rp.name) + ''; + }).join(', '); + return '
' + + '' + esc(rbr.region) + ' — ' + rbr.count.toLocaleString() + ' repeater' + (rbr.count === 1 ? '' : 's') + '' + + '
' + links + '
' + + '
'; + }).join(''); + repEl.innerHTML = + '

Repeaters by Region

' + + '

' + + '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.' + + '

' + + rows; + } else { + repEl.innerHTML = ''; + } + } } load(selectedWindow);