From c1129093f5b1192f5ba2f3bce861b51e1f947874 Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 21 Jul 2026 12:12:52 +0200 Subject: [PATCH 01/24] feat: link areas to meshguide.dk region scopes Add AreaEntry.RegionScope so config.json's "areas" can be tied to a hashRegions channel scope, plus ops/meshguide-sync/sync_areas.py to pull polygons and scope confirmations from meshguide.dk's community-maintained dataset instead of guessing from naming conventions. Co-Authored-By: Claude Sonnet 5 --- cmd/server/config.go | 6 + ops/meshguide-sync/sync_areas.py | 203 +++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100755 ops/meshguide-sync/sync_areas.py diff --git a/cmd/server/config.go b/cmd/server/config.go index e259a31e..4d852958 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -23,6 +23,12 @@ type AreaEntry struct { LatMax *float64 `json:"latMax,omitempty"` 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"` } // ListLimitsConfig defines maximum row limits for list endpoints to prevent DoS. diff --git a/ops/meshguide-sync/sync_areas.py b/ops/meshguide-sync/sync_areas.py new file mode 100755 index 00000000..06d5c41a --- /dev/null +++ b/ops/meshguide-sync/sync_areas.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Sync CoreScope's config.json "areas" from meshguide.dk's community-run +region/city dataset (polygons + hashRegions channel-scope links). + +This is dborup/meshview.dk-specific tooling -- meshguide.dk doesn't exist for +other CoreScope deployments, so this lives outside the Go binary/repo core and +is meant to be run manually or via cron/systemd timer, never as part of the +application itself. + +Usage: + sync_areas.py --config /opt/corescope/data/config.json [--dry-run] + +What it does: + 1. Fetches https://meshguide.dk/regions.json (polygon per scope) and + cities.json (scope confirmation + human names). + 2. For areas already in config.json's "areas" that we're confident match a + meshguide region (see CROSSWALK below -- hand-verified, never guessed), + sets regionScope and replaces the polygon with meshguide's more precise + one. + 3. Adds any meshguide region we don't already have as a new area entry, as + long as it has a real (non-empty) scope assigned. + 4. Anything not in CROSSWALK and not clearly a new region is left alone and + reported as a warning, never silently linked -- e.g. "dk-sdk" (Syddanmark) + is NOT the same place as our existing DK_SJ (Sønderjylland) area, so it's + added as its own new area instead of being merged into DK_SJ. + +A timestamped backup of config.json is written before any change. +""" +import argparse +import json +import re +import sys +import urllib.request +from datetime import datetime, timezone + +DEFAULT_BASE = "https://meshguide.dk" + +# Hand-verified area-key -> meshguide scope mappings. Only pairs we've +# actually confirmed refer to the same place go here. +CROSSWALK = { + "DK": "dk", + "JYL": "dk-jylland", + "DK_NJ": "dk-nj", + "DK_MJ": "dk-mj", + "DK_OJ": "dk-oj", + "DK_3K": "dk-3kant", + "AAR": "dk-aarhus", + "AAL": "dk-aalborg", + "FYN": "dk-fyn", + "ODE": "dk-fyn-odense", + "SJL": "dk-sjl", + "DK_NSJ": "dk-nordsjaelland", + "DK_LF": "dk-lo-fa", + "RNN": "dk-bhm", +} + +# Areas we deliberately did NOT auto-link, and why -- printed as a reminder +# each run so the mismatch doesn't get silently forgotten. +KNOWN_GAPS = { + "DK_VJ": "no matching meshguide region found (Vestjylland)", + "DK_SJ": 'meshguide\'s dk-sdk is "Syddanmark" (a different, broader region than Sønderjylland) -- not linked', + "CPH": "no matching meshguide region found (Storkøbenhavn)", + "SE_SKA": 'meshguide\'s se12 is "SydSverige" -- close but not confirmed identical to Skåne -- not linked', +} + + +def fetch_json(url): + with urllib.request.urlopen(url, timeout=20) as r: + return json.load(r) + + +def normalize_key(scope): + """dk-fyn-odense -> DK_FYN_ODENSE, se12 -> SE12""" + return re.sub(r"[^A-Za-z0-9]+", "_", scope).strip("_").upper() + + +def geojson_ring_to_polygon(geometry): + """First ring of a GeoJSON Polygon: [lon,lat] -> [lat,lon], closing point dropped.""" + if not geometry or geometry.get("type") != "Polygon": + return None + coords = geometry.get("coordinates") or [] + if not coords: + return None + ring = coords[0] + if len(ring) > 1 and ring[0] == ring[-1]: + ring = ring[:-1] + return [[round(lat, 6), round(lon, 6)] for lon, lat in ring] + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("--config", required=True, help="Path to CoreScope config.json") + ap.add_argument("--base-url", default=DEFAULT_BASE) + ap.add_argument( + "--dry-run", action="store_true", help="Print what would change, write nothing" + ) + args = ap.parse_args() + + regions = fetch_json(args.base_url.rstrip("/") + "/regions.json") + cities = fetch_json(args.base_url.rstrip("/") + "/cities.json") + + # meshguide region keys confirmed to have NO real scope assigned yet + # (cities.json lists them with scope: "") -- never auto-link or add these. + no_scope_keys = {k for k, v in cities.items() if not v.get("scope")} + + with open(args.config, "r", encoding="utf-8") as f: + cfg = json.load(f) + areas = cfg.setdefault("areas", {}) + + changed = [] + warnings = [] + + # scopes confirmed real even without a regions.json polygon (e.g. "dk" + # itself only has a cities.json point, no drawn boundary) + confirmed_scopes = {v.get("scope") for v in cities.values() if v.get("scope")} + confirmed_scopes |= set(regions.keys()) + + # 1) enrich existing crosswalked areas + for area_key, scope in CROSSWALK.items(): + entry = areas.get(area_key) + if entry is None: + warnings.append( + f'CROSSWALK references area "{area_key}" which no longer exists in config.json -- skipped' + ) + continue + if scope not in confirmed_scopes: + warnings.append( + f'CROSSWALK maps {area_key} -> "{scope}" but meshguide no longer has that scope -- skipped' + ) + continue + before = json.dumps(entry, sort_keys=True) + entry["regionScope"] = scope + polygon = geojson_ring_to_polygon((regions.get(scope) or {}).get("geometry")) + if polygon: + entry["polygon"] = polygon + for k in ("latMin", "latMax", "lonMin", "lonMax"): + entry.pop(k, None) + if json.dumps(entry, sort_keys=True) != before: + changed.append( + f"enriched {area_key} ({entry.get('label')}) with regionScope={scope}" + + (" + polygon" if polygon else "") + ) + + # 2) add new areas for meshguide regions we don't have yet + linked_scopes = set(CROSSWALK.values()) + existing_scopes = {v.get("regionScope") for v in areas.values() if v.get("regionScope")} + for scope, region in regions.items(): + if scope in no_scope_keys: + continue + if scope in linked_scopes or scope in existing_scopes: + continue + new_key = normalize_key(scope) + if new_key in areas: + continue + polygon = geojson_ring_to_polygon(region.get("geometry")) + if not polygon: + warnings.append(f'meshguide region "{scope}" has no polygon geometry -- skipped') + continue + areas[new_key] = { + "label": region.get("name", scope), + "polygon": polygon, + "regionScope": scope, + } + changed.append(f"added new area {new_key} ({region.get('name')}) regionScope={scope}") + + for area_key, reason in KNOWN_GAPS.items(): + if area_key in areas and not areas[area_key].get("regionScope"): + warnings.append(f"{area_key}: {reason}") + + print(f"{len(changed)} change(s):") + for c in changed: + print(" -", c) + if warnings: + print(f"\n{len(warnings)} warning(s):") + for w in warnings: + print(" !", w) + + if not changed: + print("\nNo changes -- config.json left untouched.") + return + + if args.dry_run: + print("\n--dry-run: not writing changes.") + return + + backup_path = f"{args.config}.bak-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}" + with open(args.config, "r", encoding="utf-8") as f: + raw = f.read() + with open(backup_path, "w", encoding="utf-8") as f: + f.write(raw) + print(f"\nBackup written to {backup_path}") + + with open(args.config, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) + f.write("\n") + print(f"Wrote changes to {args.config}") + print("\nRestart corescope for the change to take effect (config is only read at startup).") + + +if __name__ == "__main__": + sys.exit(main()) From 303b5d452dff870844aa03cda9b1065a07e51e29 Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 21 Jul 2026 12:33:20 +0200 Subject: [PATCH 02/24] feat: show which area a shared GPS position falls in on the Wardriving tab Adds AreaForPoint (config.go), picking the most specific configured area when several overlap, and wires it into the GPS Sharing table as a badge next to each sender's shared position. Co-Authored-By: Claude Sonnet 5 --- cmd/server/config.go | 45 ++++++++++++++++++++++ cmd/server/config_test.go | 57 ++++++++++++++++++++++++++++ cmd/server/routes.go | 8 ++++ cmd/server/types.go | 3 ++ cmd/server/wardriving_stats_test.go | 58 +++++++++++++++++++++++++++++ public/analytics.js | 6 ++- test-analytics-wardriving-tab.js | 17 +++++++++ 7 files changed, 193 insertions(+), 1 deletion(-) diff --git a/cmd/server/config.go b/cmd/server/config.go index 4d852958..0f713b90 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "log" + "math" "os" "path/filepath" "strings" @@ -31,6 +32,50 @@ type AreaEntry struct { RegionScope string `json:"regionScope,omitempty"` } +// AreaForPoint returns the label of the most specific configured area that +// contains (lat, lon), preferring the smallest matching area when several +// nested areas overlap (e.g. a point inside both "Odense by" and "Fyn"). +// Returns ok=false for (0,0)/no-fix points or when no area matches. +func AreaForPoint(lat, lon float64, areas map[string]AreaEntry) (label string, ok bool) { + if lat == 0 && lon == 0 { + return "", false + } + bestSpan := math.MaxFloat64 + for _, a := range areas { + gf := &geofilter.Config{Polygon: a.Polygon, LatMin: a.LatMin, LatMax: a.LatMax, LonMin: a.LonMin, LonMax: a.LonMax} + if !geofilter.PassesFilter(lat, lon, gf) { + continue + } + span := areaSpan(a) + if span < bestSpan { + bestSpan = span + label = a.Label + ok = true + } + } + return label, ok +} + +// areaSpan approximates an area's size as its bounding-box extent in +// degrees², used only to rank overlapping areas from most to least specific. +func areaSpan(a AreaEntry) float64 { + var latMin, latMax, lonMin, lonMax float64 + switch { + case len(a.Polygon) > 0: + latMin, latMax = a.Polygon[0][0], a.Polygon[0][0] + lonMin, lonMax = a.Polygon[0][1], a.Polygon[0][1] + for _, p := range a.Polygon { + latMin, latMax = math.Min(latMin, p[0]), math.Max(latMax, p[0]) + lonMin, lonMax = math.Min(lonMin, p[1]), math.Max(lonMax, p[1]) + } + case a.LatMin != nil && a.LatMax != nil && a.LonMin != nil && a.LonMax != nil: + latMin, latMax, lonMin, lonMax = *a.LatMin, *a.LatMax, *a.LonMin, *a.LonMax + default: + return math.MaxFloat64 + } + return (latMax - latMin) * (lonMax - lonMin) +} + // ListLimitsConfig defines maximum row limits for list endpoints to prevent DoS. type ListLimitsConfig struct { PacketsMax int `json:"packetsMax"` diff --git a/cmd/server/config_test.go b/cmd/server/config_test.go index b78968ab..393fabbd 100644 --- a/cmd/server/config_test.go +++ b/cmd/server/config_test.go @@ -515,3 +515,60 @@ func TestApplyListLimitsDefaults(t *testing.T) { } }) } + +func TestAreaForPoint(t *testing.T) { + f := func(v float64) *float64 { return &v } + + areas := map[string]AreaEntry{ + "DK": { + Label: "Danmark (alle)", + LatMin: f(54.5), LatMax: f(57.8), + LonMin: f(8.0), LonMax: f(15.25), + }, + "FYN": { + Label: "Fyn", + LatMin: f(54.9), LatMax: f(55.65), + LonMin: f(9.85), LonMax: f(11.0), + }, + "ODE": { + Label: "Odense by", + LatMin: f(55.32), LatMax: f(55.45), + LonMin: f(10.3), LonMax: f(10.5), + }, + } + + t.Run("picks the most specific nested area", func(t *testing.T) { + label, ok := AreaForPoint(55.4047, 10.381, areas) // central Odense + if !ok || label != "Odense by" { + t.Errorf("expected Odense by, got %q (ok=%v)", label, ok) + } + }) + + t.Run("falls back to a broader area when no narrower one matches", func(t *testing.T) { + label, ok := AreaForPoint(55.0, 10.6, areas) // Fyn but not Odense + if !ok || label != "Fyn" { + t.Errorf("expected Fyn, got %q (ok=%v)", label, ok) + } + }) + + t.Run("no match outside every area", func(t *testing.T) { + _, ok := AreaForPoint(60.0, 20.0, areas) + if ok { + t.Error("expected no match far outside Denmark") + } + }) + + t.Run("zero coordinates never match", func(t *testing.T) { + _, ok := AreaForPoint(0, 0, areas) + if ok { + t.Error("expected (0,0) to never match an area") + } + }) + + t.Run("empty areas map", func(t *testing.T) { + _, ok := AreaForPoint(55.4, 10.4, map[string]AreaEntry{}) + if ok { + t.Error("expected no match with no configured areas") + } + }) +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 9172363a..755a9a92 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -3776,6 +3776,14 @@ func (s *Server) handleWardrivingStats(w http.ResponseWriter, r *http.Request) { } } + if s.cfg != nil && len(s.cfg.Areas) > 0 { + for i := range resp.GPSShares { + if label, ok := AreaForPoint(resp.GPSShares[i].Lat, resp.GPSShares[i].Lon, s.cfg.Areas); ok { + resp.GPSShares[i].Area = &label + } + } + } + s.wardrivingStatsMu.Lock() if s.wardrivingStatsCache == nil { s.wardrivingStatsCache = make(map[string]*WardrivingStatsResponse) diff --git a/cmd/server/types.go b/cmd/server/types.go index 918bfc4e..9c14bd90 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -299,6 +299,9 @@ type WardrivingGPSShare struct { Lon float64 `json:"lon"` MessageCount int `json:"messageCount"` // how many times this sender shared a position in this window LastSeen string `json:"lastSeen"` + // Area is the most specific configured area containing (Lat, Lon), set + // by the handler from config.Areas — omitted when no area matches. + Area *string `json:"area,omitempty"` } type WardrivingStatsResponse struct { diff --git a/cmd/server/wardriving_stats_test.go b/cmd/server/wardriving_stats_test.go index 17ac787c..981098f0 100644 --- a/cmd/server/wardriving_stats_test.go +++ b/cmd/server/wardriving_stats_test.go @@ -449,6 +449,64 @@ func TestHandleWardrivingStats_GPSShares(t *testing.T) { } } +// TestHandleWardrivingStats_GPSShareArea confirms a shared position gets +// tagged with the most specific configured area (config.Areas), and is left +// unset when no area matches or none are configured. +func TestHandleWardrivingStats_GPSShareArea(t *testing.T) { + srv, router := setupTestServer(t) + if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil { + t.Fatalf("clear transmissions: %v", err) + } + f := func(v float64) *float64 { return &v } + srv.cfg.Areas = map[string]AreaEntry{ + "DK": {Label: "Danmark (alle)", LatMin: f(54.5), LatMax: f(57.8), LonMin: f(8.0), LonMax: f(15.25)}, + "ODE": {Label: "Odense by", LatMin: f(55.32), LatMax: f(55.45), LonMin: f(10.3), LonMax: f(10.5)}, + } + + insertTx := func(hash, sender, mmPayload string) { + ts := time.Now().UTC().Add(-30 * time.Minute).Format(time.RFC3339) + text := sender + ": " + mmPayload + if _, err := srv.db.conn.Exec( + `INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json) VALUES (?,?,?,1,5,'#wardriving',?)`, + "aa", hash, ts, `{"sender":"`+sender+`","text":"`+text+`"}`, + ); err != nil { + t.Fatalf("insert tx %s: %v", hash, err) + } + } + insertTx("in-ode", "InOdense", "MM:c3e_zJ1rUA:55.4047,10.3810") // inside Odense (and DK) + insertTx("outside", "Elsewhere", "MM:c3e_zJ1rUA:40.0000,-74.0000") // outside every configured area + + req := httptest.NewRequest("GET", "/api/analytics/wardriving?window=24h", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var resp WardrivingStatsResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v body=%s", err, w.Body.String()) + } + + byShare := map[string]WardrivingGPSShare{} + for _, s := range resp.GPSShares { + byShare[s.Sender] = s + } + inOdense, ok := byShare["InOdense"] + if !ok { + t.Fatal("InOdense missing from GPSShares") + } + if inOdense.Area == nil || *inOdense.Area != "Odense by" { + t.Errorf("InOdense.Area = %v, want \"Odense by\" (most specific match, not \"Danmark (alle)\")", inOdense.Area) + } + elsewhere, ok := byShare["Elsewhere"] + if !ok { + t.Fatal("Elsewhere missing from GPSShares") + } + if elsewhere.Area != nil { + t.Errorf("Elsewhere.Area = %v, want nil (outside every configured area)", *elsewhere.Area) + } +} + // TestHandleWardrivingStats_InvalidWindow mirrors the existing scope-stats // window validation. func TestHandleWardrivingStats_InvalidWindow(t *testing.T) { diff --git a/public/analytics.js b/public/analytics.js index cbf36502..bf361b6e 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -5650,13 +5650,17 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf return '

No sender has shared an explicit position in this window.

'; } var rows = shares.map(function(s) { + var areaBadge = s.area + ? '' + esc(s.area) + '' + : '—'; return '' + esc(s.sender) + '' + '' + mapLinkHtml(s.lat, s.lon) + '' + + '' + areaBadge + '' + '' + s.messageCount.toLocaleString() + '' + '' + (typeof timeAgo === 'function' ? timeAgo(s.lastSeen) : s.lastSeen) + ''; }).join(''); return '' + - '' + + '' + '' + rows + '' + '
SenderPosition (most recent)Times SharedLast Seen
SenderPosition (most recent)AreaTimes SharedLast Seen
'; } diff --git a/test-analytics-wardriving-tab.js b/test-analytics-wardriving-tab.js index f8499820..c2556391 100644 --- a/test-analytics-wardriving-tab.js +++ b/test-analytics-wardriving-tab.js @@ -188,6 +188,23 @@ function makeApiStub(wardrivingResp, resolveHopsResp) { assert.ok(section.includes('href="#/map?lat=55.59743&lon=13.00128&zoom=15"'), 'the position should link to the live map centered on it'); }); + await testAsync('GPS Sharing shows the area badge when the API resolved one, and a dash otherwise', async () => { + const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse({ + gpsShares: [ + { sender: 'InOdense', lat: 55.4047, lon: 10.381, messageCount: 3, lastSeen: '2026-07-20T09:00:00Z', area: 'Odense by' }, + { sender: 'NoAreaMatch', lat: 40.0, lon: -74.0, messageCount: 1, lastSeen: '2026-07-20T09:00:00Z' }, + ], + }))); + const el = fakeEl(); + await ctx.window._analyticsRenderWardrivingTab(el); + const startIdx = el.innerHTML.indexOf('id="wardrivingGPSShares"'); + const section = el.innerHTML.slice(startIdx); + assert.ok(section.includes('Area'), 'GPS Sharing table should have an Area column'); + assert.ok(section.includes('>Odense by<'), 'a resolved area should render as a badge with its label'); + const noAreaRowIdx = section.indexOf('NoAreaMatch'); + assert.ok(noAreaRowIdx > -1, 'the unresolved-area sender should still be listed'); + }); + await testAsync('GPS Sharing shows a neutral message when nobody has shared a position', async () => { const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse({ gpsShares: [] }))); const el = fakeEl(); From 81cb1a13fc0b77443f976f7c4e41c9599e5eeb63 Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 21 Jul 2026 15:29:59 +0200 Subject: [PATCH 03/24] feat: approximate a wardriving session's area from its entry-point repeater MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For senders who never share a literal GPS fix, resolve the session's path[0] entry-point prefix to a known repeater position (only when it resolves unambiguously, same unique_prefix discipline as /api/resolve-hops) and label it with the most specific configured area — shown as a badge in the Sessions table, clearly marked approximate since it's the repeater's position, not the sender's own. Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 4 ++ cmd/server/routes.go | 40 +++++++++++ cmd/server/types.go | 13 ++++ cmd/server/wardriving_stats_test.go | 107 ++++++++++++++++++++++++++++ public/analytics.js | 8 ++- test-analytics-wardriving-tab.js | 16 +++++ 6 files changed, 186 insertions(+), 2 deletions(-) diff --git a/cmd/server/db.go b/cmd/server/db.go index 97d29455..49cc1bed 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -3494,6 +3494,10 @@ func (db *DB) buildWardrivingSessions(channel, since string) ([]WardrivingSessio } cur.EntryPointCount = len(curPrefixes) cur.ObserverCount = len(curObservers) + for p := range curPrefixes { + cur.EntryPointPrefixes = append(cur.EntryPointPrefixes, p) + } + sort.Strings(cur.EntryPointPrefixes) start, errS := time.Parse(time.RFC3339, cur.StartTime) end, errE := time.Parse(time.RFC3339, cur.EndTime) if errS == nil && errE == nil { diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 755a9a92..40fcffd5 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -2716,6 +2716,39 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) { writeJSON(w, ResolveHopsResponse{Resolved: resolved}) } +// resolveEntryPointArea approximates a wardriving session's area from its +// entry-point repeater(s): for each candidate path[0] prefix, only a +// unique_prefix match (exactly one node in the prefix map, same discipline +// as handleResolveHops) with a known position is trusted — an ambiguous +// prefix is skipped rather than guessed. Returns ok=false if no prefix +// resolves this way, or the resolved node has no position, or areas aren't +// configured. +func (s *Server) resolveEntryPointArea(prefixes []string) (label string, ok bool) { + if s.store == nil || s.cfg == nil || len(s.cfg.Areas) == 0 || len(prefixes) == 0 { + return "", false + } + s.store.mu.RLock() + _, pm := s.store.getCachedNodesAndPM() + s.store.mu.RUnlock() + if pm == nil { + return "", false + } + for _, prefix := range prefixes { + candidates, found := pm.m[strings.ToLower(prefix)] + if !found || len(candidates) != 1 { + continue + } + ni := candidates[0] + if !ni.HasGPS { + continue + } + if label, ok := AreaForPoint(ni.Lat, ni.Lon, s.cfg.Areas); ok { + return label, true + } + } + return "", false +} + func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) { region := r.URL.Query().Get("region") includeEncrypted := r.URL.Query().Get("includeEncrypted") == "true" @@ -3782,6 +3815,13 @@ func (s *Server) handleWardrivingStats(w http.ResponseWriter, r *http.Request) { resp.GPSShares[i].Area = &label } } + if s.store != nil { + for i := range resp.Sessions { + if label, ok := s.resolveEntryPointArea(resp.Sessions[i].EntryPointPrefixes); ok { + resp.Sessions[i].Area = &label + } + } + } } s.wardrivingStatsMu.Lock() diff --git a/cmd/server/types.go b/cmd/server/types.go index 9c14bd90..ac1ac5bf 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -285,6 +285,19 @@ type WardrivingSession struct { // TransmissionIDs is internal — the session's own transmission IDs, // used by the route handler to compute AirtimeMs. Never serialized. TransmissionIDs []int64 `json:"-"` + // EntryPointPrefixes is internal — the session's distinct path[0] + // hex prefixes, used by the route handler to resolve Area. Never + // serialized directly. + EntryPointPrefixes []string `json:"-"` + // Area is the most specific configured area containing the session's + // entry-point repeater — always approximate (the repeater's known + // position, not the sender's own; wardriving sessions never carry a + // literal shared GPS fix, unlike WardrivingGPSShare). Resolved by the + // route handler from EntryPointPrefixes when exactly one candidate + // node matches a prefix (same unique_prefix-only discipline as + // /api/resolve-hops) and that node has a known position. Nil when no + // prefix resolves unambiguously or areas aren't configured. + Area *string `json:"area,omitempty"` } // WardrivingGPSShare is one sender who has explicitly shared their own diff --git a/cmd/server/wardriving_stats_test.go b/cmd/server/wardriving_stats_test.go index 981098f0..fe6429b3 100644 --- a/cmd/server/wardriving_stats_test.go +++ b/cmd/server/wardriving_stats_test.go @@ -507,6 +507,113 @@ func TestHandleWardrivingStats_GPSShareArea(t *testing.T) { } } +// TestHandleWardrivingStats_SessionArea covers approximating a session's +// area from its entry-point repeater (path[0]) when the sender never shares +// a literal GPS fix — must only trust a unique_prefix match, same discipline +// as /api/resolve-hops, and must never guess when a prefix is ambiguous or +// unresolvable. +func TestHandleWardrivingStats_SessionArea(t *testing.T) { + srv, router := setupTestServer(t) + if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil { + t.Fatalf("clear transmissions: %v", err) + } + if _, err := srv.db.conn.Exec(`DELETE FROM observations`); err != nil { + t.Fatalf("clear observations: %v", err) + } + + f := func(v float64) *float64 { return &v } + srv.cfg.Areas = map[string]AreaEntry{ + "ODE": {Label: "Odense by", LatMin: f(55.32), LatMax: f(55.45), LonMin: f(10.3), LonMax: f(10.5)}, + } + + // A repeater inside the configured area, resolvable only via a unique + // full-length prefix match (mirrors TestResolveHopsAPI_UniquePrefix). + if _, err := srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)", + "ff11223344", "OdenseRepeater", 55.4047, 10.3810, "repeater"); err != nil { + t.Fatalf("insert node: %v", err) + } + // A second, ambiguous prefix: two nodes share it, so it must never + // resolve to an area even though one of them has a position. + srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)", + "ee1aaaaaaa", "AmbigA", 55.40, 10.38, "repeater") + srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)", + "ee1bbbbbbb", "AmbigB", 55.41, 10.39, "repeater") + srv.store.InvalidateNodeCache() + + now := time.Now().UTC() + insertTx := func(hash, sender string, ts time.Time) int64 { + res, err := srv.db.conn.Exec( + `INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json) VALUES (?,?,?,1,5,'#wardriving',?)`, + "aa", hash, ts.Format(time.RFC3339), `{"sender":"`+sender+`","text":"`+sender+`: MM:x"}`, + ) + if err != nil { + t.Fatalf("insert tx %s: %v", hash, err) + } + id, _ := res.LastInsertId() + return id + } + seaIdx := int64(0) + res, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`, "obsSEA", "SeattleObs", "SEA") + if err != nil { + t.Fatalf("insert observer: %v", err) + } + seaIdx, _ = res.LastInsertId() + insertObs := func(txID int64, pathJSON string) { + if _, err := srv.db.conn.Exec( + `INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,?,?,?,?)`, + txID, seaIdx, 1.0, -90.0, pathJSON, time.Now().Unix(), + ); err != nil { + t.Fatalf("insert observation: %v", err) + } + } + + txResolvable := insertTx("area1", "InOdense", now.Add(-10*time.Minute)) + insertObs(txResolvable, `["ff11223344"]`) + + txAmbiguous := insertTx("area2", "Ambiguous", now.Add(-10*time.Minute)) + insertObs(txAmbiguous, `["ee1"]`) // shared prefix of ee1aaaaaaa and ee1bbbbbbb -- 2 candidates + + txNoMatch := insertTx("area3", "NoMatch", now.Add(-10*time.Minute)) + insertObs(txNoMatch, `["deadbeef99"]`) + + req := httptest.NewRequest("GET", "/api/analytics/wardriving?window=24h", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var resp WardrivingStatsResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v body=%s", err, w.Body.String()) + } + + bySender := map[string]WardrivingSession{} + for _, s := range resp.Sessions { + bySender[s.Sender] = s + } + inOdense, ok := bySender["InOdense"] + if !ok { + t.Fatal("InOdense session missing") + } + if inOdense.Area == nil || *inOdense.Area != "Odense by" { + t.Errorf("InOdense.Area = %v, want \"Odense by\" (unique_prefix match with a known position)", inOdense.Area) + } + ambiguous, ok := bySender["Ambiguous"] + if !ok { + t.Fatal("Ambiguous session missing") + } + if ambiguous.Area != nil { + t.Errorf("Ambiguous.Area = %v, want nil (prefix matches 2 candidates, must not guess)", *ambiguous.Area) + } + noMatch, ok := bySender["NoMatch"] + if !ok { + t.Fatal("NoMatch session missing") + } + if noMatch.Area != nil { + t.Errorf("NoMatch.Area = %v, want nil (prefix matches no known node)", *noMatch.Area) + } +} + // TestHandleWardrivingStats_InvalidWindow mirrors the existing scope-stats // window validation. func TestHandleWardrivingStats_InvalidWindow(t *testing.T) { diff --git a/public/analytics.js b/public/analytics.js index 43e1b04a..d2883880 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -5545,16 +5545,20 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf } var shown = expanded ? sessions : sessions.slice(0, TOP_N_LIMIT); var rows = shown.map(function(s) { + var areaBadge = s.area + ? '' + esc(s.area) + '' + : '—'; return '' + senderTriggerHtml(s.sender, s.startTime, s.endTime) + '' + '' + (typeof timeAgo === 'function' ? timeAgo(s.startTime) : s.startTime) + '' + '' + formatSessionDuration(s.durationMinutes) + '' + '' + s.messageCount.toLocaleString() + '' + '' + s.entryPointCount.toLocaleString() + '' + '' + s.observerCount.toLocaleString() + '' + + '' + areaBadge + '' + '' + formatAirtimeMs(s.airtimeMs) + ''; }).join(''); - return '' + - '' + + return '
SenderStartedDurationMessagesEntry PointsObserversAirtime
' + + '' + '' + rows + '' + '
SenderStartedDurationMessagesEntry PointsObserversAreaAirtime
' + topNToggleHtml(sessions.length, expanded, 'sessions'); } diff --git a/test-analytics-wardriving-tab.js b/test-analytics-wardriving-tab.js index c227c190..d281ed9f 100644 --- a/test-analytics-wardriving-tab.js +++ b/test-analytics-wardriving-tab.js @@ -351,6 +351,22 @@ function makeApiStub(wardrivingResp, resolveHopsResp) { assert.ok(section.includes('—'), 'a session with no airtime data (DB-only mode) should show a dash, not blank/null'); }); + await testAsync('Sessions table shows the approximate entry-point area, and a dash when unresolved', async () => { + const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse({ + sessions: [ + { sender: 'Alice', startTime: '2026-07-20T09:00:00Z', endTime: '2026-07-20T09:00:00Z', durationMinutes: 0, messageCount: 1, entryPointCount: 1, observerCount: 1, airtimeMs: 245, area: 'Odense by' }, + { sender: 'Bob', startTime: '2026-07-20T08:30:00Z', endTime: '2026-07-20T08:30:00Z', durationMinutes: 0, messageCount: 1, entryPointCount: 1, observerCount: 1, airtimeMs: null }, + ], + }))); + const el = fakeEl(); + await ctx.window._analyticsRenderWardrivingTab(el); + const startIdx = el.innerHTML.indexOf('id="wardrivingSessions"'); + const endIdx = el.innerHTML.indexOf('id="wardrivingEntryPoints"'); + const section = el.innerHTML.slice(startIdx, endIdx); + assert.ok(section.includes('Area'), 'Sessions table should have an Area column'); + assert.ok(section.includes('>Odense by<'), 'a resolved area should render as a badge'); + }); + await testAsync('Entry Points resolves unique_prefix repeaters and folds ambiguous into one bucket', async () => { const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse(), { resolved: { From 206dc5d3ff747509622a16062eb0d3a24e5a181e Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 21 Jul 2026 16:24:16 +0200 Subject: [PATCH 04/24] feat: show area labels next to region codes on the Scopes tab Expose regionScope on /api/config/areas and use it to enrich Region Utilization's unused-region list, Repeaters/Nodes by Region, and Bridge Repeaters with the linked area's human name (e.g. "dk-aarhus (Aarhus by)") instead of a bare hashRegion code. Co-Authored-By: Claude Sonnet 5 --- cmd/server/area_filter_test.go | 31 ++++++++++++++++++++++++++++++ cmd/server/routes.go | 7 ++++--- public/analytics.js | 35 +++++++++++++++++++++++++++++++--- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/cmd/server/area_filter_test.go b/cmd/server/area_filter_test.go index e08082c0..a0f55f7c 100644 --- a/cmd/server/area_filter_test.go +++ b/cmd/server/area_filter_test.go @@ -296,6 +296,37 @@ func TestHandleConfigAreas(t *testing.T) { } } +func TestHandleConfigAreas_RegionScope(t *testing.T) { + db := setupTestDBv2(t) + cfg := &Config{Areas: map[string]AreaEntry{ + "AAR": {Label: "Aarhus by", RegionScope: "dk-aarhus"}, + "MST": {Label: "Maastricht"}, + }} + + r := mux.NewRouter() + srv := &Server{db: db, cfg: cfg} + r.HandleFunc("/api/config/areas", srv.handleConfigAreas).Methods("GET") + + req := httptest.NewRequest(http.MethodGet, "/api/config/areas", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + var result []map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("decode: %v", err) + } + byKey := map[string]map[string]interface{}{} + 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"]) + } + if _, present := byKey["MST"]["regionScope"]; present { + t.Errorf("MST should omit regionScope entirely (unset), got %v", byKey["MST"]["regionScope"]) + } +} + func TestHandleConfigAreasEmpty(t *testing.T) { db := setupTestDBv2(t) cfg := &Config{} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 40fcffd5..38e618f2 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -471,15 +471,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"` + Key string `json:"key"` + Label string `json:"label"` + RegionScope string `json:"regionScope,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}) + result = append(result, areaListEntry{Key: k, Label: v.Label, RegionScope: v.RegionScope}) } writeJSON(w, result) } diff --git a/public/analytics.js b/public/analytics.js index d2883880..9e5e18d0 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -4812,7 +4812,36 @@ 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. + var regionAreaLabels = null; + async function loadRegionAreaLabels() { + if (regionAreaLabels) return regionAreaLabels; + regionAreaLabels = {}; + try { + var areas = await api('/config/areas', { ttl: CLIENT_TTL.nodeDetail }); + (areas || []).forEach(function(a) { + if (a.regionScope) regionAreaLabels[a.regionScope.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. + function regionAreaLabel(rawRegionName) { + if (!regionAreaLabels || !rawRegionName) return null; + var key = String(rawRegionName).replace(/^#/, '').toLowerCase(); + return regionAreaLabels[key] || null; + } + function regionCodeHtml(rawRegionName) { + var label = regionAreaLabel(rawRegionName); + return '' + esc(rawRegionName) + '' + (label ? ' (' + esc(label) + ')' : ''); + } + async function updateData(d, w) { + await loadRegionAreaLabels(); var s = d.summary; // #1838: denominator = transport-carrying transmissions (route_type 0,3). // Unscoped now includes non-transport routes (1,2) which are inherently @@ -5046,7 +5075,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf var unused = d.unusedRegions || []; var usedCount = configured - unused.length; var unusedPct = (unused.length / configured * 100).toFixed(1); - var listHtml = unused.map(function(name) { return esc(name); }).join(', '); + var listHtml = unused.map(function(name) { return regionCodeHtml(name); }).join(', '); setSectionHtml(utilEl, detailsSection( 'Region Utilization (' + usedCount.toLocaleString() + ' of ' + configured.toLocaleString() + ' used)', 'All-time, not limited to the window above — has this configured region ever matched a message still in retention?', @@ -5084,7 +5113,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf return '' + esc(rp.name) + ''; }).join(', '); return '
' + - '' + esc(g.region) + ' — ' + g.count.toLocaleString() + ' ' + unitLabel + (g.count === 1 ? '' : 's') + '' + + '' + regionCodeHtml(g.region) + ' — ' + g.count.toLocaleString() + ' ' + unitLabel + (g.count === 1 ? '' : 's') + '' + '
' + links + '
' + '
'; }).join('') @@ -5110,7 +5139,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf var bridgeBody; if (bridges.length > 0) { var bridgeRows = bridges.map(function(b) { - var regionList = b.regions.map(function(r) { return '' + esc(r) + ''; }).join(', '); + var regionList = b.regions.map(function(r) { return regionCodeHtml(r); }).join(', '); return '' + '' + esc(b.name) + '' + '' + b.count + '' + From 8da5f8312e71fbb9ad275fdc8e4f30b03f166d68 Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 21 Jul 2026 16:40:58 +0200 Subject: [PATCH 05/24] feat: add Scope Adoption by Area to the Scopes tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New geographic view, independent of the raw hashRegion-code-based Region Utilization: buckets every positioned node by its configured area (AreaKeyForPoint) and tallies how many have any default_scope at all, and how many specifically match the area's own linked region. Surfaces gaps Region Utilization can't see, since that only knows about region strings that already appeared in traffic — a real area with real nodes that never produced a single scoped message is invisible there but shows up here as 0% adoption. Co-Authored-By: Claude Sonnet 5 --- cmd/server/config.go | 21 ++++++++-- cmd/server/db.go | 85 +++++++++++++++++++++++++++++++++++++++ cmd/server/db_test.go | 56 ++++++++++++++++++++++++++ cmd/server/routes.go | 8 ++++ cmd/server/routes_test.go | 49 ++++++++++++++++++++++ cmd/server/types.go | 27 +++++++++++++ public/analytics.js | 38 +++++++++++++++++ 7 files changed, 281 insertions(+), 3 deletions(-) diff --git a/cmd/server/config.go b/cmd/server/config.go index 0f713b90..839247a3 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -37,11 +37,25 @@ type AreaEntry struct { // nested areas overlap (e.g. a point inside both "Odense by" and "Fyn"). // Returns ok=false for (0,0)/no-fix points or when no area matches. func AreaForPoint(lat, lon float64, areas map[string]AreaEntry) (label string, ok bool) { + _, label, ok = areaMatchForPoint(lat, lon, areas) + return label, ok +} + +// 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 +// the name. +func AreaKeyForPoint(lat, lon float64, areas map[string]AreaEntry) (key string, ok bool) { + key, _, ok = areaMatchForPoint(lat, lon, areas) + return key, ok +} + +func areaMatchForPoint(lat, lon float64, areas map[string]AreaEntry) (key, label string, ok bool) { if lat == 0 && lon == 0 { - return "", false + return "", "", false } bestSpan := math.MaxFloat64 - for _, a := range areas { + for k, a := range areas { gf := &geofilter.Config{Polygon: a.Polygon, LatMin: a.LatMin, LatMax: a.LatMax, LonMin: a.LonMin, LonMax: a.LonMax} if !geofilter.PassesFilter(lat, lon, gf) { continue @@ -49,11 +63,12 @@ func AreaForPoint(lat, lon float64, areas map[string]AreaEntry) (label string, o span := areaSpan(a) if span < bestSpan { bestSpan = span + key = k label = a.Label ok = true } } - return label, ok + return key, label, ok } // areaSpan approximates an area's size as its bounding-box extent in diff --git a/cmd/server/db.go b/cmd/server/db.go index 49cc1bed..3f837352 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -2239,6 +2239,91 @@ 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). +type nodeAreaScopeInput struct { + Lat, Lon float64 + DefaultScope string +} + +// GetNodesForScopeAdoption returns every node with a real GPS fix (0,0 +// excluded, same convention as geofilter.PassesFilter) and its +// default_scope, for computeScopeAdoptionByArea to bucket by configured +// 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" + if db.hasDefaultScope { + query += ", default_scope" + } + query += " FROM nodes WHERE lat IS NOT NULL AND lon IS NOT NULL AND lat != 0 AND lon != 0" + rows, err := db.conn.Query(query) + if err != nil { + return nil, fmt.Errorf("nodes for scope adoption query: %w", err) + } + defer rows.Close() + var out []nodeAreaScopeInput + for rows.Next() { + var lat, lon float64 + var scope sql.NullString + var scanErr error + if db.hasDefaultScope { + scanErr = rows.Scan(&lat, &lon, &scope) + } else { + scanErr = rows.Scan(&lat, &lon) + } + if scanErr != nil { + continue + } + out = append(out, nodeAreaScopeInput{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 { + counts := make(map[string]*AreaScopeAdoption) + for _, n := range nodes { + key, ok := AreaKeyForPoint(n.Lat, n.Lon, areas) + if !ok { + continue + } + c, exists := counts[key] + if !exists { + a := areas[key] + c = &AreaScopeAdoption{AreaKey: key, Label: a.Label, RegionScope: a.RegionScope} + 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++ + } + } + } + result := make([]AreaScopeAdoption, 0, len(counts)) + for _, c := range counts { + result = append(result, *c) + } + sort.Slice(result, func(i, j int) bool { + if result[i].TotalNodes != result[j].TotalNodes { + return result[i].TotalNodes > result[j].TotalNodes + } + return result[i].Label < result[j].Label + }) + return result +} + // QueryMultiNodePackets returns transmissions referencing any of the given pubkeys. func (db *DB) QueryMultiNodePackets(pubkeys []string, limit, offset int, order, since, until string) (*PacketResult, error) { if len(pubkeys) == 0 { diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index 5ac18c08..7bc73753 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -2327,3 +2327,59 @@ func TestLoadIndexesRelayHopsFromResolvedPath(t *testing.T) { t.Errorf("relay byNode entry has wrong hash: %s", store.byNode[relayPubkey][0].Hash) } } + +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 + } + + 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.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 + } + + got := computeScopeAdoptionByArea(nodes, areas) + if len(got) != 2 { + t.Fatalf("got %d areas, want 2 (ODE and GOT) -- result: %+v", len(got), got) + } + byKey := map[string]AreaScopeAdoption{} + for _, a := range got { + byKey[a.AreaKey] = a + } + + ode := byKey["ODE"] + if ode.TotalNodes != 3 { + t.Errorf("ODE.TotalNodes = %d, want 3", ode.TotalNodes) + } + if ode.NodesWithAnyScope != 2 { + t.Errorf("ODE.NodesWithAnyScope = %d, want 2 (one has no scope at all)", ode.NodesWithAnyScope) + } + if ode.NodesMatchingArea != 1 { + t.Errorf("ODE.NodesMatchingArea = %d, want 1 (only the dk-fyn-odense one matches, the dk-aarhus one doesn't)", ode.NodesMatchingArea) + } + + got2 := byKey["GOT"] + if got2.TotalNodes != 2 { + t.Errorf("GOT.TotalNodes = %d, want 2", got2.TotalNodes) + } + if got2.NodesWithAnyScope != 1 { + 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) + } +} + +func TestComputeScopeAdoptionByArea_Empty(t *testing.T) { + got := computeScopeAdoptionByArea(nil, map[string]AreaEntry{"DK": {Label: "Danmark"}}) + 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 38e618f2..34c5d598 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -3744,6 +3744,14 @@ func (s *Server) handleScopeStats(w http.ResponseWriter, r *http.Request) { log.Printf("WARN GetChannelMessageScopeStats: %v", err) } + if s.cfg != nil && len(s.cfg.Areas) > 0 { + if nodes, err := s.db.GetNodesForScopeAdoption(); err == nil { + resp.ScopeAdoptionByArea = computeScopeAdoptionByArea(nodes, s.cfg.Areas) + } else { + log.Printf("WARN GetNodesForScopeAdoption: %v", err) + } + } + if adoption, err := s.db.GetChannelScopeAdoption(window); err == nil { resp.ChannelScopeAdoption = adoption } else { diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index e6619976..c5dba4e0 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -4786,6 +4786,55 @@ func TestHandleScopeStats_OriginatingNodesByRegion(t *testing.T) { } } +func TestHandleScopeStats_ScopeAdoptionByArea(t *testing.T) { + srv, _ := setupTestServer(t) + if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil { + t.Fatalf("add scope_name column: %v", err) + } + srv.db.hasScopeName = true + if !srv.db.hasDefaultScope { + if _, err := srv.db.conn.Exec(`ALTER TABLE nodes ADD COLUMN default_scope TEXT DEFAULT NULL`); err != nil { + t.Fatalf("add default_scope column: %v", err) + } + srv.db.hasDefaultScope = true + } + 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)}, + } + + insertNode := func(pk, defaultScope string, lat, lon float64) { + if _, err := srv.db.conn.Exec( + `INSERT INTO nodes (public_key, name, role, default_scope, lat, lon) VALUES (?, ?, 'repeater', ?, ?, ?)`, + pk, pk, defaultScope, lat, lon, + ); err != nil { + t.Fatalf("seed node %s: %v", pk, err) + } + } + insertNode("odematch01", "#dk-fyn-odense", 55.4047, 10.3810) + insertNode("odewrong01", "#dk-aarhus", 55.40, 10.40) + insertNode("odenoscope1", "", 55.41, 10.41) + + req := httptest.NewRequest("GET", "/api/scope-stats?window=24h", nil) + w := httptest.NewRecorder() + srv.handleScopeStats(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var resp ScopeStatsResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.ScopeAdoptionByArea) != 1 { + t.Fatalf("scopeAdoptionByArea = %+v, want 1 entry (ODE)", resp.ScopeAdoptionByArea) + } + ode := resp.ScopeAdoptionByArea[0] + if ode.AreaKey != "ODE" || ode.TotalNodes != 3 || ode.NodesWithAnyScope != 2 || ode.NodesMatchingArea != 1 { + t.Errorf("ScopeAdoptionByArea[0] = %+v, want AreaKey=ODE TotalNodes=3 NodesWithAnyScope=2 NodesMatchingArea=1", ode) + } +} + func TestHandleScopeStatsInvalidWindow(t *testing.T) { srv, _ := setupTestServer(t) if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil { diff --git a/cmd/server/types.go b/cmd/server/types.go index ac1ac5bf..25e1a3ae 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -191,6 +191,33 @@ type ScopeStatsResponse struct { // HourlyActivityByRegion is window-scoped like Summary/TimeSeries // above — see ScopeHourlyActivity doc. HourlyActivityByRegion []ScopeHourlyActivity `json:"hourlyActivityByRegion,omitempty"` + // ScopeAdoptionByArea buckets every positioned node by its configured + // geographic area (config.Areas, AreaKeyForPoint) and tallies scope + // adoption within that area — independent of whether the raw + // hashRegion codes above are "used" at all. Surfaces gaps like "34 + // real nodes here, 0 have ever configured a scope" that + // UnusedRegions/RepeatersByRegion can't see, since those only know + // about region strings that already appeared in traffic. All-time, + // like the other Regions-tab sections. Omitted when no areas are + // configured. + ScopeAdoptionByArea []AreaScopeAdoption `json:"scopeAdoptionByArea,omitempty"` +} + +// 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"` + // NodesWithAnyScope is how many of TotalNodes have ANY default_scope + // configured, regardless of which region it is. + 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). + NodesMatchingArea int `json:"nodesMatchingArea,omitempty"` } type RepeaterRef struct { diff --git a/public/analytics.js b/public/analytics.js index 9e5e18d0..23039e03 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -4556,6 +4556,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf '' + '' + '
' + + '
' + '
' + '
' + '
' + @@ -5064,6 +5065,43 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf } } + // Scope Adoption by Area: buckets every positioned node by its + // configured geographic area and asks "does this real, physical + // community actually use the region-scope system at all" — + // independent of which raw hashRegion codes have ever appeared in + // traffic. Region Utilization below only knows about region strings + // that already showed up in a message; a real area with real nodes + // that has NEVER produced a single region-scoped message is + // invisible there. This section catches exactly that gap. + var areaAdoptEl = document.getElementById('scopes-area-adoption'); + if (areaAdoptEl) { + var byArea = d.scopeAdoptionByArea || []; + if (byArea.length > 0) { + var areaRows = byArea.map(function(a) { + var withScope = a.nodesWithAnyScope.toLocaleString() + ' (' + pct(a.nodesWithAnyScope, a.totalNodes) + ')'; + var matching = a.regionScope + ? a.nodesMatchingArea.toLocaleString() + ' (' + pct(a.nodesMatchingArea, a.totalNodes) + ')' + + ' of #' + esc(a.regionScope) + '' + : 'no region linked to this area'; + return '' + esc(a.label) + '' + + '' + a.totalNodes.toLocaleString() + '' + + '' + withScope + '' + + '' + matching + ''; + }).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.', + '' + + '' + + '' + areaRows + '' + + '
AreaNodesWith Any ScopeMatching Area’s Own Region
', + 'scope-adoption-by-area' + )); + } else { + areaAdoptEl.innerHTML = ''; + } + } + // Region utilization: how much of the configured hashRegions list // has never actually matched anything — all-time (not window-scoped), // so it doesn't fluctuate with the 1h/24h/7d selector above. Only From bb65e0893bc86174eed032cc1e5c0da5a64ba7ff Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 21 Jul 2026 16:52:22 +0200 Subject: [PATCH 06/24] fix: Scope Adoption by Area crashed the whole Scopes tab on a real 0 NodesMatchingArea had omitempty, so a genuine 0 count (the meaningful case this feature exists to surface -- "linked region, zero adoption") dropped out of the JSON entirely. The frontend read undefined off the missing key and called .toLocaleString() on it, throwing and blanking Region Utilization/Repeaters by Region/Bridge Repeaters along with it since they render later in the same updateData pass. Drop omitempty, add a defensive `|| 0` on the client, and a regression test that decodes into a raw map (not the typed struct, which hides this by zero-valuing the field regardless of whether the key was present). Caught live on stg immediately after deploying the feature -- verified via browser before/after, not just the unit test. Co-Authored-By: Claude Sonnet 5 --- cmd/server/routes_test.go | 59 +++++++++++++++++++++++++++++++++++++++ cmd/server/types.go | 6 ++-- public/analytics.js | 3 +- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index c5dba4e0..b0d6bf1f 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -4835,6 +4835,65 @@ func TestHandleScopeStats_ScopeAdoptionByArea(t *testing.T) { } } +// TestHandleScopeStats_ScopeAdoptionByArea_ZeroMatchKeyPresent is a +// regression test: NodesMatchingArea must NOT have `omitempty`, or a +// genuine 0 count (a real, meaningful value — "this area has a linked +// region but nobody uses it") silently disappears from the raw JSON. The +// frontend reads a.nodesMatchingArea directly and calls .toLocaleString() +// on it — an absent key deserializes to `undefined` in JS, not 0, which +// throws and breaks the whole Scopes tab render. Decodes into a raw map +// (not the typed struct, which would hide this by defaulting to the zero +// value regardless of whether the key was present). +func TestHandleScopeStats_ScopeAdoptionByArea_ZeroMatchKeyPresent(t *testing.T) { + srv, _ := setupTestServer(t) + if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil { + t.Fatalf("add scope_name column: %v", err) + } + srv.db.hasScopeName = true + if !srv.db.hasDefaultScope { + if _, err := srv.db.conn.Exec(`ALTER TABLE nodes ADD COLUMN default_scope TEXT DEFAULT NULL`); err != nil { + t.Fatalf("add default_scope column: %v", err) + } + srv.db.hasDefaultScope = true + } + 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)}, + } + // 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 + // case this feature was built to surface. + if _, err := srv.db.conn.Exec( + `INSERT INTO nodes (public_key, name, role, default_scope, lat, lon) VALUES ('aar00000001', 'AarhusNode', 'repeater', '#dk', 56.15, 10.15)`, + ); err != nil { + t.Fatalf("seed node: %v", err) + } + + req := httptest.NewRequest("GET", "/api/scope-stats?window=24h", nil) + w := httptest.NewRecorder() + srv.handleScopeStats(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + var raw map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&raw); err != nil { + t.Fatalf("decode: %v", err) + } + areas, ok := raw["scopeAdoptionByArea"].([]interface{}) + if !ok || len(areas) != 1 { + t.Fatalf("scopeAdoptionByArea = %v, want 1 entry", raw["scopeAdoptionByArea"]) + } + area := areas[0].(map[string]interface{}) + val, present := area["nodesMatchingArea"] + if !present { + t.Fatal("nodesMatchingArea key is missing from the JSON entirely — omitempty regression, this crashes the frontend") + } + if val != float64(0) { + t.Errorf("nodesMatchingArea = %v, want 0", val) + } +} + func TestHandleScopeStatsInvalidWindow(t *testing.T) { srv, _ := setupTestServer(t) if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil { diff --git a/cmd/server/types.go b/cmd/server/types.go index 25e1a3ae..89c08988 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -216,8 +216,10 @@ type AreaScopeAdoption struct { // 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). - NodesMatchingArea int `json:"nodesMatchingArea,omitempty"` + // 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"` } type RepeaterRef struct { diff --git a/public/analytics.js b/public/analytics.js index 23039e03..e5930133 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -5079,8 +5079,9 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf if (byArea.length > 0) { var areaRows = byArea.map(function(a) { var withScope = a.nodesWithAnyScope.toLocaleString() + ' (' + pct(a.nodesWithAnyScope, a.totalNodes) + ')'; + var matchCount = a.nodesMatchingArea || 0; var matching = a.regionScope - ? a.nodesMatchingArea.toLocaleString() + ' (' + pct(a.nodesMatchingArea, a.totalNodes) + ')' + + ? matchCount.toLocaleString() + ' (' + pct(matchCount, a.totalNodes) + ')' + ' of #' + esc(a.regionScope) + '' : 'no region linked to this area'; return '' + esc(a.label) + '' + From 9661fd0f4da8630ace554b964a9a9fce07e1a3e0 Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 21 Jul 2026 17:11:26 +0200 Subject: [PATCH 07/24] 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 + '' + From b21803db220638db22203e35420f9150ed3b4688 Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 21 Jul 2026 17:32:07 +0200 Subject: [PATCH 08/24] feat: show which specific nodes support/don't support an area's region dborup wanted per-node visibility, not just aggregate counts: for each area with a linked region, list the actual nodes that support it (own default_scope or ever relayed it) vs. the ones that sit there but don't. Replaces the summary table with expandable per-area groups (same pattern as Repeaters by Region), each showing a Supporting / Not Supporting node list with links. Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 43 +++++++++++++++++++++++------- cmd/server/db_test.go | 12 +++++++++ cmd/server/types.go | 7 +++++ public/analytics.js | 61 ++++++++++++++++++++++++++----------------- 4 files changed, 89 insertions(+), 34 deletions(-) diff --git a/cmd/server/db.go b/cmd/server/db.go index 4978d93d..97a67788 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -2239,12 +2239,14 @@ func (db *DB) GetNodesByDefaultScope() (map[string][]RepeaterRef, error) { return result, rows.Err() } -// 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. +// nodeAreaScopeInput is one node's position + name + 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 + Name string Lat, Lon float64 DefaultScope string } @@ -2255,7 +2257,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 public_key, lat, lon" + query := "SELECT public_key, name, lat, lon" if db.hasDefaultScope { query += ", default_scope" } @@ -2268,18 +2270,23 @@ func (db *DB) GetNodesForScopeAdoption() ([]nodeAreaScopeInput, error) { var out []nodeAreaScopeInput for rows.Next() { var pk string + var name sql.NullString var lat, lon float64 var scope sql.NullString var scanErr error if db.hasDefaultScope { - scanErr = rows.Scan(&pk, &lat, &lon, &scope) + scanErr = rows.Scan(&pk, &name, &lat, &lon, &scope) } else { - scanErr = rows.Scan(&pk, &lat, &lon) + scanErr = rows.Scan(&pk, &name, &lat, &lon) } if scanErr != nil { continue } - out = append(out, nodeAreaScopeInput{PublicKey: strings.ToLower(pk), Lat: lat, Lon: lon, DefaultScope: scope.String}) + displayName := pk + if name.Valid && name.String != "" { + displayName = name.String + } + out = append(out, nodeAreaScopeInput{PublicKey: strings.ToLower(pk), Name: displayName, Lat: lat, Lon: lon, DefaultScope: scope.String}) } return out, rows.Err() } @@ -2301,6 +2308,11 @@ func (db *DB) GetNodesForScopeAdoption() ([]nodeAreaScopeInput, error) { // 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. +// +// For areas with a RegionScope 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. func computeScopeAdoptionByArea(nodes []nodeAreaScopeInput, areas map[string]AreaEntry, relayInfo map[string]RepeaterRelayInfo) []AreaScopeAdoption { counts := make(map[string]*AreaScopeAdoption) for _, n := range nodes { @@ -2340,12 +2352,23 @@ func computeScopeAdoptionByArea(nodes []nodeAreaScopeInput, areas map[string]Are if hasAnyScope { c.NodesWithAnyScope++ } - if regionMatch { - c.NodesMatchingArea++ + // 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 != "" { + ref := RepeaterRef{Name: n.Name, PublicKey: n.PublicKey} + if regionMatch { + c.NodesMatchingArea++ + c.Matching = append(c.Matching, ref) + } else { + c.NotMatching = append(c.NotMatching, ref) + } } } result := make([]AreaScopeAdoption, 0, len(counts)) for _, c := range counts { + sort.Slice(c.Matching, func(i, j int) bool { return c.Matching[i].Name < c.Matching[j].Name }) + sort.Slice(c.NotMatching, func(i, j int) bool { return c.NotMatching[i].Name < c.NotMatching[j].Name }) result = append(result, *c) } sort.Slice(result, func(i, j int) bool { diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index addd29c6..33344aa0 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -2364,6 +2364,9 @@ func TestComputeScopeAdoptionByArea(t *testing.T) { if ode.NodesMatchingArea != 1 { 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) + } got2 := byKey["GOT"] if got2.TotalNodes != 2 { @@ -2375,6 +2378,9 @@ func TestComputeScopeAdoptionByArea(t *testing.T) { if got2.NodesMatchingArea != 0 { t.Errorf("GOT.NodesMatchingArea = %d, want 0 (area has no RegionScope 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) + } } // TestComputeScopeAdoptionByArea_RelayedRegionCounts covers the case @@ -2419,6 +2425,12 @@ func TestComputeScopeAdoptionByArea_RelayedRegionCounts(t *testing.T) { if h.NodesMatchingArea != 1 { t.Errorf("NodesMatchingArea = %d, want 1 (relayer01 relays dk-horsens even though its default_scope is #dk)", h.NodesMatchingArea) } + if len(h.Matching) != 1 || h.Matching[0].PublicKey != "relayer01" { + t.Errorf("Matching = %v, want just relayer01", h.Matching) + } + if len(h.NotMatching) != 2 { + t.Errorf("NotMatching = %v, want 2 (plainnode1 and relayerother)", h.NotMatching) + } } func TestComputeScopeAdoptionByArea_Empty(t *testing.T) { diff --git a/cmd/server/types.go b/cmd/server/types.go index 67aa9ac6..598edf82 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -224,6 +224,13 @@ type AreaScopeAdoption struct { // 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). + Matching []RepeaterRef `json:"matching,omitempty"` + NotMatching []RepeaterRef `json:"notMatching,omitempty"` } type RepeaterRef struct { diff --git a/public/analytics.js b/public/analytics.js index 3db9355d..e91c6f8f 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -5065,37 +5065,50 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf } } - // Scope Adoption by Area: buckets every positioned node by its - // configured geographic area and asks "does this real, physical - // community actually use the region-scope system at all" — - // independent of which raw hashRegion codes have ever appeared in - // traffic. Region Utilization below only knows about region strings - // that already showed up in a message; a real area with real nodes - // that has NEVER produced a single region-scoped message is - // invisible there. This section catches exactly that gap. + // Scope Adoption by Area: for each configured geographic area, which + // SPECIFIC nodes actually support the region that area is linked to + // (own default_scope OR ever relayed it — a repeater can carry + // dk-oj traffic and thereby support Østjylland without configuring + // dk-oj as its own scope) and which sit there but don't. Independent + // of Region Utilization below, which only knows about region + // strings that already appeared in a message — a real area with + // real nodes that never produced one is invisible there. var areaAdoptEl = document.getElementById('scopes-area-adoption'); if (areaAdoptEl) { var byArea = d.scopeAdoptionByArea || []; if (byArea.length > 0) { - var areaRows = byArea.map(function(a) { - var withScope = a.nodesWithAnyScope.toLocaleString() + ' (' + pct(a.nodesWithAnyScope, a.totalNodes) + ')'; - var matchCount = a.nodesMatchingArea || 0; - var matching = a.regionScope - ? matchCount.toLocaleString() + ' (' + pct(matchCount, a.totalNodes) + ')' + - ' of #' + esc(a.regionScope) + '' - : 'no region linked to this area'; - return '' + - '' + - '' + - ''; + function nodeLinks(refs) { + return (refs || []).map(function(r) { + return '' + esc(r.name) + ''; + }).join(', '); + } + var areaGroups = byArea.map(function(a) { + var summary, body; + if (a.regionScope) { + var matchCount = a.nodesMatchingArea || 0; + summary = esc(a.label) + ' — ' + matchCount.toLocaleString() + ' of ' + a.totalNodes.toLocaleString() + + ' support #' + esc(a.regionScope) + ' (' + pct(matchCount, a.totalNodes) + ')'; + body = + '
Supporting (' + (a.matching || []).length + '): ' + + (a.matching && a.matching.length ? nodeLinks(a.matching) : 'none') + + '
' + + '
Not supporting (' + (a.notMatching || []).length + '): ' + + (a.notMatching && a.notMatching.length ? nodeLinks(a.notMatching) : 'none') + + '
'; + } 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.

