feat: allow multiple hashRegions scopes per area

AreaEntry.RegionScope (single string) -> RegionScopes ([]string), so a
broad umbrella area (e.g. Europa) can link more than one scope name
(e.g. both "eu" and "europe") -- a node matching any one of them now
counts as supporting the area in computeScopeAdoptionByArea, the
"Scope Adoption by Area" section, and the regionScope->label lookup
used to annotate region codes elsewhere on the Scopes tab.
This commit is contained in:
dborup committed 2026-07-22 17:09:45 +02:00
1 parent 17b86361d9
commit f11d3d69e5
9 files changed
+133 -68

No files matched your search

+14 -11
View File
@@ -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
+7 -6
View File
@@ -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"])
}
}
+8 -6
View File
@@ -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)
+14 -8
View File
@@ -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++
+56 -9
View File
@@ -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)
}
}
+4 -4
View File
@@ -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)
}
+2 -2
View File
@@ -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
+14 -14
View File
@@ -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"`
}
+14 -8
View File
@@ -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 '<code>#' + esc(rs) + '</code>'; }).join(' or ');
summary = esc(a.label) + ' — ' + matchCount.toLocaleString() + ' of ' + a.totalNodes.toLocaleString() +
' support <code>#' + esc(a.regionScope) + '</code> (' + pct(matchCount, a.totalNodes) + ')';
' support ' + scopeCodes + ' (' + pct(matchCount, a.totalNodes) + ')';
body =
'<div style="margin-bottom:6px"><strong>Supporting</strong> (' + (a.matching || []).length + '): ' +
(a.matching && a.matching.length ? nodeLinks(a.matching) : '<span class="text-muted">none</span>') +
@@ -5112,7 +5118,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
} else {
summary = esc(a.label) + ' — ' + a.totalNodes.toLocaleString() + ' node' + (a.totalNodes === 1 ? '' : 's') +
', <span class="text-muted">no region linked to this area</span>';
body = '<p class="text-muted" style="margin:0;font-size:0.85em">This area has no regionScope configured, so there\'s nothing to check adoption against.</p>';
body = '<p class="text-muted" style="margin:0;font-size:0.85em">This area has no regionScopes configured, so there\'s nothing to check adoption against.</p>';
}
return '<details style="margin-bottom:8px" data-key="area-adopt:' + esc(a.areaKey) + '">' +
'<summary style="cursor:pointer">' + summary + '</summary>' +