From 9661fd0f4da8630ace554b964a9a9fce07e1a3e0 Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 21 Jul 2026 17:11:26 +0200 Subject: [PATCH] fix: Scope Adoption by Area must count relayed regions, not just default_scope dborup: a repeater that has relayed dk-horsens traffic supports the Horsens area, even if its own default_scope is something else (e.g. the generic #dk most nodes actually use) or unset entirely. The previous version only checked default_scope, missing this -- same runs-this-region vs carried-this-region's-traffic distinction the tab already draws between OriginatingNodesByRegion and RepeatersByRegion. Both NodesWithAnyScope and NodesMatchingArea now also check the node's entry in the cached RepeaterRelayInfo map (TransportedScopes), reusing the same cache RepeatersByRegion already populates. Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 71 +++++++++++++++++++++++++++++++------------ cmd/server/db_test.go | 48 +++++++++++++++++++++++++++-- cmd/server/routes.go | 10 +++++- cmd/server/types.go | 20 +++++++----- public/analytics.js | 2 +- 5 files changed, 120 insertions(+), 31 deletions(-) diff --git a/cmd/server/db.go b/cmd/server/db.go index 3f837352..4978d93d 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -2239,10 +2239,12 @@ func (db *DB) GetNodesByDefaultScope() (map[string][]RepeaterRef, error) { return result, rows.Err() } -// nodeAreaScopeInput is one node's position + default_scope — the raw -// input to computeScopeAdoptionByArea. DefaultScope is "" when unset or -// when this DB predates #899 (no default_scope column at all). +// nodeAreaScopeInput is one node's position + default_scope + pubkey — the +// raw input to computeScopeAdoptionByArea. DefaultScope is "" when unset or +// when this DB predates #899 (no default_scope column at all). PublicKey is +// lowercase, for looking a node up in a RepeaterRelayInfo map. type nodeAreaScopeInput struct { + PublicKey string Lat, Lon float64 DefaultScope string } @@ -2253,7 +2255,7 @@ type nodeAreaScopeInput struct { // area. Unlike GetNodesByDefaultScope, this includes nodes with NO scope // too — the whole point is measuring adoption, not just listing who has one. func (db *DB) GetNodesForScopeAdoption() ([]nodeAreaScopeInput, error) { - query := "SELECT lat, lon" + query := "SELECT public_key, lat, lon" if db.hasDefaultScope { query += ", default_scope" } @@ -2265,31 +2267,41 @@ func (db *DB) GetNodesForScopeAdoption() ([]nodeAreaScopeInput, error) { defer rows.Close() var out []nodeAreaScopeInput for rows.Next() { + var pk string var lat, lon float64 var scope sql.NullString var scanErr error if db.hasDefaultScope { - scanErr = rows.Scan(&lat, &lon, &scope) + scanErr = rows.Scan(&pk, &lat, &lon, &scope) } else { - scanErr = rows.Scan(&lat, &lon) + scanErr = rows.Scan(&pk, &lat, &lon) } if scanErr != nil { continue } - out = append(out, nodeAreaScopeInput{Lat: lat, Lon: lon, DefaultScope: scope.String}) + out = append(out, nodeAreaScopeInput{PublicKey: strings.ToLower(pk), Lat: lat, Lon: lon, DefaultScope: scope.String}) } return out, rows.Err() } // computeScopeAdoptionByArea buckets nodes by their most specific // configured area (AreaKeyForPoint) and tallies, per area: how many nodes -// sit there at all, how many have ANY default_scope configured, and (when -// the area itself has a RegionScope link) how many of those specifically -// match the area's own region — i.e. does this geographic community -// actually use the scope the area is nominally tied to, or something else -// entirely (or nothing at all). A node outside every configured area is -// excluded, same as the area-badge features above. -func computeScopeAdoptionByArea(nodes []nodeAreaScopeInput, areas map[string]AreaEntry) []AreaScopeAdoption { +// sit there at all, how many "use scope" in ANY sense, and (when the area +// itself has a RegionScope link) how many specifically use THAT region — +// i.e. does this geographic community actually engage with the scope the +// area is nominally tied to, or something else entirely (or nothing at +// all). A node outside every configured area is excluded, same as the +// area-badge features above. +// +// "Uses scope" counts two distinct signals, same runs-this-region vs +// carried-this-region's-traffic distinction as OriginatingNodesByRegion vs +// RepeatersByRegion above: (1) the node's own default_scope, and (2) any +// region it has ever RELAYED (relayInfo/TransportedScopes) — a repeater +// can carry dk-horsens traffic and thereby support the Horsens area +// without ever configuring dk-horsens as its own default_scope. relayInfo +// may be nil (in-memory store unavailable), in which case matching falls +// back to default_scope only. +func computeScopeAdoptionByArea(nodes []nodeAreaScopeInput, areas map[string]AreaEntry, relayInfo map[string]RepeaterRelayInfo) []AreaScopeAdoption { counts := make(map[string]*AreaScopeAdoption) for _, n := range nodes { key, ok := AreaKeyForPoint(n.Lat, n.Lon, areas) @@ -2303,13 +2315,34 @@ func computeScopeAdoptionByArea(nodes []nodeAreaScopeInput, areas map[string]Are counts[key] = c } c.TotalNodes++ - scope := strings.ToLower(strings.TrimPrefix(n.DefaultScope, "#")) - if scope != "" { - c.NodesWithAnyScope++ - if c.RegionScope != "" && scope == strings.ToLower(c.RegionScope) { - c.NodesMatchingArea++ + + ownScope := strings.ToLower(strings.TrimPrefix(n.DefaultScope, "#")) + hasAnyScope := ownScope != "" + var normalizedRegion string + regionMatch := false + if c.RegionScope != "" { + normalizedRegion = strings.ToLower(c.RegionScope) + regionMatch = ownScope == normalizedRegion + } + + if info, ok := relayInfo[n.PublicKey]; ok && len(info.TransportedScopes) > 0 { + hasAnyScope = true + if normalizedRegion != "" && !regionMatch { + for _, r := range info.TransportedScopes { + if strings.ToLower(strings.TrimPrefix(r, "#")) == normalizedRegion { + regionMatch = true + break + } + } } } + + if hasAnyScope { + c.NodesWithAnyScope++ + } + if regionMatch { + c.NodesMatchingArea++ + } } result := make([]AreaScopeAdoption, 0, len(counts)) for _, c := range counts { diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index 7bc73753..addd29c6 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -2345,7 +2345,7 @@ func TestComputeScopeAdoptionByArea(t *testing.T) { {Lat: 51.0, Lon: 4.0, DefaultScope: "#belgium"}, // outside every configured area, excluded } - got := computeScopeAdoptionByArea(nodes, areas) + got := computeScopeAdoptionByArea(nodes, areas, nil) if len(got) != 2 { t.Fatalf("got %d areas, want 2 (ODE and GOT) -- result: %+v", len(got), got) } @@ -2377,8 +2377,52 @@ func TestComputeScopeAdoptionByArea(t *testing.T) { } } +// TestComputeScopeAdoptionByArea_RelayedRegionCounts covers the case +// dborup flagged directly: a repeater sitting inside the Horsens area that +// has RELAYED dk-horsens traffic supports that region, even if its own +// default_scope is something else (or unset entirely) — matching must not +// be limited to default_scope, same runs-this-region vs +// carried-this-region's-traffic distinction as RepeatersByRegion vs +// OriginatingNodesByRegion elsewhere in this file. +func TestComputeScopeAdoptionByArea_RelayedRegionCounts(t *testing.T) { + f := func(v float64) *float64 { return &v } + areas := map[string]AreaEntry{ + "HORSENS": {Label: "Horsens", RegionScope: "dk-horsens", LatMin: f(55.76), LatMax: f(55.94), LonMin: f(9.6), LonMax: f(9.96)}, + } + nodes := []nodeAreaScopeInput{ + {PublicKey: "relayer01", Lat: 55.85, Lon: 9.85, DefaultScope: "#dk"}, // own scope is the generic #dk, NOT dk-horsens + {PublicKey: "plainnode1", Lat: 55.86, Lon: 9.86, DefaultScope: ""}, // no scope, no relay activity either + {PublicKey: "relayerother", Lat: 55.87, Lon: 9.87, DefaultScope: ""}, // relays something, but not dk-horsens + } + relayInfo := map[string]RepeaterRelayInfo{ + "relayer01": {TransportedScopes: []string{"#dk-horsens"}}, + "relayerother": {TransportedScopes: []string{"#dk-aarhus"}}, + } + + got := computeScopeAdoptionByArea(nodes, areas, relayInfo) + if len(got) != 1 { + t.Fatalf("got %d areas, want 1", len(got)) + } + h := got[0] + if h.TotalNodes != 3 { + t.Errorf("TotalNodes = %d, want 3", h.TotalNodes) + } + // relayer01 (relays dk-horsens) and relayerother (relays something, + // just not dk-horsens) both "use scope" in some sense; plainnode1 does + // nothing at all. + if h.NodesWithAnyScope != 2 { + t.Errorf("NodesWithAnyScope = %d, want 2", h.NodesWithAnyScope) + } + // Only relayer01 specifically relays THIS area's own region + // (dk-horsens) -- despite its own default_scope being the unrelated, + // generic #dk. + if h.NodesMatchingArea != 1 { + t.Errorf("NodesMatchingArea = %d, want 1 (relayer01 relays dk-horsens even though its default_scope is #dk)", h.NodesMatchingArea) + } +} + func TestComputeScopeAdoptionByArea_Empty(t *testing.T) { - got := computeScopeAdoptionByArea(nil, map[string]AreaEntry{"DK": {Label: "Danmark"}}) + got := computeScopeAdoptionByArea(nil, map[string]AreaEntry{"DK": {Label: "Danmark"}}, nil) if len(got) != 0 { t.Errorf("expected no areas with 0 nodes, got %+v", got) } diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 34c5d598..8cfcef18 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -3746,7 +3746,15 @@ func (s *Server) handleScopeStats(w http.ResponseWriter, r *http.Request) { if s.cfg != nil && len(s.cfg.Areas) > 0 { if nodes, err := s.db.GetNodesForScopeAdoption(); err == nil { - resp.ScopeAdoptionByArea = computeScopeAdoptionByArea(nodes, s.cfg.Areas) + // Reuses the same cached relay-info map as RepeatersByRegion + // above (GetRepeaterRelayInfoMap never rebuilds inline on a + // populated cache) so a node relaying an area's region counts + // as using it, not just one with a matching default_scope. + var relayInfo map[string]RepeaterRelayInfo + if s.store != nil { + relayInfo = s.store.GetRepeaterRelayInfoMap(s.cfg.GetHealthThresholds().RelayActiveHours) + } + resp.ScopeAdoptionByArea = computeScopeAdoptionByArea(nodes, s.cfg.Areas, relayInfo) } else { log.Printf("WARN GetNodesForScopeAdoption: %v", err) } diff --git a/cmd/server/types.go b/cmd/server/types.go index 89c08988..67aa9ac6 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -210,15 +210,19 @@ type AreaScopeAdoption struct { Label string `json:"label"` RegionScope string `json:"regionScope,omitempty"` TotalNodes int `json:"totalNodes"` - // NodesWithAnyScope is how many of TotalNodes have ANY default_scope - // configured, regardless of which region it is. + // NodesWithAnyScope is how many of TotalNodes "use scope" in any + // sense: either their own default_scope is set, or they've ever + // relayed traffic carrying ANY region's scope (a repeater can support + // a region purely by relaying it, without configuring that region as + // its own — see computeScopeAdoptionByArea). NodesWithAnyScope int `json:"nodesWithAnyScope"` - // NodesMatchingArea is the subset of NodesWithAnyScope whose scope - // matches this area's own RegionScope specifically. Only meaningful - // when RegionScope is set — 0 otherwise (not the same as "0 of them - // match", there's simply nothing configured to match against). No - // omitempty: a real 0 count must still serialize, or the frontend has - // no way to distinguish it from "field absent". + // NodesMatchingArea is the subset of NodesWithAnyScope that + // specifically use THIS area's own RegionScope — via default_scope OR + // by having relayed it. Only meaningful when RegionScope is set — 0 + // otherwise (not the same as "0 of them match", there's simply + // nothing configured to match against). No omitempty: a real 0 count + // must still serialize, or the frontend has no way to distinguish it + // from "field absent". NodesMatchingArea int `json:"nodesMatchingArea"` } diff --git a/public/analytics.js b/public/analytics.js index e5930133..3db9355d 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -5091,7 +5091,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf }).join(''); setSectionHtml(areaAdoptEl, detailsSection( 'Scope Adoption by Area (' + byArea.length.toLocaleString() + ' areas)', - 'All-time — every configured area with at least one positioned node. Shows whether that geographic community actually runs the region-scope system, and whether it runs the specific region this area is linked to.', + 'All-time — every configured area with at least one positioned node. "With Any Scope" and "Matching" both count a node\'s own default_scope AND anything it has ever relayed — a repeater carrying dk-horsens traffic supports that region even without configuring it as its own.', '' + '' + '' + areaRows + '' +
AreaNodesWith Any ScopeMatching Area’s Own Region