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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Same "Show all N / Show fewer" pattern just added for Top Senders and
Coverage by Observer, extended to Sessions and Entry Points — a 7-day
window can have a lot of rows in all four tables.
Entry Points splits the resolve step (async /resolve-hops call, runs
once) from the render step (sync, runs once per toggle) so expanding
doesn't re-hit the API. The Ambiguous bucket row, when present, stays
always-visible and doesn't count against the top-10 limit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signal Quality Trends now renders right after the message-volume
chart, above Top Senders and Coverage by Observer (previously it was
lower, after Sessions/Entry Points).
Top Senders and Coverage by Observer collapse to the top 10 rows by
default, with a "Show all N" / "Show fewer" toggle — same pattern
already used for node neighbor lists (nodes.js). Coverage's "% of
Observations" stays relative to ALL observers, not just the shown
top-10 slice, so the percentages don't lie when collapsed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wardriving analytics tab (all 6 originally-proposed ideas): activity
over time, entry-point repeaters, per-observer coverage, signal
quality trends, session detection, and coordinate-broadcast/GPS-share
detection — plus per-sender message drill-down, live-map path
visualization (real GPS trail, approximate entry-point trail, and a
compare view between the two), and session airtime (LoRa Time-on-Air
x distinct relaying repeaters).
Also reapplies c4b4453 (Foreign Traffic tab Bornholm fix), which had
only existed on foreign-traffic-wip and never reached master.
User report: "Hvorfor er der bornholm repeater under Foreign Traffic
igen!?" — this exact bug was already fixed once this session (commit
c4b4453), but that fix only ever existed on foreign-traffic-wip and
was never merged into master. wardriving-analytics branched from
master, so it never had the fix, and every stg deploy of this branch
today silently reintroduced it.
Reapplies c4b4453's fix: renderForeignTrafficTab now classifies
"foreign" live from each node's own lat/lon via nodePassesGeoFilter +
window.MC_GEO_FILTER (same mechanism the Nodes page already uses),
instead of trusting the node's `foreign` DB flag — a one-way flag
written once by the ingestor's MarkNodeForeign and never cleared, so
a node flagged foreign under an old, narrower geo_filter box stays
flagged forever even after the box widens to include it.
Note: while investigating, found that foreign-traffic-wip also has
entire Foreign Traffic tab features (Entry Points, Distance vs Hop
Count sections) that never reached master either — out of scope for
this specific fix, worth a separate look.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Caught live on stg: every wardriving session showed airtimeMs=0, even
for senders with confirmed multi-hop relay paths. The in-memory store
is memory-bounded (maxMemoryMB/maxPackets) and had already evicted
these transmissions despite them still being well within the SQL
24h/7d window — AirtimeForTransmissions treated "not found in memory"
the same as "found, zero relays", so ok=true with a silent 0 came back
indistinguishable from a genuinely never-relayed message.
Now ok=false when NONE of the requested IDs are held in memory
(nothing knowable → omit the field). A partial match still returns
ok=true with the known subset's total, matching how every other
airtime metric already tolerates eviction rather than going dark
entirely once retention exceeds the in-memory window.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds AirtimeMs to WardrivingSession: total network airtime consumed
relaying a session's messages, using the exact same formula as the
Overview tab's "Relay Airtime Share" (issue #1768) — ToA(payload_bytes)
x COUNT(DISTINCT resolved repeater in path), summed per message.
That formula needs the in-memory store's resolved-path index
(resolvedPubkeyReverse), which db.go's SQL-only wardriving code
doesn't have — so buildWardrivingSessions now also tracks each
session's transmission IDs (unexported, json:"-"), and the route
handler calls the new PacketStore.AirtimeForTransmissions(txIDs) to
fill in AirtimeMs after the fact. Omitted entirely (nil) in DB-only
mode rather than shown as a misleading zero.
Frontend adds an Airtime column to the Sessions table (ms/s
formatting, dash when unavailable).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
For messages where a sender shared their real position AND have a
resolvable entry-point repeater, pairs them up: haversine distance
shown inline in the message table ("2.3 km from DK-XXX"), plus a new
"Compare shared position vs. entry point" drill-down button.
On the map (drawGPSTrail's new 'compare' kind), draws both trails —
real position (blue/red/green) and entry-point repeater (orange) —
with a thin dashed line connecting each matched pair, so you can see
at a glance how good the entry-point proxy is when ground truth is
available.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
For senders who don't share a literal position, each message's
resolved path[0] entry-point repeater has a known, fixed location.
Plotting those in chronological order gives a rough "which coverage
area were they in" trail — not the sender's real position, just the
nearest repeater that first relayed them at that moment. Consecutive
messages hitting the same repeater collapse into one point.
Adds a second drill-down button, "View approximate path via entry
points", alongside the existing GPS-share path button. drawGPSTrail
(map.js) now accepts an optional per-point label (shows the repeater
name in the popup) and an opts.kind so the on-map caption makes clear
when a trail is the approximate entry-point kind vs a real shared
position.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds drawGPSTrail (map.js) — a self-contained polyline+markers renderer
for a plain sequence of lat/lon points, distinct from drawPacketRoute's
mesh-hop/node-resolution path. Handed off via the same sessionStorage
pattern already used for packet routes (map-route-hops), keyed
map-gps-trail: {points, sender}. Start/end points are colored blue/red,
intermediate points green, each with a timestamped popup.
In the Wardriving tab's per-sender drill-down, a "View path on map"
button appears whenever a session/sender has 2+ shared positions in
the fetched range, ordered chronologically (the API returns
most-recent-first).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found in production: at least one wardriving client appends plaintext
"<lat>,<lon>" after the standard token (e.g.
"MM:c3e_zJ1rUA:55.59743,13.00128"), confirmed against live traffic.
The generic anomaly framing (flagging non-standard payload length,
showing a raw hex dump) was based on the wrong assumption that this
was an undocumented binary format — it's actually deliberate,
human-readable coordinate sharing by that sender's client.
Replaces WardrivingAnomaly/detectWardrivingAnomalies/
StandardPayloadCount with WardrivingGPSShare/detectWardrivingGPSShares:
detects the specific "<token>:<lat>,<lon>" suffix (with range
validation) and reports the most recent position per sender. The
per-message drill-down gets Lat/Lon instead of a standard/anomaly
flag. Frontend replaces the "Payload Anomalies" section with "GPS
Sharing", linking each position to the live map
(#/map?lat=..&lon=..&zoom=15, an existing deep-link format).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
.btn-link is a large touch-target utility class (min-height/width
48px, background/border chrome) used for toolbar icon buttons — not a
lightweight inline-link style. Applying it to sender names inside a
table cell made them look like broken buttons. Switched to inline
styles matching the existing plain-link pattern used elsewhere
(nodes.js's "Show all N neighbors" button): no background/border,
color: var(--link-color).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New GET /api/analytics/wardriving/sender-messages returns one sender's
individual messages (most-recent-first, capped at 200): resolved
entry-point path (path[0] first), per-observer SNR/RSSI, and payload
standard/anomaly classification. Pass since+until to scope to one
session's exact range; otherwise it covers the sender's whole window.
Frontend makes sender names in Top Senders and Sessions clickable —
clicking toggles an inline expansion row with that sender's messages,
resolving every distinct path prefix in one /resolve-hops call (same
unique_prefix-only discipline as the aggregate Entry Points table).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
decodeGrpTxt (cmd/ingestor/decoder.go) builds decoded_json.text as
"<sender>: <message>", so a wardriving message's real text is
"<sender>: MM:<base64>", not a bare "MM:<base64>" at the start of the
string. detectWardrivingAnomalies's HasPrefix(text, "MM:") check never
matched anything live, silently returning standardPayloadCount=0 and
no anomalies — caught after deploying idea 6 to stg and seeing 0/0
against traffic that should have had ~160 standard-format messages.
Fixed to match the exact "<sender>: MM:" prefix. The Go test fixtures
shared the same wrong assumption and are fixed too.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extends GET /api/analytics/wardriving with a payload-anomaly detector:
every "MM:<base64>" message is checked against the standard 7-byte
anonymous session token (confirmed empirically against live traffic).
Non-standard lengths or undecodable payloads are grouped per sender
as a candidate signal that MeshMapper's optional "Broadcast My
Coordinates" mode is active — surfaced as a raw hex dump, never
interpreted as lat/lon, since that mode's byte format is undocumented.
Messages without the "MM:" prefix (plain channel chat) are ignored
entirely rather than polluting either bucket.
Frontend adds a Payload Anomalies table plus a stat card, completing
all 6 originally-proposed wardriving analytics ideas.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extends GET /api/analytics/wardriving with each sender's messages
grouped into distinct sessions/runs — a gap over 15 minutes starts a
new one. Each session reports duration, message count, and how many
distinct entry-point repeaters/observers it touched. Frontend adds a
Sessions table (most-recent-first) and a session-count stat card.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extends GET /api/analytics/wardriving with avgSnr/avgRssi over the
same time buckets as the activity series, plus overall averages.
Frontend adds two min/max-scaled line charts (SNR has no natural
zero floor, RSSI is negative dBm, so these can't reuse the 0-baseline
message-volume chart) and two stat cards.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Surfaced by the new Wardriving Coverage-by-Observer table: every
observer on this Danish mesh showed a "—" location because iataCoords
only had US/global codes. Same root cause as issue #1786, just never
extended to the codes this specific mesh's observers actually use.
Added the 11 codes I could verify as real airport IATA codes; QXV,
KRP, MRW look non-standard and are left unresolved.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New GET /api/analytics/wardriving endpoint plus an Analytics tab
covering the three requested angles: activity over time + top
senders, entry-point repeaters (path[0] tally, unique_prefix-only
name resolution), and per-observer coverage using observers' known
IATA coordinates. MeshMapper's on-air ping is an anonymous session
token by default, not the sender's live GPS, so sender position
itself isn't tracked — documented in the tab and OpenAPI description.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Scoped/Unscoped/Unknown counts answered "how much" but not "which
regions" — a channel spanning multiple hashRegions areas looked
identical to one using just one. New "Regions Used" column lists the
distinct scope_name values seen on each channel's scoped messages,
most-used first.
Backend: GetChannelScopeAdoption now also queries per-(channel,
scope_name) counts and attaches them as ChannelScopeAdoption.Regions.
Non-fatal on query failure — adoption counts still render without it.
[dijkstra] doc-noted that `total` in handleNodes' filtered path reflects
only the returned page-slice, not an all-pages count — matches
pre-existing semantics, not a regression, but worth being explicit that
fetchAllNodes (public/app.js) never reads it for pagination (relies on
page length instead).
[munger] the pagination compensation loop's maxIterations=50 safety cap
now logs a WARN breadcrumb when it's actually hit, instead of silently
returning a possibly-short page. New test seeds 51 consecutive
hidden-prefix nodes at limit=1 to force the cap and asserts both the
empty result and the log line.
MAJOR (carmack): handleConfigClient and nodeListPostFilters both read
s.cfg.GeoFilter unlocked, but the field is cfgMu-guarded — PUT
/api/config/geo-filter swaps it under Lock(). Both sites now go through
s.getGeoFilter() like the other existing read sites.
MAJOR (dijkstra): window.MC_GEO_FILTER is set asynchronously by roles.js's
/api/config/client fetch, but nodes.js read it synchronously during
loadNodes(). On a cold page load where the Nodes page renders before that
fetch resolves, every node was silently classified as domestic until the
user clicked a filter chip. loadNodes() now awaits window.MeshConfigReady
(which never rejects) before the geo-scope filter runs. New regression
test proves the race with a manually-controlled promise, and fails
against the pre-fix code (verified via git stash).
MINOR: stale test-file header comment (still described the filter as
using the `foreign` flag, predating 6012ed0's lat/lon rewrite); loose
interface{} != float64 comparison in the geoFilter config test replaced
with explicit type assertions so a shape change fails loudly instead of
comparing unequal to the wrong type.
NIT: trimmed an 8-line struct-field doc comment; added
role="group"/aria-label to both the Status and Domestic/Foreign
filter-button groups on the Nodes page (bot noted Status already lacked
one too).
geo_filter, node blacklist, and hidden-name-prefix were all applied to a
page AFTER the SQL LIMIT already fixed its size, so a page that was
genuinely full at the DB layer could come back shorter than requested.
fetchAllNodes (public/app.js) treats "page shorter than requested" as
"this was the last page" and stops — so a single filtered-out row
anywhere in a page silently truncated everything after it.
Found on stg.meshview.dk: the live map showed ~450 of ~1300 GPS-valid
nodes because a hidden-name-prefix node happened to land in the first
500-row page.
handleNodes now loops, re-fetching and re-filtering additional DB pages,
until it has collected a full page of post-filter results or the DB
itself runs dry (a raw page shorter than requested = genuine end of
data) — restoring the "short page really means end of data" contract
fetchAllNodes relies on. Regression test reproduces the exact scenario
and fails against the old code (verified via git stash).
The `foreign` flag only reflects nodes whose ADVERT was decoded and
classified AFTER geo_filter was configured. On stg.meshview.dk this made
the just-shipped filter nearly useless: only 27 of 1503 nodes were
flagged foreign, while 570 actually have GPS placing them outside the
configured box (543 of those simply never got re-flagged).
Exposes the geo_filter box/polygon via /api/config/client (new
GeoFilter field on ClientConfigResponse), and adds nodePassesGeoFilter
+ helpers to app.js — a faithful JS port of internal/geofilter's
PassesFilter/PointInPolygon/DistToSegmentKm, cross-checked line-for-line
against the real Go implementation for bbox, polygon, and buffer cases.
Nodes with no GPS fix (or (0,0)) count as domestic, matching both the
Go semantics and the user's explicit choice for this feature.
nodes.js's Domestic/Foreign filter-group (added in f99df27) now calls
nodePassesGeoFilter(n.lat, n.lon, window.MC_GEO_FILTER) instead of
reading n.foreign.
The unfiltered node list is dominated by foreign-flagged nodes (#730
geo_filter classification) on deployments with heavy cross-border
traffic — hard to see which of 1500+ listed nodes are actually local.
Adds an All/Domestic/Foreign filter-group next to the existing Status
filter, narrowing on each node's `foreign` boolean, persisted to
localStorage like the other Nodes-page filters.
Bot review on PR #1852 (munger, MAJOR-adjacent→MINOR): "bounded by the
1-byte channel hash space" only holds for encrypted channels (256
enc_%02x buckets) — plain-text CHAN channels use a free-form name
string (ingestor/db.go), so the bound isn't as hard as the comment
claimed. Correctness of the uncap itself is unaffected; this is a
doc-only fix.
GetChannelScopeAdoption was capped at the top 30 channels by message
volume, silently hiding quieter channels from the Scopes tab. Channel
cardinality is bounded by the 1-byte hash space, so the cap wasn't
protecting against anything real — removed it.
Client gets a filter (All/Unencrypted/Encrypted) based on the 'enc_'
channel_hash prefix db.go already uses for channels the ingestor
couldn't decrypt, so a visitor can separate plain channels from
encrypted ones without hunting through a long unfiltered list.
Was a collapsed-by-default <details> section further down the Overview
sub-tab; now renders uncollapsed directly under the stat-card rows,
matching the always-visible Channel Messages section's style.
Surfaces the headline number from each of the other two sub-tabs
(regions used, bridge repeaters, nodes without a default scope,
repeaters never relaying) right under the main summary cards, so a
visitor doesn't have to switch sub-tabs to see them.
Also de-duplicates the fetchAllNodes call the two Hygiene sections used
to make independently — it's now fetched once for the highlights row
and reused by both.
Description text was inside <details> as a sibling of <summary>, so
<details> hid it along with the table whenever a section was collapsed.
Moving it inside <summary> (which is always shown regardless of open
state) surfaces the "what is this" context without expanding.
Clarifies the section is specifically about default_scope (not the
separate "never relayed a scope" signal below it), and adds role filter
buttons (e.g. companions only) plus a name/key search box so the list is
usable beyond the top 100 most-recently-active nodes.
The sub-tab split (4b37038) grouped the tab into 3 screens, but each
one still stacked several full sections/tables — Nodes Not Using Any
Scope and Repeaters Never Relaying Any Scope alone can each run 100
rows. Wrapped every subsection (Scope Adoption by Channel, Region
Breakdown & Trend, Activity by Hour of Day, Region Utilization,
Repeaters by Region, Nodes Running This Region, Bridge Repeaters, and
both hygiene lists) in a collapsed-by-default <details>/<summary>,
via a small shared detailsSection(title, description, body) helper.
The summary label carries the real count computed at render time
("Nodes Not Using Any Scope (1423 of 1491)"), so the headline number
is visible without expanding anything — matches the same "see the
count, decide if you need the table" shape the existing per-region
<details> in Repeaters by Region / Nodes Running This Region already
used, just applied one level up. Left the top-of-panel Scope
Statistics and Channel Messages stat-card grids always visible — 4
small cards each, not a scroll problem, and they're the headline
numbers worth seeing on load.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The tab grew to 12 stacked sections as this branch's work landed
(windowed adoption stats, all-time region breakdowns, and the two new
node/repeater hygiene lists) — one long scroll with no way to jump to
a specific concern. Grouped into 3 sub-tabs:
- Overview: the 1h/24h/7d-windowed stuff (stat cards, channel
messages, scope adoption by channel, per-region table, time series,
hour-of-day heatmap). The window picker now lives inside this
panel, not above the sub-tab bar, since it only affects this group
— the other two ignore it entirely.
- Regions: all-time region breakdowns (utilization, repeaters by
region, nodes running this region, bridge repeaters).
- Hygiene: the two adoption-gap lists (nodes not using any scope,
repeaters never relaying any scope).
Purely presentational — all three groups' data still loads together
in one pass (one /scope-stats fetch + the hygiene sections' own
fetchAllNodes calls); only DOM visibility toggles on the active
sub-tab, so there's no new fetch/render logic to get wrong. Selection
persists in sessionStorage (scopes_subtab), same pattern as the
existing window-picker persistence.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Distinct from "Nodes Not Using Any Scope": a repeater's own
default_scope (does it send its own scoped adverts?) and
transported_scopes (has it ever relayed ANYONE's region-scoped
traffic?) are different signals. Checked the real numbers on
stg.meshview.dk before building this — of 1181 repeaters with no
default_scope, 168 (14%) still relay scope for others (their
hashRegions config enables relaying even though their own adverts
never carry a matching transport code), so the two lists aren't
interchangeable.
Filters to repeater/room nodes only (the only roles that can relay at
all) with empty/absent transported_scopes, sorted by relay_count_24h
descending — the busiest still-unconfigured repeaters are the most
consequential to fix first. Same pure-function pattern as
computeNodesWithoutScope (computeRepeatersNeverRelayingScope),
reusing the node list already fetched for the sibling section instead
of a second fetchAllNodes call. 6 new unit tests, including one
pinning the exact "no default_scope but does relay for others" case
found in the real data.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The flip side of the existing "Nodes Running This Region" section:
every node with no default_scope configured at all — never set up a
hashRegions region for itself. Computed client-side from the full
node list (default_scope isn't windowed/aggregated server-side the
way the rest of scope-stats is).
On stg.meshview.dk this surfaced a real adoption gap: 1423 of 1491
known nodes (95%) have no scope configured at all, 1128 of them
repeaters. Shows the role breakdown plus a most-recently-active-first
table (capped at 100 rows) — worth reaching out to first, since
they're still transmitting.
Extracted the filter/tally/sort logic into computeNodesWithoutScope,
a pure module-level function (same pattern as packets.js's
reconcileVisibleCols) so it's unit-testable without needing to stand
up the Scopes tab's full DOM skeleton, which updateData reaches into
directly via getElementById rather than writing to a passed-in
element the way renderForeignTrafficTab does.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The prior caveat claimed a foreign_advert flag arriving via a healthy
multi-hop local relay chain was "almost always" a wrong/joke GPS
value on an otherwise-local node. That assumed LoRa mesh range tops
out in the tens of km, but this deployment's own node population
(checked earlier this session) spans a dense, continuous mesh across
much of Northern/Western Europe — a FLOOD packet can legitimately
hop node-to-node across hundreds of km through a sufficiently dense
chain, no MQTT bridging or propagation anomaly required. Reworded to
state the traceable-relay-chain case as genuine foreign traffic
reaching the mesh, not data-quality noise.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- MAJOR: config.example.json was missing geoFilterExemptNodeList
entirely — every other Config field in this repo gets a sibling
_comment_<field> entry (established, actively-used convention
throughout the file, ~20 existing examples), documented here to
match. Also corrected the adjacent geo_filter _comment, which
predates this PR and claimed it "restricts ingestion" — it doesn't;
that's foreignAdverts.mode=drop's job, unrelated to this flag.
- MINOR: ?geoFilter=<v> only recognized the literal string "1" to
enable and treated anything else (including "true") as disabling —
a real footgun since "true" reads as an obviously valid boolean
value. Now accepts 1/true and 0/false as explicit overrides; any
other value (including absent) falls through to the deployment
default instead of silently flipping it off.
- Tightened the Foreign Traffic tab's stop-hook test: it previously
only asserted stop() doesn't throw, which a no-op stub would also
pass. Sandbox's setInterval/clearInterval are now real spies
tracking live/cleared ids, so the test verifies render() registers
exactly one interval and stop() actually clears that exact id
(idempotently on a second call).
- NIT: relays.sort now wraps unscoped_relay_count_24h in Number(...)
defensively (AGENTS.md § Type Safety — cast at the boundary).
- NIT: foreignNodes.sort now parses last_seen once per node
(decorate-sort-undecorate) instead of twice per comparator call.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
7cc5aae's GeoFilterAppliesToNodeList had the polarity backwards: it
defaulted to false, meaning geo_filter would NOT apply to the node
list unless a deployment explicitly opted in — but every existing
deployment that already had geo_filter configured (predating this
field entirely) would decode that field as false too, since it's
simply absent from their config.json. They'd silently get the new
"show everything" behavior on upgrade with no way back short of
reading release notes and adding a new config key — exactly the
breaking change the opt-in gate was meant to prevent, just one layer
deeper.
Renamed to GeoFilterExemptNodeList (still defaults false): geo_filter
now applies to the node list by DEFAULT when configured, matching the
long-standing #730 behavior exactly, for every deployment predating
this field. Only a deployment that explicitly sets
geoFilterExemptNodeList=true (i.e. one adopting geo_filter fresh,
purely for foreign_advert classification/analytics) gets the
non-filtering default. ?geoFilter=0/1 still overrides either default
for a single request.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>