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 =
'
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 '