diff --git a/AREAS.md b/AREAS.md index 3e916f88..58ec539d 100644 --- a/AREAS.md +++ b/AREAS.md @@ -18,17 +18,17 @@ draw or which country you run CoreScope in. "areas": { "DK": { "label": "Danmark (alle)", - "regionScope": "dk", + "regionScopes": ["dk"], "polygon": [[54.85, 8.65], [55.50, 8.10], [57.10, 8.20], ...] }, "AAR": { "label": "Aarhus by", - "regionScope": "dk-aarhus", + "regionScopes": ["dk-aarhus"], "polygon": [[56.35, 10.33], [56.31, 10.45], ...] }, "EU": { "label": "Europa (alle)", - "regionScope": "eu", + "regionScopes": ["eu", "europe"], "latMin": 34.0, "latMax": 71.5, "lonMin": -25.0, "lonMax": 45.0 } } @@ -49,14 +49,17 @@ Each entry has three parts: If `polygon` has at least 3 points, it's used; otherwise the code falls back to the box. An area can have one or the other, not both meaningfully at once. -- **`regionScope`** (optional) — links this area to a hashRegions channel - scope (e.g. `"dk-aarhus"`, stored *without* the leading `#`). This powers - the Scopes tab's "Scope Adoption by Area" section: which nodes physically - in this area actually use (via their own `default_scope`, or by relaying - it) the region this area represents. Leave it unset if there's no - matching hashRegion — the area still works for everything else (badges, - node counts, the area filter), it just won't have anything to compare - scope-adoption against. +- **`regionScopes`** (optional) — links this area to one or more hashRegions + channel scopes (e.g. `["dk-aarhus"]`, each stored *without* the leading + `#`). This powers the Scopes tab's "Scope Adoption by Area" section: which + nodes physically in this area actually use (via their own `default_scope`, + or by relaying it) *any* of the regions this area represents. Most areas + only need one scope, but a broad umbrella area can link several — e.g. + Europa linking both `"eu"` and `"europe"` if both names see real traffic — + and a node matching any one of them counts as supporting the area. Leave + it unset/empty if there's no matching hashRegion — the area still works + for everything else (badges, node counts, the area filter), it just won't + have anything to compare scope-adoption against. ## Hierarchy: draw it, don't declare it diff --git a/cmd/server/area_filter_test.go b/cmd/server/area_filter_test.go index a0f55f7c..989c8790 100644 --- a/cmd/server/area_filter_test.go +++ b/cmd/server/area_filter_test.go @@ -296,10 +296,10 @@ func TestHandleConfigAreas(t *testing.T) { } } -func TestHandleConfigAreas_RegionScope(t *testing.T) { +func TestHandleConfigAreas_RegionScopes(t *testing.T) { db := setupTestDBv2(t) cfg := &Config{Areas: map[string]AreaEntry{ - "AAR": {Label: "Aarhus by", RegionScope: "dk-aarhus"}, + "AAR": {Label: "Aarhus by", RegionScopes: []string{"dk-aarhus"}}, "MST": {Label: "Maastricht"}, }} @@ -319,11 +319,12 @@ func TestHandleConfigAreas_RegionScope(t *testing.T) { for _, entry := range result { byKey[entry["key"].(string)] = entry } - if byKey["AAR"]["regionScope"] != "dk-aarhus" { - t.Errorf("AAR regionScope = %v, want \"dk-aarhus\"", byKey["AAR"]["regionScope"]) + aarScopes, _ := byKey["AAR"]["regionScopes"].([]interface{}) + if len(aarScopes) != 1 || aarScopes[0] != "dk-aarhus" { + t.Errorf("AAR regionScopes = %v, want [\"dk-aarhus\"]", byKey["AAR"]["regionScopes"]) } - if _, present := byKey["MST"]["regionScope"]; present { - t.Errorf("MST should omit regionScope entirely (unset), got %v", byKey["MST"]["regionScope"]) + if _, present := byKey["MST"]["regionScopes"]; present { + t.Errorf("MST should omit regionScopes entirely (unset), got %v", byKey["MST"]["regionScopes"]) } } diff --git a/cmd/server/config.go b/cmd/server/config.go index 9abd6580..af3c139a 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -25,11 +25,13 @@ type AreaEntry struct { LonMin *float64 `json:"lonMin,omitempty"` LonMax *float64 `json:"lonMax,omitempty"` - // RegionScope links this area to a hashRegions channel scope (e.g. - // "dk-aarhus"), stored without the leading "#" — callers should run it - // through regions.Normalize before comparing against a scope_name. - // Left empty when no confident area<->scope mapping exists. - RegionScope string `json:"regionScope,omitempty"` + // RegionScopes links this area to one or more hashRegions channel + // scopes (e.g. "dk-aarhus"; a broad umbrella area can have several, + // e.g. Europa linking both "eu" and "europe"), stored without the + // leading "#" — callers should run each through regions.Normalize + // before comparing against a scope_name. Left empty when no confident + // area<->scope mapping exists. + RegionScopes []string `json:"regionScopes,omitempty"` } // AreaForPoint returns the label of the most specific configured area that @@ -43,7 +45,7 @@ func AreaForPoint(lat, lon float64, areas map[string]AreaEntry) (label string, o // AreaKeyForPoint is AreaForPoint but returns the area's config key (e.g. // "ODE") instead of its display label — for callers that need to look up -// other fields on the matched AreaEntry (e.g. RegionScope), not just show +// other fields on the matched AreaEntry (e.g. RegionScopes), not just show // the name. func AreaKeyForPoint(lat, lon float64, areas map[string]AreaEntry) (key string, ok bool) { key, _, ok = areaMatchForPoint(lat, lon, areas) diff --git a/cmd/server/db.go b/cmd/server/db.go index 476fb26c..7e7fc763 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -2299,7 +2299,7 @@ func (db *DB) GetNodesForScopeAdoption() ([]nodeAreaScopeInput, error) { // computeScopeAdoptionByArea buckets nodes by their most specific // configured area and tallies, per area: how many nodes 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 +// RegionScopes 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. @@ -2322,7 +2322,7 @@ func (db *DB) GetNodesForScopeAdoption() ([]nodeAreaScopeInput, error) { // may be nil (in-memory store unavailable), in which case matching falls // back to default_scope only. // -// For areas with a RegionScope link, also returns the actual node lists +// For areas with a RegionScopes link, also returns the actual node lists // (Matching/NotMatching) — dborup wanted to see which specific nodes in // e.g. Østjylland relay dk-oj (correctly "support" the area) and which // don't, not just an aggregate count. @@ -2347,7 +2347,7 @@ func computeScopeAdoptionByArea(nodes []nodeAreaScopeInput, areas map[string]Are c, exists := counts[key] if !exists { a := areas[key] - c = &AreaScopeAdoption{AreaKey: key, Label: a.Label, RegionScope: a.RegionScope} + c = &AreaScopeAdoption{AreaKey: key, Label: a.Label, RegionScopes: a.RegionScopes} counts[key] = c } c.TotalNodes++ @@ -2355,11 +2355,17 @@ func computeScopeAdoptionByArea(nodes []nodeAreaScopeInput, areas map[string]Are c.NodesWithAnyScope++ } // Matching/NotMatching per-node lists only make sense when the - // area actually has a region to compare against — an area - // with no RegionScope link has nothing to be "not matching". - if c.RegionScope != "" { - normalizedRegion := strings.ToLower(c.RegionScope) - regionMatch := ownScope == normalizedRegion || relayedRegions[normalizedRegion] + // area actually has region(s) to compare against — an area + // with no RegionScopes link has nothing to be "not matching". + if len(c.RegionScopes) > 0 { + regionMatch := false + for _, rs := range c.RegionScopes { + normalizedRegion := strings.ToLower(rs) + if ownScope == normalizedRegion || relayedRegions[normalizedRegion] { + regionMatch = true + break + } + } ref := RepeaterRef{Name: n.Name, PublicKey: n.PublicKey} if regionMatch { c.NodesMatchingArea++ diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index 1d685351..1357db49 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -2331,15 +2331,15 @@ func TestLoadIndexesRelayHopsFromResolvedPath(t *testing.T) { func TestComputeScopeAdoptionByArea(t *testing.T) { f := func(v float64) *float64 { return &v } areas := map[string]AreaEntry{ - "ODE": {Label: "Odense by", RegionScope: "dk-fyn-odense", LatMin: f(55.32), LatMax: f(55.45), LonMin: f(10.3), LonMax: f(10.5)}, - "GOT": {Label: "Göteborg, SE", LatMin: f(57.35), LatMax: f(57.90), LonMin: f(11.85), LonMax: f(12.85)}, // no RegionScope link + "ODE": {Label: "Odense by", RegionScopes: []string{"dk-fyn-odense"}, LatMin: f(55.32), LatMax: f(55.45), LonMin: f(10.3), LonMax: f(10.5)}, + "GOT": {Label: "Göteborg, SE", LatMin: f(57.35), LatMax: f(57.90), LonMin: f(11.85), LonMax: f(12.85)}, // no RegionScopes link } nodes := []nodeAreaScopeInput{ {Lat: 55.4047, Lon: 10.3810, DefaultScope: "#dk-fyn-odense"}, // Odense, matches area's own region {Lat: 55.40, Lon: 10.40, DefaultScope: "#dk-aarhus"}, // Odense, but a DIFFERENT region {Lat: 55.41, Lon: 10.41, DefaultScope: ""}, // Odense, no scope at all - {Lat: 57.68, Lon: 11.97, DefaultScope: "#dk-aarhus"}, // Göteborg, has a scope, but area has no RegionScope link + {Lat: 57.68, Lon: 11.97, DefaultScope: "#dk-aarhus"}, // Göteborg, has a scope, but area has no RegionScopes link {Lat: 57.70, Lon: 11.98, DefaultScope: ""}, // Göteborg, no scope {Lat: 0, Lon: 0, DefaultScope: "#dk-aarhus"}, // no-fix, must be excluded entirely {Lat: 51.0, Lon: 4.0, DefaultScope: "#belgium"}, // outside every configured area, excluded @@ -2365,7 +2365,7 @@ func TestComputeScopeAdoptionByArea(t *testing.T) { t.Errorf("ODE.NodesMatchingArea = %d, want 1 (only the dk-fyn-odense one matches, the dk-aarhus one doesn't)", ode.NodesMatchingArea) } if len(ode.Matching) != 1 || len(ode.NotMatching) != 2 { - t.Errorf("ODE.Matching=%v NotMatching=%v, want 1 matching + 2 not-matching (RegionScope is set)", ode.Matching, ode.NotMatching) + t.Errorf("ODE.Matching=%v NotMatching=%v, want 1 matching + 2 not-matching (RegionScopes is set)", ode.Matching, ode.NotMatching) } got2 := byKey["GOT"] @@ -2376,10 +2376,10 @@ func TestComputeScopeAdoptionByArea(t *testing.T) { t.Errorf("GOT.NodesWithAnyScope = %d, want 1", got2.NodesWithAnyScope) } if got2.NodesMatchingArea != 0 { - t.Errorf("GOT.NodesMatchingArea = %d, want 0 (area has no RegionScope link to match against)", got2.NodesMatchingArea) + t.Errorf("GOT.NodesMatchingArea = %d, want 0 (area has no RegionScopes link to match against)", got2.NodesMatchingArea) } if len(got2.Matching) != 0 || len(got2.NotMatching) != 0 { - t.Errorf("GOT.Matching=%v NotMatching=%v, want both empty (no RegionScope, nothing to split into two groups)", got2.Matching, got2.NotMatching) + t.Errorf("GOT.Matching=%v NotMatching=%v, want both empty (no RegionScopes, nothing to split into two groups)", got2.Matching, got2.NotMatching) } } @@ -2393,7 +2393,7 @@ func TestComputeScopeAdoptionByArea(t *testing.T) { 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)}, + "HORSENS": {Label: "Horsens", RegionScopes: []string{"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 @@ -2443,8 +2443,8 @@ func TestComputeScopeAdoptionByArea_RelayedRegionCounts(t *testing.T) { func TestComputeScopeAdoptionByArea_RollsUpIntoBroaderAreas(t *testing.T) { f := func(v float64) *float64 { return &v } areas := map[string]AreaEntry{ - "DK": {Label: "Danmark (alle)", RegionScope: "dk", LatMin: f(54.5), LatMax: f(57.8), LonMin: f(8.0), LonMax: f(15.25)}, - "ODE": {Label: "Odense by", RegionScope: "dk-fyn-odense", LatMin: f(55.32), LatMax: f(55.45), LonMin: f(10.3), LonMax: f(10.5)}, + "DK": {Label: "Danmark (alle)", RegionScopes: []string{"dk"}, LatMin: f(54.5), LatMax: f(57.8), LonMin: f(8.0), LonMax: f(15.25)}, + "ODE": {Label: "Odense by", RegionScopes: []string{"dk-fyn-odense"}, LatMin: f(55.32), LatMax: f(55.45), LonMin: f(10.3), LonMax: f(10.5)}, } nodes := []nodeAreaScopeInput{ {PublicKey: "odenode01", Name: "OdenseNode", Lat: 55.4047, Lon: 10.3810, DefaultScope: "#dk-fyn-odense"}, // inside BOTH DK and ODE @@ -2483,3 +2483,50 @@ func TestComputeScopeAdoptionByArea_Empty(t *testing.T) { t.Errorf("expected no areas with 0 nodes, got %+v", got) } } + +// TestComputeScopeAdoptionByArea_MultipleRegionScopes covers dborup's exact +// request: a broad umbrella area (Europa) can be linked to more than one +// hashRegions scope at once (e.g. both "eu" and "europe"), and a node using +// EITHER counts as matching -- not just the first-configured one. +func TestComputeScopeAdoptionByArea_MultipleRegionScopes(t *testing.T) { + f := func(v float64) *float64 { return &v } + areas := map[string]AreaEntry{ + "EU": {Label: "Europa", RegionScopes: []string{"eu", "europe"}, LatMin: f(34.0), LatMax: f(71.5), LonMin: f(-25.0), LonMax: f(45.0)}, + } + nodes := []nodeAreaScopeInput{ + {PublicKey: "usesEu", Lat: 48.0, Lon: 10.0, DefaultScope: "#eu"}, + {PublicKey: "usesEurope", Lat: 48.0, Lon: 11.0, DefaultScope: "#europe"}, + {PublicKey: "relaysEurope", Lat: 48.0, Lon: 12.0, DefaultScope: ""}, + {PublicKey: "usesNeither", Lat: 48.0, Lon: 13.0, DefaultScope: "#dk"}, + } + relayInfo := map[string]RepeaterRelayInfo{ + "relaysEurope": {TransportedScopes: []string{"#europe"}}, + } + + got := computeScopeAdoptionByArea(nodes, areas, relayInfo) + if len(got) != 1 { + t.Fatalf("got %d areas, want 1", len(got)) + } + eu := got[0] + if eu.TotalNodes != 4 { + t.Errorf("TotalNodes = %d, want 4", eu.TotalNodes) + } + // usesEu (#eu), usesEurope (#europe), and relaysEurope (relays + // #europe) all match one of Europa's two linked scopes; usesNeither + // (#dk) matches neither. + if eu.NodesMatchingArea != 3 { + t.Errorf("NodesMatchingArea = %d, want 3 (any of #eu or #europe should count)", eu.NodesMatchingArea) + } + matchingKeys := map[string]bool{} + for _, m := range eu.Matching { + matchingKeys[m.PublicKey] = true + } + for _, want := range []string{"usesEu", "usesEurope", "relaysEurope"} { + if !matchingKeys[want] { + t.Errorf("Matching missing %q, got %v", want, eu.Matching) + } + } + if len(eu.NotMatching) != 1 || eu.NotMatching[0].PublicKey != "usesNeither" { + t.Errorf("NotMatching = %v, want just usesNeither", eu.NotMatching) + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 952f3016..f23ba854 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -489,16 +489,16 @@ func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) { func (s *Server) handleConfigAreas(w http.ResponseWriter, r *http.Request) { type areaListEntry struct { - Key string `json:"key"` - Label string `json:"label"` - RegionScope string `json:"regionScope,omitempty"` + Key string `json:"key"` + Label string `json:"label"` + RegionScopes []string `json:"regionScopes,omitempty"` } result := make([]areaListEntry, 0, len(s.cfg.Areas)) for k, v := range s.cfg.Areas { if v.Label == "" { continue // skip comment/invalid entries (e.g. "_comment" keys in config) } - result = append(result, areaListEntry{Key: k, Label: v.Label, RegionScope: v.RegionScope}) + result = append(result, areaListEntry{Key: k, Label: v.Label, RegionScopes: v.RegionScopes}) } writeJSON(w, result) } diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index 6a97e357..0ace90ff 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -4802,7 +4802,7 @@ func TestHandleScopeStats_ScopeAdoptionByArea(t *testing.T) { } f := func(v float64) *float64 { return &v } srv.cfg.Areas = map[string]AreaEntry{ - "ODE": {Label: "Odense by", RegionScope: "dk-fyn-odense", LatMin: f(55.32), LatMax: f(55.45), LonMin: f(10.3), LonMax: f(10.5)}, + "ODE": {Label: "Odense by", RegionScopes: []string{"dk-fyn-odense"}, LatMin: f(55.32), LatMax: f(55.45), LonMin: f(10.3), LonMax: f(10.5)}, } insertNode := func(pk, defaultScope string, lat, lon float64) { @@ -4860,7 +4860,7 @@ func TestHandleScopeStats_ScopeAdoptionByArea_ZeroMatchKeyPresent(t *testing.T) } f := func(v float64) *float64 { return &v } srv.cfg.Areas = map[string]AreaEntry{ - "AAR": {Label: "Aarhus by", RegionScope: "dk-aarhus", LatMin: f(56.05), LatMax: f(56.25), LonMin: f(9.95), LonMax: f(10.35)}, + "AAR": {Label: "Aarhus by", RegionScopes: []string{"dk-aarhus"}, LatMin: f(56.05), LatMax: f(56.25), LonMin: f(9.95), LonMax: f(10.35)}, } // A node with a scope, but NOT the area's own region — NodesMatchingArea // must come out as a real, present 0, same as the live #dk-vs-#dk-aarhus diff --git a/cmd/server/types.go b/cmd/server/types.go index 598edf82..41b2e3da 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -206,10 +206,10 @@ type ScopeStatsResponse struct { // AreaScopeAdoption is one configured area's node count and scope adoption // — see ScopeStatsResponse.ScopeAdoptionByArea and computeScopeAdoptionByArea. type AreaScopeAdoption struct { - AreaKey string `json:"areaKey"` - Label string `json:"label"` - RegionScope string `json:"regionScope,omitempty"` - TotalNodes int `json:"totalNodes"` + AreaKey string `json:"areaKey"` + Label string `json:"label"` + RegionScopes []string `json:"regionScopes,omitempty"` + TotalNodes int `json:"totalNodes"` // 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 @@ -217,18 +217,18 @@ type AreaScopeAdoption struct { // its own — see computeScopeAdoptionByArea). NodesWithAnyScope int `json:"nodesWithAnyScope"` // 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". + // specifically use one of THIS area's own RegionScopes — via + // default_scope OR by having relayed it. Only meaningful when + // RegionScopes is non-empty — 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"` // Matching/NotMatching are the actual nodes behind NodesMatchingArea — - // which specific nodes in this area relay/configure the area's own - // region (correctly "support" it) and which sit here but don't. Only - // populated when RegionScope is set (nothing to split into two groups - // otherwise). + // which specific nodes in this area relay/configure any of the area's + // own regions (correctly "support" it) and which sit here but don't. + // Only populated when RegionScopes is non-empty (nothing to split + // into two groups otherwise). Matching []RepeaterRef `json:"matching,omitempty"` NotMatching []RepeaterRef `json:"notMatching,omitempty"` } diff --git a/public/analytics.js b/public/analytics.js index 2c8818c7..0186bb7e 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -4828,8 +4828,11 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf } // regionScope -> area label lookup, e.g. "dk-aarhus" -> "Aarhus by". - // Areas rarely change, so this is fetched once and reused across every - // region-code section below rather than re-fetched per render. + // An area can link more than one scope (e.g. Europa: both "eu" and + // "europe"), so each one gets its own entry pointing back to the same + // label. Areas rarely change, so this is fetched once and reused + // across every region-code section below rather than re-fetched per + // render. var regionAreaLabels = null; async function loadRegionAreaLabels() { if (regionAreaLabels) return regionAreaLabels; @@ -4837,14 +4840,16 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf try { var areas = await api('/config/areas', { ttl: CLIENT_TTL.nodeDetail }); (areas || []).forEach(function(a) { - if (a.regionScope) regionAreaLabels[a.regionScope.toLowerCase()] = a.label; + (a.regionScopes || []).forEach(function(rs) { + regionAreaLabels[rs.toLowerCase()] = a.label; + }); }); } catch (e) { /* leave empty -- region codes render unlabeled */ } return regionAreaLabels; } // A configured hashRegions name is always "#"-prefixed (see - // internal/regions.Normalize); AreaEntry.RegionScope is stored without - // it, so strip before looking up. + // internal/regions.Normalize); AreaEntry.RegionScopes entries are + // stored without it, so strip before looking up. function regionAreaLabel(rawRegionName) { if (!regionAreaLabels || !rawRegionName) return null; var key = String(rawRegionName).replace(/^#/, '').toLowerCase(); @@ -5098,10 +5103,11 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf } var areaGroups = byArea.map(function(a) { var summary, body; - if (a.regionScope) { + if (a.regionScopes && a.regionScopes.length) { var matchCount = a.nodesMatchingArea || 0; + var scopeCodes = a.regionScopes.map(function(rs) { return '#' + esc(rs) + ''; }).join(' or '); summary = esc(a.label) + ' — ' + matchCount.toLocaleString() + ' of ' + a.totalNodes.toLocaleString() + - ' support #' + esc(a.regionScope) + ' (' + pct(matchCount, a.totalNodes) + ')'; + ' support ' + scopeCodes + ' (' + pct(matchCount, a.totalNodes) + ')'; body = '
Supporting (' + (a.matching || []).length + '): ' + (a.matching && a.matching.length ? nodeLinks(a.matching) : 'none') + @@ -5112,7 +5118,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf } else { summary = esc(a.label) + ' — ' + a.totalNodes.toLocaleString() + ' node' + (a.totalNodes === 1 ? '' : 's') + ', no region linked to this area'; - body = '

This area has no regionScope configured, so there\'s nothing to check adoption against.

'; + body = '

This area has no regionScopes configured, so there\'s nothing to check adoption against.

'; } return '
' + '' + summary + '' +