'; + } + return '
' + + '' + summary + '' + + '
' + body + '
' + + '
'; }).join(''); setSectionHtml(areaAdoptEl, detailsSection( 'Scope Adoption by Area (' + byArea.length.toLocaleString() + ' areas)', - '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.', - '
AreaNodesWith Any ScopeMatching Area’s Own Region
' + esc(a.label) + '' + a.totalNodes.toLocaleString() + '' + withScope + '' + matching + '
' + - '' + - '' + areaRows + '' + - '
AreaNodesWith Any ScopeMatching Area’s Own Region
', + 'All-time — expand an area to see exactly which nodes support its linked region (own default_scope or ever relayed it) and which don\'t.', + areaGroups, 'scope-adoption-by-area' )); } else { From 6b31a6ab12307e30c2348792fa6a375021b4e475 Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 21 Jul 2026 17:49:31 +0200 Subject: [PATCH 09/24] feat: Scope Adoption by Area rolls up into containing areas dborup: "Danmark (alle)" showed almost nothing (0 of 10 support) even though most real nodes use the generic #dk scope. Root cause: the per-area node count used AreaKeyForPoint, which picks only the single most-specific area for each node -- a node in "Odense by" never also counted toward the broader "Fyn" or "Danmark (alle)" it geographically sits inside, so a country-level area only ever saw the leftovers no smaller area had already claimed. New AreaKeysForPoint returns every containing area (not just the best match), used by computeScopeAdoptionByArea so a node now counts toward all of its containing areas -- Danmark (alle) genuinely aggregates every Danish sub-area's nodes now, not just stragglers. AreaForPoint/AreaKeyForPoint (single-match, used by the GPS-share and session area badges) are unchanged. Co-Authored-By: Claude Sonnet 5 --- cmd/server/config.go | 22 ++++++++++ cmd/server/config_test.go | 25 +++++++++++ cmd/server/db.go | 92 +++++++++++++++++++-------------------- cmd/server/db_test.go | 44 +++++++++++++++++++ public/analytics.js | 2 +- 5 files changed, 138 insertions(+), 47 deletions(-) diff --git a/cmd/server/config.go b/cmd/server/config.go index 839247a3..46cd5e9a 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -50,6 +50,28 @@ func AreaKeyForPoint(lat, lon float64, areas map[string]AreaEntry) (key string, return key, ok } +// AreaKeysForPoint returns every configured area's key whose geometry +// contains (lat, lon) — unlike AreaForPoint/AreaKeyForPoint (single +// most-specific match, for per-node badges), this returns ALL of them, so +// a point inside both "Aarhus by" and the broader "Jylland"/"Danmark +// (alle)" counts toward all three. For aggregate reporting +// (computeScopeAdoptionByArea) where a country-level area should roll up +// its sub-areas' totals rather than only catching leftovers no smaller +// area claimed. Returns nil for (0,0)/no-fix points. +func AreaKeysForPoint(lat, lon float64, areas map[string]AreaEntry) []string { + if lat == 0 && lon == 0 { + return nil + } + var keys []string + for k, a := range areas { + gf := &geofilter.Config{Polygon: a.Polygon, LatMin: a.LatMin, LatMax: a.LatMax, LonMin: a.LonMin, LonMax: a.LonMax} + if geofilter.PassesFilter(lat, lon, gf) { + keys = append(keys, k) + } + } + return keys +} + func areaMatchForPoint(lat, lon float64, areas map[string]AreaEntry) (key, label string, ok bool) { if lat == 0 && lon == 0 { return "", "", false diff --git a/cmd/server/config_test.go b/cmd/server/config_test.go index 393fabbd..27b1fc44 100644 --- a/cmd/server/config_test.go +++ b/cmd/server/config_test.go @@ -571,4 +571,29 @@ func TestAreaForPoint(t *testing.T) { t.Error("expected no match with no configured areas") } }) + + t.Run("AreaKeysForPoint returns every containing area, not just the most specific", func(t *testing.T) { + keys := AreaKeysForPoint(55.4047, 10.381, areas) // central Odense -- also inside Fyn and DK + want := map[string]bool{"DK": true, "FYN": true, "ODE": true} + if len(keys) != len(want) { + t.Fatalf("got %v, want all 3 of %v", keys, want) + } + for _, k := range keys { + if !want[k] { + t.Errorf("unexpected key %q in %v", k, keys) + } + } + }) + + t.Run("AreaKeysForPoint returns nil outside every area", func(t *testing.T) { + if keys := AreaKeysForPoint(60.0, 20.0, areas); keys != nil { + t.Errorf("expected nil, got %v", keys) + } + }) + + t.Run("AreaKeysForPoint returns nil for zero coordinates", func(t *testing.T) { + if keys := AreaKeysForPoint(0, 0, areas); keys != nil { + t.Errorf("expected nil, got %v", keys) + } + }) } diff --git a/cmd/server/db.go b/cmd/server/db.go index 97a67788..af209811 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -2292,13 +2292,21 @@ func (db *DB) GetNodesForScopeAdoption() ([]nodeAreaScopeInput, error) { } // computeScopeAdoptionByArea buckets nodes by their most specific -// configured area (AreaKeyForPoint) 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 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. +// 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 +// 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. +// +// Unlike the per-node area *badges* (AreaForPoint/AreaKeyForPoint, which +// pick a single most-specific area), this uses AreaKeysForPoint so a node +// counts toward EVERY containing area — a node in "Aarhus by" also counts +// toward "Jylland" and "Danmark (alle)". Without this, a broad roll-up +// area like "Danmark (alle)" would only ever show the handful of nodes not +// claimed by any smaller, more specific area (dborup flagged this: DK +// showed almost nothing because nearly every real node already belonged +// to a narrower sub-area), instead of the whole country's actual adoption. // // "Uses scope" counts two distinct signals, same runs-this-region vs // carried-this-region's-traffic distinction as OriginatingNodesByRegion vs @@ -2316,52 +2324,44 @@ func (db *DB) GetNodesForScopeAdoption() ([]nodeAreaScopeInput, error) { 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) - if !ok { + keys := AreaKeysForPoint(n.Lat, n.Lon, areas) + if len(keys) == 0 { continue } - c, exists := counts[key] - if !exists { - a := areas[key] - c = &AreaScopeAdoption{AreaKey: key, Label: a.Label, RegionScope: a.RegionScope} - counts[key] = c - } - c.TotalNodes++ 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 - } - } + relayedRegions := make(map[string]bool) + if info, ok := relayInfo[n.PublicKey]; ok { + for _, r := range info.TransportedScopes { + relayedRegions[strings.ToLower(strings.TrimPrefix(r, "#"))] = true } } + hasAnyScope := ownScope != "" || len(relayedRegions) > 0 - if hasAnyScope { - 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 != "" { - ref := RepeaterRef{Name: n.Name, PublicKey: n.PublicKey} - if regionMatch { - c.NodesMatchingArea++ - c.Matching = append(c.Matching, ref) - } else { - c.NotMatching = append(c.NotMatching, ref) + for _, key := range keys { + c, exists := counts[key] + if !exists { + a := areas[key] + c = &AreaScopeAdoption{AreaKey: key, Label: a.Label, RegionScope: a.RegionScope} + counts[key] = c + } + c.TotalNodes++ + if hasAnyScope { + 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] + ref := RepeaterRef{Name: n.Name, PublicKey: n.PublicKey} + if regionMatch { + c.NodesMatchingArea++ + c.Matching = append(c.Matching, ref) + } else { + c.NotMatching = append(c.NotMatching, ref) + } } } } diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index 33344aa0..1d685351 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -2433,6 +2433,50 @@ func TestComputeScopeAdoptionByArea_RelayedRegionCounts(t *testing.T) { } } +// TestComputeScopeAdoptionByArea_RollsUpIntoBroaderAreas is a regression +// test for exactly what dborup flagged live: "Danmark (alle)" showed only +// a handful of nodes because AreaKeyForPoint's single-most-specific-match +// meant every node already claimed by a smaller sub-area (e.g. "Odense +// by") never counted toward the broader containing area at all. A node +// inside a nested area must now count toward EVERY containing area, so +// DK's totals genuinely reflect the whole country, not just leftovers. +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)}, + } + nodes := []nodeAreaScopeInput{ + {PublicKey: "odenode01", Name: "OdenseNode", Lat: 55.4047, Lon: 10.3810, DefaultScope: "#dk-fyn-odense"}, // inside BOTH DK and ODE + {PublicKey: "dkonly01", Name: "SomewhereElseInDK", Lat: 56.0, Lon: 9.0, DefaultScope: "#dk"}, // inside DK only, not ODE + } + + got := computeScopeAdoptionByArea(nodes, areas, nil) + byKey := map[string]AreaScopeAdoption{} + for _, a := range got { + byKey[a.AreaKey] = a + } + + dk := byKey["DK"] + if dk.TotalNodes != 2 { + t.Errorf("DK.TotalNodes = %d, want 2 (both nodes fall inside DK's box, including the one also inside ODE)", dk.TotalNodes) + } + // The Odense node's own scope is dk-fyn-odense, not dk -- so it does + // NOT match DK's own region even though it geographically counts + // toward DK's totals. Only dkonly01 (#dk) matches. + if dk.NodesMatchingArea != 1 { + t.Errorf("DK.NodesMatchingArea = %d, want 1 (only dkonly01 actually uses #dk)", dk.NodesMatchingArea) + } + + ode := byKey["ODE"] + if ode.TotalNodes != 1 { + t.Errorf("ODE.TotalNodes = %d, want 1 (only the Odense-positioned node)", ode.TotalNodes) + } + if ode.NodesMatchingArea != 1 { + t.Errorf("ODE.NodesMatchingArea = %d, want 1", ode.NodesMatchingArea) + } +} + func TestComputeScopeAdoptionByArea_Empty(t *testing.T) { got := computeScopeAdoptionByArea(nil, map[string]AreaEntry{"DK": {Label: "Danmark"}}, nil) if len(got) != 0 { diff --git a/public/analytics.js b/public/analytics.js index e91c6f8f..104f8f29 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -5107,7 +5107,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf }).join(''); setSectionHtml(areaAdoptEl, detailsSection( 'Scope Adoption by Area (' + byArea.length.toLocaleString() + ' areas)', - 'All-time — expand an area to see exactly which nodes support its linked region (own default_scope or ever relayed it) and which don\'t.', + 'All-time — expand an area to see exactly which nodes support its linked region (own default_scope or ever relayed it) and which don\'t. Broader areas (e.g. "Danmark (alle)") roll up every nested sub-area\'s nodes too, not just the ones no smaller area already claimed.', areaGroups, 'scope-adoption-by-area' )); From bc628141586cdfd83d04362e6f1374b82bbbbc3f Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 21 Jul 2026 18:02:49 +0200 Subject: [PATCH 10/24] docs: cross-reference Scope Adoption by Area and Repeaters by Region dborup asked why the two sections' counts for the same region (e.g. #dk) disagree. They're not meant to match: Scope Adoption by Area only counts positioned nodes geographically inside the area, while Repeaters by Region has no geographic restriction and reflects a live, independently-refreshing relay-activity window. Added an explicit note to each section pointing at the other and explaining why their numbers differ, so this isn't mistaken for a bug again. Co-Authored-By: Claude Sonnet 5 --- public/analytics.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index 104f8f29..529d58b2 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -5107,7 +5107,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf }).join(''); setSectionHtml(areaAdoptEl, detailsSection( 'Scope Adoption by Area (' + byArea.length.toLocaleString() + ' areas)', - 'All-time — expand an area to see exactly which nodes support its linked region (own default_scope or ever relayed it) and which don\'t. Broader areas (e.g. "Danmark (alle)") roll up every nested sub-area\'s nodes too, not just the ones no smaller area already claimed.', + 'All-time — expand an area to see exactly which nodes support its linked region (own default_scope or ever relayed it) and which don\'t. Broader areas (e.g. "Danmark (alle)") roll up every nested sub-area\'s nodes too, not just the ones no smaller area already claimed. Only counts nodes with a known GPS position, geographically inside the area — the "Repeaters by Region" section further down has no such restriction, so its counts won\'t match these; they measure different things, not the same thing twice.', areaGroups, 'scope-adoption-by-area' )); @@ -5180,7 +5180,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf } renderRegionNodeGroups('scopes-repeaters', 'Repeaters by Region', - 'All-time, not limited to the window above — which repeaters have relayed traffic carrying each region scope. A region carried by only 1 repeater is a single point of failure for that area.', + 'Live, rolling activity window (not the 1h/24h/7d picker above) — which repeaters have RECENTLY relayed traffic carrying each region scope, with no geographic restriction (a repeater counts regardless of where it physically sits). A region carried by only 1 repeater is a single point of failure for that area. Counts here won\'t match "Scope Adoption by Area" further up, which is geographically bounded to positioned nodes — the two measure different things, not the same thing twice.', d.repeatersByRegion, 'repeater'); // Bridge repeaters: RepeatersByRegion inverted — repeaters relaying From 32be6807f52ab34bdaa652665ff7da2a5fbe690a Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 21 Jul 2026 18:08:20 +0200 Subject: [PATCH 11/24] revert: shorten Scope Adoption/Repeaters by Region descriptions back down Too verbose -- reverting to the original short copy. Co-Authored-By: Claude Sonnet 5 --- public/analytics.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index 529d58b2..104f8f29 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -5107,7 +5107,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf }).join(''); setSectionHtml(areaAdoptEl, detailsSection( 'Scope Adoption by Area (' + byArea.length.toLocaleString() + ' areas)', - 'All-time — expand an area to see exactly which nodes support its linked region (own default_scope or ever relayed it) and which don\'t. Broader areas (e.g. "Danmark (alle)") roll up every nested sub-area\'s nodes too, not just the ones no smaller area already claimed. Only counts nodes with a known GPS position, geographically inside the area — the "Repeaters by Region" section further down has no such restriction, so its counts won\'t match these; they measure different things, not the same thing twice.', + 'All-time — expand an area to see exactly which nodes support its linked region (own default_scope or ever relayed it) and which don\'t. Broader areas (e.g. "Danmark (alle)") roll up every nested sub-area\'s nodes too, not just the ones no smaller area already claimed.', areaGroups, 'scope-adoption-by-area' )); @@ -5180,7 +5180,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf } renderRegionNodeGroups('scopes-repeaters', 'Repeaters by Region', - 'Live, rolling activity window (not the 1h/24h/7d picker above) — which repeaters have RECENTLY relayed traffic carrying each region scope, with no geographic restriction (a repeater counts regardless of where it physically sits). A region carried by only 1 repeater is a single point of failure for that area. Counts here won\'t match "Scope Adoption by Area" further up, which is geographically bounded to positioned nodes — the two measure different things, not the same thing twice.', + 'All-time, not limited to the window above — which repeaters have relayed traffic carrying each region scope. A region carried by only 1 repeater is a single point of failure for that area.', d.repeatersByRegion, 'repeater'); // Bridge repeaters: RepeatersByRegion inverted — repeaters relaying From 896a21cda70fbab765889235baef1f5ca4a1c7c3 Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 21 Jul 2026 18:22:59 +0200 Subject: [PATCH 12/24] chore: remove Scope Adoption by Area description text Co-Authored-By: Claude Sonnet 5 --- public/analytics.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/analytics.js b/public/analytics.js index 104f8f29..e58cdb99 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -5107,7 +5107,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf }).join(''); setSectionHtml(areaAdoptEl, detailsSection( 'Scope Adoption by Area (' + byArea.length.toLocaleString() + ' areas)', - 'All-time — expand an area to see exactly which nodes support its linked region (own default_scope or ever relayed it) and which don\'t. Broader areas (e.g. "Danmark (alle)") roll up every nested sub-area\'s nodes too, not just the ones no smaller area already claimed.', + null, areaGroups, 'scope-adoption-by-area' )); From e8e7b670e677d56eafc2fabb9d4b32dd14521791 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 22 Jul 2026 10:09:43 +0200 Subject: [PATCH 13/24] feat: link the foreign/domestic geo_filter boundary to a named area Adds Config.HomeArea: when set to an existing area's key, that area's geometry becomes the effective geo_filter (getGeoFilter), instead of maintaining a second, independently-drawn boundary that can drift out of sync -- exactly what happened with Germany's box bleeding into southern Denmark earlier this session. Falls back to the standalone GeoFilter field when HomeArea is unset or unresolved, so existing deployments see no behavior change. Co-Authored-By: Claude Sonnet 5 --- cmd/server/config.go | 9 +++++ cmd/server/config_client_geofilter_test.go | 46 ++++++++++++++++++++++ cmd/server/routes.go | 18 +++++++++ 3 files changed, 73 insertions(+) diff --git a/cmd/server/config.go b/cmd/server/config.go index 46cd5e9a..9abd6580 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -244,6 +244,15 @@ type Config struct { Areas map[string]AreaEntry `json:"areas,omitempty"` + // HomeArea names an entry in Areas whose geometry defines "home" for + // the foreign/domestic classification (geo_filter above), instead of + // maintaining a second, separately-drawn boundary that can drift out + // of sync with the area map (see AreaForPoint/AreaKeysForPoint). + // When set and the named area exists, it takes priority over any + // standalone GeoFilter value — see (*Server).getGeoFilter. Leave + // unset to keep using GeoFilter exactly as before. + HomeArea string `json:"homeArea,omitempty"` + Timestamps *TimestampConfig `json:"timestamps,omitempty"` // CORSAllowedOrigins is the list of origins permitted to make cross-origin diff --git a/cmd/server/config_client_geofilter_test.go b/cmd/server/config_client_geofilter_test.go index 7cb7fc8d..188c5794 100644 --- a/cmd/server/config_client_geofilter_test.go +++ b/cmd/server/config_client_geofilter_test.go @@ -76,3 +76,49 @@ func TestConfigClientOmitsGeoFilterWhenUnconfigured(t *testing.T) { t.Error("expected geoFilter to be omitted from /api/config/client when unconfigured") } } + +// TestGetGeoFilter_HomeAreaTakesPriority covers the new HomeArea linkage: +// when cfg.HomeArea names an existing area, its geometry is the effective +// geo_filter -- a single boundary shared with the areas system instead of +// a second copy that can drift out of sync (exactly what happened with +// Germany's box vs. Denmark's area earlier this session). +func TestGetGeoFilter_HomeAreaTakesPriority(t *testing.T) { + srv, _ := setupTestServer(t) + standaloneLat := 10.0 + srv.cfg.GeoFilter = &GeoFilterConfig{LatMin: &standaloneLat} // deliberately different from the area + f := func(v float64) *float64 { return &v } + srv.cfg.Areas = map[string]AreaEntry{ + "DK": {Label: "Danmark", LatMin: f(54.5), LatMax: f(57.8), LonMin: f(8.0), LonMax: f(15.25)}, + } + srv.cfg.HomeArea = "DK" + + gf := srv.getGeoFilter() + if gf == nil || gf.LatMin == nil || *gf.LatMin != 54.5 { + t.Fatalf("getGeoFilter() = %+v, want DK area's LatMin=54.5 (HomeArea should win over the standalone GeoFilter)", gf) + } +} + +// TestGetGeoFilter_FallsBackWhenHomeAreaUnresolved confirms existing +// deployments (no HomeArea configured, or one naming a since-removed area) +// see no behavior change -- the standalone GeoFilter still applies. +func TestGetGeoFilter_FallsBackWhenHomeAreaUnresolved(t *testing.T) { + srv, _ := setupTestServer(t) + standaloneLat := 10.0 + srv.cfg.GeoFilter = &GeoFilterConfig{LatMin: &standaloneLat} + + t.Run("HomeArea unset", func(t *testing.T) { + srv.cfg.HomeArea = "" + gf := srv.getGeoFilter() + if gf == nil || gf.LatMin == nil || *gf.LatMin != 10.0 { + t.Errorf("getGeoFilter() = %+v, want the standalone GeoFilter (LatMin=10.0)", gf) + } + }) + + t.Run("HomeArea names a nonexistent area", func(t *testing.T) { + srv.cfg.HomeArea = "NOPE" + gf := srv.getGeoFilter() + if gf == nil || gf.LatMin == nil || *gf.LatMin != 10.0 { + t.Errorf("getGeoFilter() = %+v, want the standalone GeoFilter (LatMin=10.0)", gf) + } + }) +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 8cfcef18..b7a4cc68 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -18,6 +18,7 @@ import ( "time" "github.com/gorilla/mux" + "github.com/meshcore-analyzer/geofilter" "github.com/meshcore-analyzer/packetpath" "github.com/meshcore-analyzer/prunequeue" regionutil "github.com/meshcore-analyzer/regions" @@ -180,9 +181,26 @@ func (s *Server) isPubkeyHidden(pubkey string) bool { return s.cfg.IsNameHidden(name) } +// getGeoFilter returns the effective home-boundary geometry. If +// cfg.HomeArea names an existing entry in cfg.Areas, that area's geometry +// wins — a single boundary shared with the areas system, instead of a +// second copy that can silently drift (see cfg.HomeArea doc comment). +// Falls back to the standalone GeoFilter field when HomeArea is unset or +// doesn't resolve, so existing deployments see no behavior change. func (s *Server) getGeoFilter() *GeoFilterConfig { s.cfgMu.RLock() defer s.cfgMu.RUnlock() + if s.cfg != nil && s.cfg.HomeArea != "" { + if area, ok := s.cfg.Areas[s.cfg.HomeArea]; ok { + return &geofilter.Config{ + Polygon: area.Polygon, + LatMin: area.LatMin, + LatMax: area.LatMax, + LonMin: area.LonMin, + LonMax: area.LonMax, + } + } + } return s.cfg.GeoFilter } From 2fa991d64b901edb97a61cc7b0e6593798347b83 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 22 Jul 2026 12:21:52 +0200 Subject: [PATCH 14/24] docs: add AREAS.md explaining the areas/homeArea config system Covers AreaEntry fields (label/polygon-or-box/regionScope), why hierarchy is purely geometric (no parent field -- draw a broad area generously enough to contain its sub-areas and rollup just works), the two different area lookups (single most-specific match for badges vs. all-containing-areas for aggregate counts), the homeArea link to geo_filter and why it exists, and a verify-before-deploy checklist for anyone drawing a new polygon. Co-Authored-By: Claude Sonnet 5 --- AREAS.md | 147 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 AREAS.md diff --git a/AREAS.md b/AREAS.md new file mode 100644 index 00000000..3e916f88 --- /dev/null +++ b/AREAS.md @@ -0,0 +1,147 @@ +# Areas + +`config.json`'s `areas` map lets you draw named geographic regions — cities, +regions, whole countries, even continents — and have CoreScope automatically +count which nodes fall inside each one, roll counts up through nested areas +(a city inside a country inside a continent), and (optionally) classify +foreign vs. domestic traffic from the same boundaries. + +There is no per-deployment code involved. Everything below is `config.json` +content — the areas feature itself (`AreaEntry`, `AreaForPoint`, +`AreaKeyForPoint`, `AreaKeysForPoint`, `HomeArea`, `computeScopeAdoptionByArea` +in `cmd/server/`) is generic and works the same regardless of what regions you +draw or which country you run CoreScope in. + +## Defining an area + +```json +"areas": { + "DK": { + "label": "Danmark (alle)", + "regionScope": "dk", + "polygon": [[54.85, 8.65], [55.50, 8.10], [57.10, 8.20], ...] + }, + "AAR": { + "label": "Aarhus by", + "regionScope": "dk-aarhus", + "polygon": [[56.35, 10.33], [56.31, 10.45], ...] + }, + "EU": { + "label": "Europa (alle)", + "regionScope": "eu", + "latMin": 34.0, "latMax": 71.5, "lonMin": -25.0, "lonMax": 45.0 + } +} +``` + +Each entry has three parts: + +- **`label`** — the human-readable name shown in the UI. +- **Geometry** — either: + - `polygon`: a list of `[lat, lon]` points tracing a real boundary + (coastline, border). Use this when you care about precision — the + boundary between two adjacent countries/regions especially, since a + simple box will bleed across it. + - `latMin` / `latMax` / `lonMin` / `lonMax`: a plain bounding box. Good + enough for a rough first pass, or for areas with no close neighbor to + worry about overlapping (e.g. a whole continent). + + 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. + +## Hierarchy: draw it, don't declare it + +**There is no `"parent"` field.** An area doesn't know it's "inside" another +area — that's worked out purely from geometry, every time a node needs to be +counted: for each area, does the node's `(lat, lon)` fall inside its +geometry? If yes, the node counts toward that area. A node in Aarhus falls +inside `AAR`'s polygon *and* a broader `JYL` (Jylland) polygon *and* `DK` +*and* `EU`, simultaneously — no code anywhere needs to know those areas are +related, and none of them need to enumerate their members. + +Practically, this means: + +- To make a country-level area (e.g. "Danmark") show the *whole country's* + totals rather than just the leftover nodes no smaller area already + claimed, its geometry must actually contain those smaller areas' + geometry. Draw the country boundary generously enough to cover all its + regions/cities and it just works. +- Adding a new area — a new city, region, or country — never requires + touching any other area's config, or any code. Draw its boundary, add the + entry, restart. If it geographically sits inside an existing broader + area, it's automatically included in that area's totals from the next + restart on. +- The same applies at any scale: adding e.g. "Finland" as a new country + area automatically starts contributing to "Europa (alle)" the moment its + polygon is added, with zero changes to the Europe entry. + +Two different lookups use this geometry, for different purposes: + +- **Single most-specific match** (`AreaForPoint` / `AreaKeyForPoint`) — used + for per-node badges (Wardriving tab's GPS-share/session area tags): picks + the *smallest* matching area, so a node in Aarhus is labeled "Aarhus by", + not "Danmark". +- **All containing areas** (`AreaKeysForPoint`) — used for aggregate counts + (Scope Adoption by Area): a node counts toward *every* area it + geographically sits inside, so country/continent totals genuinely + aggregate their sub-areas instead of only showing leftovers. + +## `homeArea`: linking foreign/domestic classification to an area + +```json +"homeArea": "DK" +``` + +`homeArea` names an entry in `areas` whose geometry becomes the effective +`geo_filter` — the boundary the Foreign Traffic tab, the Nodes page +All/Domestic/Foreign filter, and the live map's declutter logic all use to +decide "is this node ours or foreign". + +Before `homeArea` existed, `geo_filter` was a second, independently-drawn +boundary — easy to let drift out of sync with whatever the "home" area +actually looked like (this happened in practice: a too-loose home-country +box quietly claimed a neighboring country's nodes as domestic, and fixing +the area's polygon didn't fix `geo_filter` until this field existed). + +Set `homeArea` to the key of whichever area represents "home" for your +deployment. Leave it unset (or pointing at a key that doesn't exist in +`areas`) to keep using a standalone `geo_filter` value exactly as before — +this is fully backward compatible. + +## Adding a new area + +1. Get a boundary. A rough bounding box is a fine starting point + (`latMin`/`latMax`/`lonMin`/`lonMax`); upgrade to a `polygon` later if it + turns out to overlap a neighbor. +2. Add it under `areas` in `config.json`. +3. Restart CoreScope — config is only read at startup, there's no hot-reload. +4. Done. It's picked up everywhere automatically: the area filter dropdown, + per-node badges, Scope Adoption by Area, and (if it's nested inside a + broader area) that broader area's totals. + +If you draw a `polygon`, verify it before deploying: fetch +`/api/nodes?limit=5000` and `/api/config/areas/polygons`, and run every +node you know belongs on each side of the new boundary through a +point-in-polygon check (ray-casting — see `AreaForPoint` in +`cmd/server/config.go` for the exact algorithm CoreScope uses) to catch +bleed across a shared border before it ships. A box is more forgiving of +imprecision than a polygon since there's usually nothing on the other side +of an unclaimed edge to misclassify — but two adjacent countries sharing a +long border will bleed into each other badly with simple boxes, which is +why Denmark/Sweden/Norway/Germany all ended up as polygons. + +## `ops/meshguide-sync/` + +That directory holds a script specific to this deployment (fetches Danish +region boundaries from a community-run site, meshguide.dk) — not part of +the generic areas feature, and not expected to be useful for any other +CoreScope deployment. See its own docstring for details. From 058f4a8d41c763a9f827637a74c56c3c2fdd5d39 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 22 Jul 2026 13:28:22 +0200 Subject: [PATCH 15/24] feat: show the linked area next to a channel message's Scope tag "Scope: #dk-aarhus" now renders as "Scope: #dk-aarhus (Aarhus by)" when that region is linked to a configured area -- same regionScope lookup already used on the Scopes tab, just reused here via a small local cache (loadRegionAreaLabels) since channels.js doesn't share analytics.js's closure. Co-Authored-By: Claude Sonnet 5 --- public/channels.js | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/public/channels.js b/public/channels.js index 40abb9db..14a7aad8 100644 --- a/public/channels.js +++ b/public/channels.js @@ -60,6 +60,7 @@ let selectedNode = null; let observerIataById = {}; let observerIataByName = {}; + let regionAreaLabels = {}; let messageRequestId = 0; var _nodeCacheTTL = 5 * 60 * 1000; // 5 minutes @@ -117,6 +118,26 @@ } catch {} } + // regionScope ("dk-aarhus", no leading '#') -> area label ("Aarhus by"), + // for annotating a message's "Scope: #dk-aarhus" tag with the linked + // area's human name. Same convention as analytics.js's Scopes tab. + async function loadRegionAreaLabels() { + try { + var areas = await api('/config/areas', { ttl: CLIENT_TTL.nodeDetail }); + var labels = {}; + (areas || []).forEach(function (a) { + if (a.regionScope) labels[a.regionScope.toLowerCase()] = a.label; + }); + regionAreaLabels = labels; + } catch {} + } + + function areaLabelForScope(rawScope) { + if (!rawScope) return null; + var key = String(rawScope).replace(/^#/, '').toLowerCase(); + return regionAreaLabels[key] || null; + } + function beginMessageRequest(hash, regionParam) { return { id: ++messageRequestId, hash: hash, regionParam: regionParam || '' }; } @@ -1119,6 +1140,7 @@ }); loadObserverRegions(); + loadRegionAreaLabels(); loadChannels().then(async function () { // Also load user-added encrypted channels into the sidebar. // mergeUserChannels() mutates `channels` (marks userAdded, appends @@ -2273,8 +2295,10 @@ // HMAC collision made the match ambiguous) — show it as unknown // rather than silently omitting the tag. const isTransportRoute = msg.routeType === 0 || msg.routeType === 3; - if (msg.scope) meta.push(`Scope: ${escapeHtml(msg.scope)}`); - else if (isTransportRoute) meta.push('Scope: unknown'); + if (msg.scope) { + const areaLabel = areaLabelForScope(msg.scope); + meta.push(`Scope: ${escapeHtml(msg.scope)}` + (areaLabel ? ` (${escapeHtml(areaLabel)})` : '')); + } else if (isTransportRoute) meta.push('Scope: unknown'); const safeId = btoa(encodeURIComponent(sender)); // #1367: emit BOTH the new chat-app class names (.ch-message / From 6a909f641f819acdaf43cabf08d432f1b9f58c98 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 22 Jul 2026 13:50:43 +0200 Subject: [PATCH 16/24] feat: show where a channel message's sender actually was, not just its scope dborup: sitting in Aarhus but sending with the broad #dk scope should still show "Aarhus by" -- the scope-linked area alone doesn't tell you where the sender physically was. Adds a second, independent area resolved from the message's own path[0] entry-point repeater (same unique_prefix-only discipline as resolveEntryPointArea/Wardriving), shown as "From: " alongside the existing "Scope: #dk (Danmark alle)" tag. GetChannelMessages (both DB and in-memory paths) now captures path[0] as an internal "entryPrefix" field; handleChannelMessages resolves it to an area server-side via the existing resolveEntryPointArea, then strips entryPrefix before the response goes out -- raw hash prefixes never reach the client for this feature. Scoped to the REST message list only, not the WebSocket live-append path (shared broadcast infra used by several other pages) -- a brand-new live message shows the scope-linked area only, until the next full load picks up the resolved path[0] area. Co-Authored-By: Claude Sonnet 5 --- cmd/server/channel_message_area_test.go | 157 ++++++++++++++++++++++++ cmd/server/db.go | 7 +- cmd/server/routes.go | 26 ++++ cmd/server/store.go | 14 +++ public/channels.js | 8 ++ 5 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 cmd/server/channel_message_area_test.go diff --git a/cmd/server/channel_message_area_test.go b/cmd/server/channel_message_area_test.go new file mode 100644 index 00000000..e4612d49 --- /dev/null +++ b/cmd/server/channel_message_area_test.go @@ -0,0 +1,157 @@ +package main + +import ( + "encoding/json" + "net/http/httptest" + "testing" + "time" +) + +// TestHandleChannelMessages_EntryPointArea covers dborup's request: a +// channel message needs BOTH the scope it was sent with (already shown as +// "Scope: #dk (Danmark (alle))") AND the area the sender was actually +// physically in, resolved from the message's own path[0] entry-point +// repeater -- distinct because someone in Aarhus can still send with the +// broad #dk scope. Also confirms the internal "entryPrefix" field used to +// carry path[0] through to the resolution step never reaches the client. +func TestHandleChannelMessages_EntryPointArea(t *testing.T) { + srv, router := setupTestServer(t) + if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil { + t.Fatalf("clear transmissions: %v", err) + } + if _, err := srv.db.conn.Exec(`DELETE FROM observations`); err != nil { + t.Fatalf("clear observations: %v", err) + } + if !srv.db.hasScopeName { + if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil { + t.Fatalf("add scope_name column: %v", err) + } + srv.db.hasScopeName = true + } + + f := func(v float64) *float64 { return &v } + srv.cfg.Areas = map[string]AreaEntry{ + "AAR": {Label: "Aarhus by", LatMin: f(56.05), LatMax: f(56.25), LonMin: f(9.95), LonMax: f(10.35)}, + } + + // A repeater physically in Aarhus, resolvable only via a unique + // full-length prefix match (mirrors TestResolveHopsAPI_UniquePrefix). + if _, err := srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)", + "aar0011223344", "AarhusRepeater", 56.1503, 10.1965, "repeater"); err != nil { + t.Fatalf("insert node: %v", err) + } + srv.store.InvalidateNodeCache() + + now := time.Now().UTC().Format(time.RFC3339) + res, err := srv.db.conn.Exec( + `INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json,scope_name) VALUES (?,?,?,0,5,'#dk',?,'#dk')`, + "aa", "chmsg1", now, `{"sender":"AarhusSender","text":"AarhusSender: hey from #dk"}`, + ) + if err != nil { + t.Fatalf("insert tx: %v", err) + } + txID, _ := res.LastInsertId() + + obsRes, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`, "obsAAR", "AarhusObs", "AAR") + if err != nil { + t.Fatalf("insert observer: %v", err) + } + obsIdx, _ := obsRes.LastInsertId() + if _, err := srv.db.conn.Exec( + `INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,?,?,?,?)`, + txID, obsIdx, 1.0, -90.0, `["aar0011223344"]`, time.Now().Unix(), + ); err != nil { + t.Fatalf("insert observation: %v", err) + } + + req := httptest.NewRequest("GET", "/api/channels/%23dk/messages?limit=10", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + + var body map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + messages, _ := body["messages"].([]interface{}) + if len(messages) != 1 { + t.Fatalf("messages = %+v, want 1", messages) + } + msg, _ := messages[0].(map[string]interface{}) + + if msg["scope"] != "#dk" { + t.Errorf("scope = %v, want #dk", msg["scope"]) + } + if msg["area"] != "Aarhus by" { + t.Errorf("area = %v, want \"Aarhus by\" (resolved from path[0], not the #dk scope)", msg["area"]) + } + if _, present := msg["entryPrefix"]; present { + t.Error("entryPrefix must never reach the client -- it's an internal-only intermediate field") + } +} + +// TestHandleChannelMessages_EntryPointArea_Unresolved confirms "area" is +// simply omitted (never guessed) when the entry-point prefix doesn't +// resolve unambiguously, or when no areas are configured at all. +func TestHandleChannelMessages_EntryPointArea_Unresolved(t *testing.T) { + srv, router := setupTestServer(t) + if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil { + t.Fatalf("clear transmissions: %v", err) + } + if _, err := srv.db.conn.Exec(`DELETE FROM observations`); err != nil { + t.Fatalf("clear observations: %v", err) + } + if !srv.db.hasScopeName { + if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil { + t.Fatalf("add scope_name column: %v", err) + } + srv.db.hasScopeName = true + } + // No areas configured at all. + srv.cfg.Areas = nil + + now := time.Now().UTC().Format(time.RFC3339) + res, err := srv.db.conn.Exec( + `INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json,scope_name) VALUES (?,?,?,0,5,'#dk',?,'#dk')`, + "aa", "chmsg2", now, `{"sender":"Someone","text":"Someone: hi"}`, + ) + if err != nil { + t.Fatalf("insert tx: %v", err) + } + txID, _ := res.LastInsertId() + obsRes, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`, "obsX", "ObsX", "XXX") + if err != nil { + t.Fatalf("insert observer: %v", err) + } + obsIdx, _ := obsRes.LastInsertId() + if _, err := srv.db.conn.Exec( + `INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,?,?,?,?)`, + txID, obsIdx, 1.0, -90.0, `["deadbeef99"]`, time.Now().Unix(), + ); err != nil { + t.Fatalf("insert observation: %v", err) + } + + req := httptest.NewRequest("GET", "/api/channels/%23dk/messages?limit=10", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var body map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + messages, _ := body["messages"].([]interface{}) + if len(messages) != 1 { + t.Fatalf("messages = %+v, want 1", messages) + } + msg, _ := messages[0].(map[string]interface{}) + if _, present := msg["area"]; present { + t.Errorf("area = %v, want absent (no areas configured)", msg["area"]) + } + if _, present := msg["entryPrefix"]; present { + t.Error("entryPrefix must never reach the client") + } +} diff --git a/cmd/server/db.go b/cmd/server/db.go index af209811..476fb26c 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1945,10 +1945,14 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . } } var hops int + var entryPrefix string if pathJSON.Valid { - var h []interface{} + var h []string if json.Unmarshal([]byte(pathJSON.String), &h) == nil { hops = len(h) + if len(h) > 0 { + entryPrefix = h[0] + } } } senderTs := decoded["sender_timestamp"] @@ -1967,6 +1971,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . "snr": nullFloat(snr), "scope": nullStr(scopeName), "routeType": nullInt(routeType), + "entryPrefix": entryPrefix, }, Repeats: 1, } diff --git a/cmd/server/routes.go b/cmd/server/routes.go index b7a4cc68..8f33e490 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -2768,6 +2768,30 @@ func (s *Server) resolveEntryPointArea(prefixes []string) (label string, ok bool return "", false } +// annotateMessageAreas resolves each message's entry-point repeater (its +// path[0], captured as "entryPrefix" by GetChannelMessages) to a +// configured area — same unique_prefix-only discipline as +// resolveEntryPointArea/handleWardrivingStats: only a truly unambiguous +// prefix match with a known position sets "area", so this shows where the +// SENDER actually was, distinct from (and often more specific than) the +// area linked to the channel scope they sent with — dborup's own example: +// sitting in Aarhus but sending with the broad #dk scope should still show +// "Aarhus by" here. The raw entryPrefix never reaches the client, whether +// or not it resolved. +func (s *Server) annotateMessageAreas(messages []map[string]interface{}) { + hasAreas := s.cfg != nil && len(s.cfg.Areas) > 0 + for _, m := range messages { + prefix, _ := m["entryPrefix"].(string) + delete(m, "entryPrefix") + if !hasAreas || prefix == "" { + continue + } + if label, ok := s.resolveEntryPointArea([]string{prefix}); ok { + m["area"] = label + } + } +} + func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) { region := r.URL.Query().Get("region") includeEncrypted := r.URL.Query().Get("includeEncrypted") == "true" @@ -2812,11 +2836,13 @@ func (s *Server) handleChannelMessages(w http.ResponseWriter, r *http.Request) { writeError(w, 500, err.Error()) return } + s.annotateMessageAreas(messages) writeJSON(w, ChannelMessagesResponse{Messages: messages, Total: total}) return } if s.store != nil { messages, total := s.store.GetChannelMessages(hash, limit, offset, region) + s.annotateMessageAreas(messages) writeJSON(w, ChannelMessagesResponse{Messages: messages, Total: total}) return } diff --git a/cmd/server/store.go b/cmd/server/store.go index da066405..324c107f 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -1675,6 +1675,19 @@ func pathLen(pathJSON string) int { return len(hops) } +// pathFirstHop returns path[0] (the entry-point repeater's hex prefix), or +// "" when pathJSON is empty/invalid/has no hops. +func pathFirstHop(pathJSON string) string { + if pathJSON == "" { + return "" + } + var hops []string + if json.Unmarshal([]byte(pathJSON), &hops) != nil || len(hops) == 0 { + return "" + } + return hops[0] +} + // indexResolvedPathHops indexes a transmission under every relay-hop pubkey // extracted from an observation's resolved_path, and refreshes the dependent // resolved-pubkey + path-hop indexes. This is the single point of truth for @@ -5521,6 +5534,7 @@ func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int, "snr": snrVal, "scope": strOrNil(tx.ScopeName), "routeType": intPtrOrNil(tx.RouteType), + "entryPrefix": pathFirstHop(tx.PathJSON), }, Repeats: 1, Observers: observers, diff --git a/public/channels.js b/public/channels.js index 14a7aad8..3798e2fe 100644 --- a/public/channels.js +++ b/public/channels.js @@ -2299,6 +2299,14 @@ const areaLabel = areaLabelForScope(msg.scope); meta.push(`Scope: ${escapeHtml(msg.scope)}` + (areaLabel ? ` (${escapeHtml(areaLabel)})` : '')); } else if (isTransportRoute) meta.push('Scope: unknown'); + // msg.area (set server-side from the message's own path[0] + // entry-point repeater, not from the scope string) is where the + // SENDER physically was — distinct from the scope-linked area + // above, since e.g. a sender in Aarhus can still send with the + // broad #dk scope. Only present when path[0] resolved unambiguously + // (unique_prefix) to a positioned node; omitted otherwise, not + // guessed. + if (msg.area) meta.push(`From: ${escapeHtml(msg.area)}`); const safeId = btoa(encodeURIComponent(sender)); // #1367: emit BOTH the new chat-app class names (.ch-message / From 9770a930b243b22ce7a4800191acf259ddf912cf Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 22 Jul 2026 14:05:19 +0200 Subject: [PATCH 17/24] revert: drop the area label on channel messages' Scope tag Keeping the scope tag plain ("Scope: #dk") -- the path[0]-resolved "From: " is the more meaningful, accurate signal for where the sender was, so labeling the scope string too was redundant. Co-Authored-By: Claude Sonnet 5 --- public/channels.js | 28 ++-------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/public/channels.js b/public/channels.js index 3798e2fe..419ff8d7 100644 --- a/public/channels.js +++ b/public/channels.js @@ -60,7 +60,6 @@ let selectedNode = null; let observerIataById = {}; let observerIataByName = {}; - let regionAreaLabels = {}; let messageRequestId = 0; var _nodeCacheTTL = 5 * 60 * 1000; // 5 minutes @@ -118,26 +117,6 @@ } catch {} } - // regionScope ("dk-aarhus", no leading '#') -> area label ("Aarhus by"), - // for annotating a message's "Scope: #dk-aarhus" tag with the linked - // area's human name. Same convention as analytics.js's Scopes tab. - async function loadRegionAreaLabels() { - try { - var areas = await api('/config/areas', { ttl: CLIENT_TTL.nodeDetail }); - var labels = {}; - (areas || []).forEach(function (a) { - if (a.regionScope) labels[a.regionScope.toLowerCase()] = a.label; - }); - regionAreaLabels = labels; - } catch {} - } - - function areaLabelForScope(rawScope) { - if (!rawScope) return null; - var key = String(rawScope).replace(/^#/, '').toLowerCase(); - return regionAreaLabels[key] || null; - } - function beginMessageRequest(hash, regionParam) { return { id: ++messageRequestId, hash: hash, regionParam: regionParam || '' }; } @@ -1140,7 +1119,6 @@ }); loadObserverRegions(); - loadRegionAreaLabels(); loadChannels().then(async function () { // Also load user-added encrypted channels into the sidebar. // mergeUserChannels() mutates `channels` (marks userAdded, appends @@ -2295,10 +2273,8 @@ // HMAC collision made the match ambiguous) — show it as unknown // rather than silently omitting the tag. const isTransportRoute = msg.routeType === 0 || msg.routeType === 3; - if (msg.scope) { - const areaLabel = areaLabelForScope(msg.scope); - meta.push(`Scope: ${escapeHtml(msg.scope)}` + (areaLabel ? ` (${escapeHtml(areaLabel)})` : '')); - } else if (isTransportRoute) meta.push('Scope: unknown'); + if (msg.scope) meta.push(`Scope: ${escapeHtml(msg.scope)}`); + else if (isTransportRoute) meta.push('Scope: unknown'); // msg.area (set server-side from the message's own path[0] // entry-point repeater, not from the scope string) is where the // SENDER physically was — distinct from the scope-linked area From ad48b7712699aba377b9e61af21ee0fcc06a85c2 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 22 Jul 2026 14:12:29 +0200 Subject: [PATCH 18/24] rename: "From:" -> "Area:" for the path[0]-resolved location tag Co-Authored-By: Claude Sonnet 5 --- public/channels.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/channels.js b/public/channels.js index 419ff8d7..1a4068b5 100644 --- a/public/channels.js +++ b/public/channels.js @@ -2282,7 +2282,7 @@ // broad #dk scope. Only present when path[0] resolved unambiguously // (unique_prefix) to a positioned node; omitted otherwise, not // guessed. - if (msg.area) meta.push(`From: ${escapeHtml(msg.area)}`); + if (msg.area) meta.push(`Area: ${escapeHtml(msg.area)}`); const safeId = btoa(encodeURIComponent(sender)); // #1367: emit BOTH the new chat-app class names (.ch-message / From 746230535011d328b8214ee45c159e35d9a47ace Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 22 Jul 2026 15:07:08 +0200 Subject: [PATCH 19/24] fix: resolve path0 area on live WebSocket-appended channel messages resolveEntryPointArea moves onto PacketStore (which already owns the config and prefix map) so IngestNewFromDB/IngestNewObservations can resolve area at broadcast time, not just on the next REST reload. Previously a freshly-sent message only showed "Area:" after a page refresh since the WS live-append path never computed it. --- cmd/server/routes.go | 23 ++--------------- cmd/server/routes_test.go | 2 ++ cmd/server/store.go | 54 +++++++++++++++++++++++++++++++++++++++ public/channels.js | 5 ++++ 4 files changed, 63 insertions(+), 21 deletions(-) diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 8f33e490..952f3016 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -2743,29 +2743,10 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) { // resolves this way, or the resolved node has no position, or areas aren't // configured. func (s *Server) resolveEntryPointArea(prefixes []string) (label string, ok bool) { - if s.store == nil || s.cfg == nil || len(s.cfg.Areas) == 0 || len(prefixes) == 0 { + if s.store == nil { return "", false } - s.store.mu.RLock() - _, pm := s.store.getCachedNodesAndPM() - s.store.mu.RUnlock() - if pm == nil { - return "", false - } - for _, prefix := range prefixes { - candidates, found := pm.m[strings.ToLower(prefix)] - if !found || len(candidates) != 1 { - continue - } - ni := candidates[0] - if !ni.HasGPS { - continue - } - if label, ok := AreaForPoint(ni.Lat, ni.Lon, s.cfg.Areas); ok { - return label, true - } - } - return "", false + return s.store.resolveEntryPointArea(prefixes) } // annotateMessageAreas resolves each message's entry-point repeater (its diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index b0d6bf1f..6a97e357 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -34,6 +34,7 @@ func setupTestServer(t *testing.T) (*Server, *mux.Router) { if !store.WaitIndexesReady(5 * time.Second) { t.Fatalf("background indexes never became ready") } + store.config = cfg // mirrors main.go's real wiring -- store.resolveEntryPointArea/resolveAreaNodes need it srv.store = store router := mux.NewRouter() srv.RegisterRoutes(router) @@ -55,6 +56,7 @@ func setupTestServerWithAPIKey(t *testing.T, apiKey string) (*Server, *mux.Route if !store.WaitIndexesReady(5 * time.Second) { t.Fatalf("background indexes never became ready") } + store.config = cfg srv.store = store router := mux.NewRouter() srv.RegisterRoutes(router) diff --git a/cmd/server/store.go b/cmd/server/store.go index 324c107f..5d48b85f 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -2962,6 +2962,16 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac "observation_count": tx.ObservationCount, "scope_name": strOrNil(tx.ScopeName), } + // Same entry-point area resolution as the REST channel message + // list (annotateMessageAreas), applied live so a message + // appended via WebSocket shows "Area:" immediately instead of + // only after the next full reload. Only a unique_prefix match + // with a known position sets it — never a guess. + if entryPrefix := pathFirstHop(obs.PathJSON); entryPrefix != "" { + if label, ok := s.resolveEntryPointArea([]string{entryPrefix}); ok { + pkt["area"] = label + } + } // Use decode-window resolved path for broadcast (never from struct) if broadcastRP != nil { if rp, ok := broadcastRP[obs.ID]; ok && rp != nil { @@ -3235,6 +3245,13 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string] "observation_count": tx.ObservationCount, "scope_name": strOrNil(tx.ScopeName), } + // Same live entry-point area resolution as IngestNewFromDB above -- + // see the comment there. + if entryPrefix := pathFirstHop(obs.PathJSON); entryPrefix != "" { + if label, ok := s.resolveEntryPointArea([]string{entryPrefix}); ok { + pkt["area"] = label + } + } // Use decode-window resolved path for broadcast if obsRPMap != nil { if rp, ok := obsRPMap[obs.ID]; ok && rp != nil { @@ -3613,6 +3630,43 @@ func (s *PacketStore) fetchAndCacheRegionObs(region string) map[string]bool { return m } +// resolveEntryPointArea approximates a sender's area from their entry-point +// repeater(s): for each candidate path[0] prefix, only a unique_prefix +// match (exactly one node in the prefix map, same discipline as +// handleResolveHops) with a known position is trusted — an ambiguous +// prefix is skipped rather than guessed. Returns ok=false if no prefix +// resolves this way, the resolved node has no position, or areas aren't +// configured. Lives on PacketStore (not Server) so both the REST message +// list (via (*Server).resolveEntryPointArea, which just delegates here) +// and the live WebSocket broadcast path can reuse the exact same +// resolution — a store method has everything it needs (config, prefix +// map) without reaching back out to *Server. +func (s *PacketStore) resolveEntryPointArea(prefixes []string) (label string, ok bool) { + if s == nil || s.config == nil || len(s.config.Areas) == 0 || len(prefixes) == 0 { + return "", false + } + s.mu.RLock() + _, pm := s.getCachedNodesAndPM() + s.mu.RUnlock() + if pm == nil { + return "", false + } + for _, prefix := range prefixes { + candidates, found := pm.m[strings.ToLower(prefix)] + if !found || len(candidates) != 1 { + continue + } + ni := candidates[0] + if !ni.HasGPS { + continue + } + if label, ok := AreaForPoint(ni.Lat, ni.Lon, s.config.Areas); ok { + return label, true + } + } + return "", false +} + // resolveAreaNodes returns a set of node pubkeys whose GPS coordinates fall // inside the named area polygon. Returns nil if the area key is not in config. // Results are cached per-key for 30 seconds. Uses its own RWMutex so callers diff --git a/public/channels.js b/public/channels.js index 1a4068b5..447fdfe6 100644 --- a/public/channels.js +++ b/public/channels.js @@ -1423,6 +1423,10 @@ var observer = m.data?.packet?.observer_name || m.data?.observer || null; var scope = m.data?.scope_name || m.data?.packet?.scope_name || null; var routeType = m.data?.route_type ?? m.data?.packet?.route_type ?? null; + // Same path[0]-resolved area as the REST message list (server-side + // resolveEntryPointArea, see store.go) -- already computed at + // broadcast time, just read it here. + var area = m.data?.area || m.data?.packet?.area || null; // Update channel list entry — only once per unique packet hash var isFirstObservation = pktHash && !seenHashes.has(pktHash + ':' + channelKey); @@ -1476,6 +1480,7 @@ snr: snr, scope: scope, routeType: routeType, + area: area, // #1498: mark as WS-pushed so a later REST replacement // (selectChannel / refreshMessages) can merge instead of // stomp. Without this flag the REST response wipes any From 9abeaaf7f434bdd9cb95a1bc638b78a9599d9f93 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 22 Jul 2026 15:47:22 +0200 Subject: [PATCH 20/24] fix: remove self-deadlocking s.mu.RLock in resolveEntryPointArea IngestNewFromDB/IngestNewObservations call resolveEntryPointArea while already holding s.mu.Lock() (write lock). resolveEntryPointArea then tried to s.mu.RLock() the same non-reentrant mutex, deadlocking the goroutine permanently and blocking every other s.mu waiter -- this stalled LoadChunked mid-startup and hung /api/stats on stg. getCachedNodesAndPM() guards itself with its own cacheMu and never touches s.mu, so the RLock/RUnlock wrapping was unnecessary. --- cmd/server/store.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cmd/server/store.go b/cmd/server/store.go index 5d48b85f..a66d9cb3 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -3645,9 +3645,12 @@ func (s *PacketStore) resolveEntryPointArea(prefixes []string) (label string, ok if s == nil || s.config == nil || len(s.config.Areas) == 0 || len(prefixes) == 0 { return "", false } - s.mu.RLock() + // getCachedNodesAndPM guards itself with its own cacheMu -- it never + // touches s.mu. Do not wrap this call in s.mu.RLock(): callers like + // IngestNewFromDB/IngestNewObservations invoke this while already + // holding s.mu.Lock(), and Go's RWMutex is not reentrant, so an + // RLock here would deadlock against the caller's own write lock. _, pm := s.getCachedNodesAndPM() - s.mu.RUnlock() if pm == nil { return "", false } From d44ce0a3a4aeb7e3d86580a78353f05dbd723107 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 22 Jul 2026 16:21:18 +0200 Subject: [PATCH 21/24] style: lowercase "scope"/"area" labels in channel message meta line --- public/channels.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/public/channels.js b/public/channels.js index 447fdfe6..2dea7662 100644 --- a/public/channels.js +++ b/public/channels.js @@ -2278,8 +2278,8 @@ // HMAC collision made the match ambiguous) — show it as unknown // rather than silently omitting the tag. const isTransportRoute = msg.routeType === 0 || msg.routeType === 3; - if (msg.scope) meta.push(`Scope: ${escapeHtml(msg.scope)}`); - else if (isTransportRoute) meta.push('Scope: unknown'); + if (msg.scope) meta.push(`scope: ${escapeHtml(msg.scope)}`); + else if (isTransportRoute) meta.push('scope: unknown'); // msg.area (set server-side from the message's own path[0] // entry-point repeater, not from the scope string) is where the // SENDER physically was — distinct from the scope-linked area @@ -2287,7 +2287,7 @@ // broad #dk scope. Only present when path[0] resolved unambiguously // (unique_prefix) to a positioned node; omitted otherwise, not // guessed. - if (msg.area) meta.push(`Area: ${escapeHtml(msg.area)}`); + if (msg.area) meta.push(`area: ${escapeHtml(msg.area)}`); const safeId = btoa(encodeURIComponent(sender)); // #1367: emit BOTH the new chat-app class names (.ch-message / From 17b86361d9c35e493d92bdf228404cbbd49a1535 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 22 Jul 2026 16:48:37 +0200 Subject: [PATCH 22/24] feat: add Domestic/Foreign filter to scope hygiene sections "Nodes Without a Default Scope" and "Repeaters Never Relaying Any Scope" now have the same All/Domestic/Foreign filter as the Nodes tab, classified live from each node's lat/lon against window.MC_GEO_FILTER (same nodePassesGeoFilter helper, not the node's stale one-way `foreign` DB flag -- see renderForeignTrafficTab's isForeignNode). Refactored the never-relay section's inline render block into its own renderNeverRelaySection, mirroring renderNoScopeSection, so its filter can re-render reactively without a re-fetch. --- public/analytics.js | 136 +++++++++++++++++++------- test-analytics-nodes-without-scope.js | 43 ++++++++ 2 files changed, 144 insertions(+), 35 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index e58cdb99..2c8818c7 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -4504,12 +4504,18 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf var subtabKey = 'scopes_subtab'; var selectedSubtab = (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(subtabKey)) || 'overview'; - // Role/text filter for the "Nodes Without a Default Scope" section + // Role/text/geo filter for the "Nodes Without a Default Scope" section // below. Lives at this scope (not inside updateData) so it survives // the 60s auto-refresh re-render — same reasoning as selectedWindow // above, just kept in memory rather than sessionStorage since it's a // finer-grained, more transient filter. - var noScopeFilter = { role: '', q: '' }; + var noScopeFilter = { role: '', q: '', geo: '' }; + + // Geo filter for "Repeaters Never Relaying Any Scope" below — same + // domestic/foreign split as noScopeFilter.geo, kept separate since the + // two sections' result sets are independent (see + // computeRepeatersNeverRelayingScope's doc comment). + var neverRelayFilter = { geo: '' }; // Encrypted/unencrypted filter for "Scope Adoption by Channel" below — // same persistence reasoning as noScopeFilter above. '' = all, @@ -4657,7 +4663,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf var searchFocused = document.activeElement && document.activeElement.id === 'noScopeSearch'; var caretPos = searchFocused ? document.activeElement.selectionStart : null; - var noScope = computeNodesWithoutScope(allNodes, 100, { role: noScopeFilter.role, q: noScopeFilter.q }); + var noScope = computeNodesWithoutScope(allNodes, 100, { role: noScopeFilter.role, q: noScopeFilter.q, geo: noScopeFilter.geo }); var roleSummaryText = noScope.roleSummary.map(function(r) { return r.count.toLocaleString() + ' ' + esc(r.role); }).join(', '); var roleButtons = ''; }).join(''); - var controlsHtml = '
' + + var controlsHtml = geoFilterButtons(noScopeFilter, 'geo-filter') + + '
' + roleButtons + '' + @@ -4684,7 +4691,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf 'NodeRoleLast Seen' + '' + noScopeRows + '' + ''; - } else if (noScopeFilter.role || noScopeFilter.q) { + } else if (noScopeFilter.role || noScopeFilter.q || noScopeFilter.geo) { resultsBody = '

No nodes without a default scope match this filter.

'; } else { resultsBody = '

Every known node has a configured default scope.

'; @@ -4710,6 +4717,13 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf }); }); + sectionEl.querySelectorAll('[data-geo-filter]').forEach(function(btn) { + btn.addEventListener('click', function() { + noScopeFilter.geo = btn.dataset.geoFilter || ''; + renderNoScopeSection(allNodes); + }); + }); + var searchInput = sectionEl.querySelector('#noScopeSearch'); if (searchInput) { searchInput.addEventListener('input', debounce(function() { @@ -5246,39 +5260,64 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf // computeRepeatersNeverRelayingScope doc comment). Reuses the same // `allNodes` fetched above. var neverRelayEl = document.getElementById('scopes-never-relay-scope'); - if (neverRelayEl && !allNodes) { - setSectionHtml(neverRelayEl, detailsSection('Repeaters Never Relaying Any Scope', null, '

Failed to load.

', 'never-relay')); - } else if (neverRelayEl) { - try { - var neverRelay = computeRepeatersNeverRelayingScope(allNodes, 100); - var neverRelayBody; - if (neverRelay.sortedCapped.length > 0) { - var neverRelayRows = neverRelay.sortedCapped.map(function(n) { - return '' + esc(n.name || n.public_key) + '' + - '' + esc(n.role || '—') + '' + - '' + (n.relay_count_24h || 0).toLocaleString() + '' + - '' + timeAgo(n.last_seen) + ''; - }).join(''); - neverRelayBody = '' + - '' + - '' + neverRelayRows + '' + - '
RepeaterRoleRelays (24h)Last Seen
'; - } else { - neverRelayBody = '

Every known repeater/room has relayed at least one region-scoped packet.

'; - } - setSectionHtml(neverRelayEl, detailsSection( - 'Repeaters Never Relaying Any Scope (' + neverRelay.total.toLocaleString() + ')', - 'Repeater/room nodes that have never carried a single region-scoped (TRANSPORT_FLOOD/DIRECT) packet, ever — not the same set as "no default_scope" above: a repeater\'s hashRegions config can let it relay for others even when its own adverts never carry a matching transport code. ' + - 'Sorted by current relay volume — the busiest ones are the most consequential to configure first' + (neverRelay.truncated ? ' (showing the top ' + neverRelay.sortedCapped.length + ')' : '') + '.', - neverRelayBody, - 'never-relay' - )); - } catch (e) { + if (neverRelayEl) { + if (allNodes) { + renderNeverRelaySection(allNodes); + } else { setSectionHtml(neverRelayEl, detailsSection('Repeaters Never Relaying Any Scope', null, '

Failed to load.

', 'never-relay')); } } } + // Renders (and re-renders, on geo filter change) the "Repeaters Never + // Relaying Any Scope" section from the already-fetched node list — same + // reactive-filter pattern as renderNoScopeSection above. + function renderNeverRelaySection(allNodes) { + var neverRelayElInner = document.getElementById('scopes-never-relay-scope'); + if (!neverRelayElInner) return; + try { + var neverRelay = computeRepeatersNeverRelayingScope(allNodes, 100, { geo: neverRelayFilter.geo }); + var neverRelayBody; + if (neverRelay.sortedCapped.length > 0) { + var neverRelayRows = neverRelay.sortedCapped.map(function(n) { + return '' + esc(n.name || n.public_key) + '' + + '' + esc(n.role || '—') + '' + + '' + (n.relay_count_24h || 0).toLocaleString() + '' + + '' + timeAgo(n.last_seen) + ''; + }).join(''); + neverRelayBody = '' + + '' + + '' + neverRelayRows + '' + + '
RepeaterRoleRelays (24h)Last Seen
'; + } else if (neverRelayFilter.geo) { + neverRelayBody = '

No repeaters match this filter.

'; + } else { + neverRelayBody = '

Every known repeater/room has relayed at least one region-scoped packet.

'; + } + setSectionHtml(neverRelayElInner, detailsSection( + 'Repeaters Never Relaying Any Scope (' + neverRelay.total.toLocaleString() + ')', + 'Repeater/room nodes that have never carried a single region-scoped (TRANSPORT_FLOOD/DIRECT) packet, ever — not the same set as "no default_scope" above: a repeater\'s hashRegions config can let it relay for others even when its own adverts never carry a matching transport code. ' + + 'Sorted by current relay volume — the busiest ones are the most consequential to configure first' + + (neverRelay.filteredTotal !== neverRelay.total ? ', filtered to ' + neverRelay.filteredTotal.toLocaleString() + ' matching below' : '') + + (neverRelay.truncated ? ' (showing the top ' + neverRelay.sortedCapped.length + ')' : '') + '.', + geoFilterButtons(neverRelayFilter, 'never-relay-geo-filter') + neverRelayBody, + 'never-relay' + )); + } catch (e) { + setSectionHtml(neverRelayElInner, detailsSection('Repeaters Never Relaying Any Scope', null, '

Failed to load.

', 'never-relay')); + return; + } + + var sectionEl = document.getElementById('scopes-never-relay-scope'); + if (!sectionEl) return; + sectionEl.querySelectorAll('[data-never-relay-geo-filter]').forEach(function(btn) { + btn.addEventListener('click', function() { + neverRelayFilter.geo = btn.dataset.neverRelayGeoFilter || ''; + renderNeverRelaySection(allNodes); + }); + }); + } + load(selectedWindow); @@ -5301,6 +5340,27 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf // returns a most-recently-active-first slice capped at `cap` rows. // `total` is always the full unfiltered count; `filteredTotal` reflects // opts. + + // Classifies live from the node's own lat/lon against the currently + // configured geo_filter (window.MC_GEO_FILTER, set from + // /api/config/client — see public/roles.js), mirroring the Nodes tab's + // Domestic/Foreign filter (public/nodes.js) and renderForeignTrafficTab's + // isForeignNode below — NOT the node's one-way, never-cleared `foreign` + // DB flag. + function nodeMatchesGeo(n, mode) { + if (!mode) return true; + var domestic = nodePassesGeoFilter(n.lat, n.lon, window.MC_GEO_FILTER); + return mode === 'domestic' ? domestic : !domestic; + } + + function geoFilterButtons(filter, dataAttr) { + return '
' + + '' + + '' + + '' + + '
'; + } + function computeNodesWithoutScope(allNodes, cap, opts) { opts = opts || {}; var noScopeNodes = allNodes.filter(function(n) { return !n.default_scope; }); @@ -5314,6 +5374,9 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf .map(function(r) { return { role: r, count: roleCounts[r] }; }); var filtered = noScopeNodes; + if (opts.geo) { + filtered = filtered.filter(function(n) { return nodeMatchesGeo(n, opts.geo); }); + } if (opts.role) { filtered = filtered.filter(function(n) { return (n.role || 'unknown') === opts.role; }); } @@ -5349,16 +5412,19 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf // out rather than silently 0-counted alongside them. Sorted by // relay_count_24h descending: the busiest unconfigured repeaters are the // most consequential ones to fix first. - function computeRepeatersNeverRelayingScope(allNodes, cap) { + function computeRepeatersNeverRelayingScope(allNodes, cap, opts) { + opts = opts || {}; var candidates = allNodes.filter(function(n) { return (n.role === 'repeater' || n.role === 'room') && (!n.transported_scopes || n.transported_scopes.length === 0); }); - var sorted = candidates.slice().sort(function(a, b) { + var filtered = opts.geo ? candidates.filter(function(n) { return nodeMatchesGeo(n, opts.geo); }) : candidates; + var sorted = filtered.slice().sort(function(a, b) { return Number(b.relay_count_24h || 0) - Number(a.relay_count_24h || 0); }); return { total: candidates.length, + filteredTotal: filtered.length, sortedCapped: sorted.slice(0, cap), truncated: sorted.length > cap, }; diff --git a/test-analytics-nodes-without-scope.js b/test-analytics-nodes-without-scope.js index f60b173f..160b0df4 100644 --- a/test-analytics-nodes-without-scope.js +++ b/test-analytics-nodes-without-scope.js @@ -219,6 +219,29 @@ test('no opts (or empty opts) behaves exactly as before — filteredTotal equals assert.strictEqual(result.filteredTotal, result.total); }); +test('opts.geo narrows to domestic/foreign via window.MC_GEO_FILTER, same box the Nodes tab uses', () => { + // Denmark-ish box: lat 54-58, lon 8-13. A node outside it is "foreign". + ctx.window.MC_GEO_FILTER = { latMin: 54, latMax: 58, lonMin: 8, lonMax: 13 }; + try { + const nodes = [ + { public_key: 'pk1', name: 'Domestic1', role: 'repeater', default_scope: null, lat: 56.0, lon: 10.0 }, + { public_key: 'pk2', name: 'Foreign1', role: 'repeater', default_scope: null, lat: 40.0, lon: 20.0 }, + ]; + const domestic = computeNodesWithoutScope(nodes, 100, { geo: 'domestic' }); + assert.strictEqual(domestic.filteredTotal, 1); + assert.strictEqual(domestic.sortedCapped[0].name, 'Domestic1'); + + const foreign = computeNodesWithoutScope(nodes, 100, { geo: 'foreign' }); + assert.strictEqual(foreign.filteredTotal, 1); + assert.strictEqual(foreign.sortedCapped[0].name, 'Foreign1'); + + const all = computeNodesWithoutScope(nodes, 100); + assert.strictEqual(all.filteredTotal, 2, 'no geo opt leaves both nodes'); + } finally { + ctx.window.MC_GEO_FILTER = null; + } +}); + console.log('\n=== analytics.js: computeRepeatersNeverRelayingScope ==='); test('is exported for testing', () => { @@ -285,6 +308,26 @@ test('returns zero total when every repeater/room has relayed at least one scope assert.deepStrictEqual(result.sortedCapped, []); }); +test('opts.geo narrows to domestic/foreign, leaving total (unfiltered) unchanged', () => { + ctx.window.MC_GEO_FILTER = { latMin: 54, latMax: 58, lonMin: 8, lonMax: 13 }; + try { + const nodes = [ + { public_key: 'pk1', name: 'DomesticRepeater', role: 'repeater', transported_scopes: null, lat: 56.0, lon: 10.0 }, + { public_key: 'pk2', name: 'ForeignRepeater', role: 'repeater', transported_scopes: null, lat: 40.0, lon: 20.0 }, + ]; + const domestic = computeRepeatersNeverRelayingScope(nodes, 100, { geo: 'domestic' }); + assert.strictEqual(domestic.total, 2, 'total stays the full unfiltered count'); + assert.strictEqual(domestic.filteredTotal, 1); + assert.strictEqual(domestic.sortedCapped[0].name, 'DomesticRepeater'); + + const foreign = computeRepeatersNeverRelayingScope(nodes, 100, { geo: 'foreign' }); + assert.strictEqual(foreign.filteredTotal, 1); + assert.strictEqual(foreign.sortedCapped[0].name, 'ForeignRepeater'); + } finally { + ctx.window.MC_GEO_FILTER = null; + } +}); + console.log('\n════════════════════════════════════════'); console.log(` Nodes Without Scope: ${passed} passed, ${failed} failed`); console.log('════════════════════════════════════════'); From f11d3d69e51a59bdb154a41ecab73e46089cab1b Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 22 Jul 2026 17:09:45 +0200 Subject: [PATCH 23/24] 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. --- AREAS.md | 25 +++++++------ cmd/server/area_filter_test.go | 13 +++---- cmd/server/config.go | 14 ++++---- cmd/server/db.go | 22 +++++++----- cmd/server/db_test.go | 65 +++++++++++++++++++++++++++++----- cmd/server/routes.go | 8 ++--- cmd/server/routes_test.go | 4 +-- cmd/server/types.go | 28 +++++++-------- public/analytics.js | 22 +++++++----- 9 files changed, 133 insertions(+), 68 deletions(-) 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 + '' + From 83931e8ec3007edff6185e30b82cf16fb4323b03 Mon Sep 17 00:00:00 2001 From: dborup Date: Wed, 22 Jul 2026 17:33:31 +0200 Subject: [PATCH 24/24] feat: show which specific scope each node supports in Scope Adoption by Area AreaScopeAdoption.Matching entries now carry MatchedScopes (which of the area's linked regionScopes each node actually matched, via default_scope or by relaying it) instead of just a bare name/key ref. A node can match more than one when an area links several scopes and the node uses/relays more than one of them. Frontend: an area linking more than one scope (e.g. Europa's "eu" and "europe") now splits its Supporting list into one sub-group per scope instead of a single flat list, so it's visible which nodes support which specific scope. --- cmd/server/db.go | 12 +++++------- cmd/server/db_test.go | 37 ++++++++++++++++++++++++------------- cmd/server/types.go | 19 ++++++++++++++++--- public/analytics.js | 23 +++++++++++++++++++++-- 4 files changed, 66 insertions(+), 25 deletions(-) diff --git a/cmd/server/db.go b/cmd/server/db.go index 7e7fc763..34079c79 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -2358,20 +2358,18 @@ func computeScopeAdoptionByArea(nodes []nodeAreaScopeInput, areas map[string]Are // 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 + var matchedScopes []string for _, rs := range c.RegionScopes { normalizedRegion := strings.ToLower(rs) if ownScope == normalizedRegion || relayedRegions[normalizedRegion] { - regionMatch = true - break + matchedScopes = append(matchedScopes, rs) } } - ref := RepeaterRef{Name: n.Name, PublicKey: n.PublicKey} - if regionMatch { + if len(matchedScopes) > 0 { c.NodesMatchingArea++ - c.Matching = append(c.Matching, ref) + c.Matching = append(c.Matching, AreaScopeMatch{Name: n.Name, PublicKey: n.PublicKey, MatchedScopes: matchedScopes}) } else { - c.NotMatching = append(c.NotMatching, ref) + c.NotMatching = append(c.NotMatching, RepeaterRef{Name: n.Name, PublicKey: n.PublicKey}) } } } diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index 1357db49..20af417d 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -2498,9 +2498,13 @@ func TestComputeScopeAdoptionByArea_MultipleRegionScopes(t *testing.T) { {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"}, + // Own scope is #eu AND it also relays #europe -- must be + // reported as matching BOTH, not just the first one found. + {PublicKey: "usesBoth", Lat: 48.0, Lon: 14.0, DefaultScope: "#eu"}, } relayInfo := map[string]RepeaterRelayInfo{ "relaysEurope": {TransportedScopes: []string{"#europe"}}, + "usesBoth": {TransportedScopes: []string{"#europe"}}, } got := computeScopeAdoptionByArea(nodes, areas, relayInfo) @@ -2508,23 +2512,30 @@ func TestComputeScopeAdoptionByArea_MultipleRegionScopes(t *testing.T) { t.Fatalf("got %d areas, want 1", len(got)) } eu := got[0] - if eu.TotalNodes != 4 { - t.Errorf("TotalNodes = %d, want 4", eu.TotalNodes) + if eu.TotalNodes != 5 { + t.Errorf("TotalNodes = %d, want 5", 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) + // usesEu (#eu), usesEurope (#europe), relaysEurope (relays #europe), + // and usesBoth (both) all match one of Europa's two linked scopes; + // usesNeither (#dk) matches neither. + if eu.NodesMatchingArea != 4 { + t.Errorf("NodesMatchingArea = %d, want 4 (any of #eu or #europe should count)", eu.NodesMatchingArea) } - matchingKeys := map[string]bool{} + matchedScopesByKey := map[string][]string{} for _, m := range eu.Matching { - matchingKeys[m.PublicKey] = true + matchedScopesByKey[m.PublicKey] = m.MatchedScopes } - for _, want := range []string{"usesEu", "usesEurope", "relaysEurope"} { - if !matchingKeys[want] { - t.Errorf("Matching missing %q, got %v", want, eu.Matching) - } + if diff := matchedScopesByKey["usesEu"]; len(diff) != 1 || diff[0] != "eu" { + t.Errorf("usesEu.MatchedScopes = %v, want [\"eu\"]", diff) + } + if diff := matchedScopesByKey["usesEurope"]; len(diff) != 1 || diff[0] != "europe" { + t.Errorf("usesEurope.MatchedScopes = %v, want [\"europe\"]", diff) + } + if diff := matchedScopesByKey["relaysEurope"]; len(diff) != 1 || diff[0] != "europe" { + t.Errorf("relaysEurope.MatchedScopes = %v, want [\"europe\"]", diff) + } + if diff := matchedScopesByKey["usesBoth"]; len(diff) != 2 || diff[0] != "eu" || diff[1] != "europe" { + t.Errorf("usesBoth.MatchedScopes = %v, want [\"eu\" \"europe\"] (node uses one scope AND relays the other)", diff) } if len(eu.NotMatching) != 1 || eu.NotMatching[0].PublicKey != "usesNeither" { t.Errorf("NotMatching = %v, want just usesNeither", eu.NotMatching) diff --git a/cmd/server/types.go b/cmd/server/types.go index 41b2e3da..0f0e8195 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -228,9 +228,12 @@ type AreaScopeAdoption struct { // 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"` + // into two groups otherwise). Matching entries also carry WHICH of the + // area's regions each node matched (an area with several linked + // scopes, e.g. Europa's "eu"/"europe", needs this to answer "which + // nodes support which scope" — not just an aggregate yes/no). + Matching []AreaScopeMatch `json:"matching,omitempty"` + NotMatching []RepeaterRef `json:"notMatching,omitempty"` } type RepeaterRef struct { @@ -238,6 +241,16 @@ type RepeaterRef struct { PublicKey string `json:"publicKey"` } +// AreaScopeMatch is a RepeaterRef plus which of the area's own +// RegionScopes this node actually matched (via default_scope or by having +// relayed it) — a node can match more than one when an area links several +// scopes and the node uses/relays more than one of them. +type AreaScopeMatch struct { + Name string `json:"name"` + PublicKey string `json:"publicKey"` + MatchedScopes []string `json:"matchedScopes"` +} + type ScopeRegionRepeaters struct { Region string `json:"region"` Count int `json:"count"` diff --git a/public/analytics.js b/public/analytics.js index 0186bb7e..2144c869 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -5101,6 +5101,23 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf return '' + esc(r.name) + ''; }).join(', '); } + // For areas linking more than one scope (e.g. Europa: "eu" and + // "europe"), a flat Supporting list can't say WHICH scope each + // node actually uses/relays — split it into one sub-list per + // scope instead. A node matching more than one scope (its own + // default_scope is one, and it relays another) appears in every + // group it matched, not just the first. Single-scope areas keep + // the plain flat list (grouping by one group is just noise). + function matchingByScope(matching, regionScopes) { + return regionScopes.map(function(rs) { + var inGroup = (matching || []).filter(function(m) { + return (m.matchedScopes || []).indexOf(rs) !== -1; + }); + return '
#' + esc(rs) + ' (' + inGroup.length + '): ' + + (inGroup.length ? nodeLinks(inGroup) : 'none') + + '
'; + }).join(''); + } var areaGroups = byArea.map(function(a) { var summary, body; if (a.regionScopes && a.regionScopes.length) { @@ -5108,9 +5125,11 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf var scopeCodes = a.regionScopes.map(function(rs) { return '#' + esc(rs) + ''; }).join(' or '); summary = esc(a.label) + ' — ' + matchCount.toLocaleString() + ' of ' + a.totalNodes.toLocaleString() + ' support ' + scopeCodes + ' (' + pct(matchCount, a.totalNodes) + ')'; + var supportingBody = a.regionScopes.length > 1 + ? matchingByScope(a.matching, a.regionScopes) + : (a.matching && a.matching.length ? nodeLinks(a.matching) : 'none'); body = - '
Supporting (' + (a.matching || []).length + '): ' + - (a.matching && a.matching.length ? nodeLinks(a.matching) : 'none') + + '
Supporting (' + (a.matching || []).length + '): ' + supportingBody + '
' + '
Not supporting (' + (a.notMatching || []).length + '): ' + (a.notMatching && a.notMatching.length ? nodeLinks(a.notMatching) : 'none') +