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