feat: repeaters-by-region breakdown on the Scopes analytics tab

Adds repeatersByRegion to /api/scope-stats: for every region that has
ever matched a transmission, which distinct repeaters/rooms have
relayed traffic carrying that scope. Sourced from the same 5-min
background-recomputed bulk relay-info cache the Nodes page already
uses (GetRepeaterRelayInfoMap / TransportedScopes, #1751) — no new
expensive computation, just an inversion + name lookup.

Frontend renders a collapsible per-region repeater list (name links
to the node detail page) under a new "Repeaters by Region" section,
explicitly framed as a coverage/redundancy signal: a region carried
by only one repeater is a single point of failure for that area.
This commit is contained in:
dborup
2026-07-17 16:52:00 +02:00
parent afa76c4f52
commit 87d2f479a7
5 changed files with 175 additions and 1 deletions
+33
View File
@@ -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 {
+42
View File
@@ -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)
+52
View File
@@ -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 {
+18
View File
@@ -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 ────────────────────────────────────────────────────────────────────
+30 -1
View File
@@ -4490,7 +4490,8 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
'<tbody id="scopes-tbody"></tbody>' +
'</table>' +
'<div id="scopes-chart"></div>' +
'<div id="scopes-utilization" style="margin-top:16px"></div>';
'<div id="scopes-utilization" style="margin-top:16px"></div>' +
'<div id="scopes-repeaters" style="margin-top:16px"></div>';
// 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 '<a href="#/nodes/' + encodeURIComponent(rp.publicKey) + '">' + esc(rp.name) + '</a>';
}).join(', ');
return '<details style="margin-bottom:6px">' +
'<summary style="cursor:pointer"><code>' + esc(rbr.region) + '</code> — ' + rbr.count.toLocaleString() + ' repeater' + (rbr.count === 1 ? '' : 's') + '</summary>' +
'<div class="text-muted" style="font-size:11px;margin-top:6px;margin-left:12px;max-height:200px;overflow-y:auto;line-height:1.8">' + links + '</div>' +
'</details>';
}).join('');
repEl.innerHTML =
'<h4 style="margin:0 0 4px">Repeaters by Region</h4>' +
'<p class="text-muted" style="margin:0 0 8px;font-size:0.85em">' +
'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.' +
'</p>' +
rows;
} else {
repEl.innerHTML = '';
}
}
}
load(selectedWindow);