Commit Graph
1082 Commits
Author SHA1 Message Date
dborupandClaude Sonnet 5 bc62814158 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 <noreply@anthropic.com>
2026-07-21 18:02:49 +02:00
dborupandClaude Sonnet 5 6b31a6ab12 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 <noreply@anthropic.com>
2026-07-21 17:49:31 +02:00
dborupandClaude Sonnet 5 b21803db22 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 <noreply@anthropic.com>
2026-07-21 17:32:07 +02:00
dborupandClaude Sonnet 5 9661fd0f4d 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 <noreply@anthropic.com>
2026-07-21 17:11:26 +02:00
dborupandClaude Sonnet 5 bb65e0893b 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 <noreply@anthropic.com>
2026-07-21 16:52:22 +02:00
dborupandClaude Sonnet 5 8da5f8312e feat: add Scope Adoption by Area to the Scopes tab
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>
2026-07-21 16:40:58 +02:00
dborupandClaude Sonnet 5 206dc5d3ff 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 <noreply@anthropic.com>
2026-07-21 16:24:16 +02:00
dborupandClaude Sonnet 5 81cb1a13fc feat: approximate a wardriving session's area from its entry-point repeater
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>
2026-07-21 15:29:59 +02:00
dborup 9e476fc5c2 Merge master (top-10 collapse feature) into areas-meshguide-sync 2026-07-21 15:27:42 +02:00
dborupandClaude Sonnet 5 303b5d452d 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 <noreply@anthropic.com>
2026-07-21 12:33:20 +02:00
dborupandClaude Sonnet 5 8344b08683 feat: collapse Sessions and Entry Points to top 10 too
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>
2026-07-21 08:11:54 +02:00
dborupandClaude Sonnet 5 352e1e322c feat: reorder Wardriving tab and collapse Top Senders/Coverage to top 10
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>
2026-07-21 07:56:07 +02:00
dborupandClaude Sonnet 5 1d2cab9bb5 fix: Bornholm repeater back under Foreign Traffic (c4b4453 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>
2026-07-20 18:21:07 +02:00
dborupandClaude Sonnet 5 5083161832 feat: session airtime — LoRa Time-on-Air x distinct relaying repeaters
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>
2026-07-20 17:37:52 +02:00
dborupandClaude Sonnet 5 9d77d2ea96 feat: compare a GPS-sharing sender's real position against path[0]
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>
2026-07-20 17:17:21 +02:00
dborupandClaude Sonnet 5 23b650337f feat: approximate map path via entry-point repeaters for non-GPS senders
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>
2026-07-20 17:03:46 +02:00
dborupandClaude Sonnet 5 82a09f75da feat: view a wardriving session's shared-position path on the live map
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>
2026-07-20 16:52:41 +02:00
dborupandClaude Sonnet 5 00f346ba3a feat: replace payload-anomaly detector with GPS-sharing detector
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>
2026-07-20 16:38:30 +02:00
dborupandClaude Sonnet 5 64603a5ae5 fix: sender drill-down trigger rendered as an ugly gray/brown box
.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>
2026-07-20 16:08:09 +02:00
dborupandClaude Sonnet 5 1821b21310 feat: Wardriving per-sender message drill-down
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>
2026-07-20 15:51:21 +02:00
dborupandClaude Sonnet 5 3453caee53 feat: Wardriving coordinate-broadcast detection (idea 6)
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>
2026-07-20 15:18:01 +02:00
dborupandClaude Sonnet 5 5ee832a38c feat: Wardriving session detection (idea 5)
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>
2026-07-20 14:57:18 +02:00
dborupandClaude Sonnet 5 fb7bb240cc feat: Wardriving signal quality trends (idea 4)
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>
2026-07-20 14:23:56 +02:00
dborupandClaude Sonnet 5 ea1d48aca8 feat: Wardriving channel analytics tab
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>
2026-07-20 13:50:59 +02:00
dborup 524b6326fd feat: show which regions are actually used per channel in Scope Adoption by Channel
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.
2026-07-20 11:21:46 +02:00
dborup 81bc9930af fix: address bot review on PR #1852 (2 MAJOR races + cleanup)
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).
2026-07-20 10:04:00 +02:00
dborup 2c80b44787 fix: Domestic/Foreign node filter now classifies from lat/lon, not the stale foreign flag
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.
2026-07-20 08:16:39 +02:00
dborup 06554ba172 feat: Domestic/Foreign filter on the Nodes page
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.
2026-07-20 08:16:39 +02:00
dborup d947ada49a feat: show all channels in Scope Adoption, filter encrypted/unencrypted
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.
2026-07-19 22:46:26 +02:00
dborup 097406e822 fix: Activity by Hour of Day always visible, moved under highlight cards
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.
2026-07-19 22:46:26 +02:00
dborup 4fe212a5b8 feat: Regions/Hygiene highlight stat cards on Scopes Overview
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.
2026-07-19 22:46:26 +02:00
dborup 84ba1da0f3 fix: keep Scopes tab section descriptions visible while collapsed
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.
2026-07-19 22:46:26 +02:00
dborup f2fc3931a4 feat: role/search filter for Scopes tab "Nodes Without a Default Scope"
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.
2026-07-19 22:46:26 +02:00
dborup 99b471c41b fix: preserve Scopes tab collapsible section open state across auto-refresh
The 60s auto-refresh rebuilds each section's innerHTML from scratch, which
was silently re-collapsing any <details> a visitor had open mid-read.
2026-07-19 22:46:26 +02:00
dborupandClaude Sonnet 5 a301de1ece feat: collapsible <details> sections within each Scopes sub-tab
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>
2026-07-19 22:46:26 +02:00
dborupandClaude Sonnet 5 904fd5bf42 feat: split the Scopes tab into Overview/Regions/Hygiene sub-tabs
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>
2026-07-19 22:46:26 +02:00
dborupandClaude Sonnet 5 30120bcf2e feat: Repeaters Never Relaying Any Scope section on the Scopes tab
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>
2026-07-19 22:46:26 +02:00
dborupandClaude Sonnet 5 40391b126d feat: Nodes Not Using Any Scope section on the Scopes tab
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>
2026-07-19 22:46:26 +02:00
dborupandClaude Sonnet 5 4855e26277 fix: correct Foreign-Flagged Nodes caveat — long relay chains are real, not a GPS artifact
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>
2026-07-19 06:46:31 +02:00
dborupandClaude Sonnet 5 7141acca7a fix: address remaining bot-review findings on the geo_filter/Foreign Traffic work
- 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>
2026-07-18 22:19:21 +02:00
dborupandClaude Sonnet 5 ac50b2f3e6 docs: add GPS-reliability caveat to Foreign-Flagged Nodes section
foreign_advert is purely self-reported GPS from the node's own
ADVERT. Investigating a real flagged node (RYDBOHOLM, self-reported
as Romania) showed its packets entering the DK mesh through a normal,
healthy 8-hop local FLOOD relay chain (RSSI/SNR consistent with real
short-range LoRa) — and MeshCore has no MQTT-to-RF bridging mechanism,
so that's only physically possible if the node is actually local and
its GPS is simply wrong. Documents the inference rule directly on the
tab: a flag near an actual border may be genuine; a flag claiming
hundreds/thousands of km away, arriving via a healthy local relay
chain, is a GPS data-quality artifact, not real long-distance traffic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 20:57:32 +02:00
dborupandClaude Sonnet 5 a55c237ea1 fix: move Foreign-Flagged Nodes list above the unscoped-relay table
Reordered so the node list (what's actually accumulating right now,
worth checking back on) comes first, with the unscoped-relay-volume
table — the part that needs more data to be interesting — below it.
Added a heading to the relay table for symmetry with the new
sub-heading above it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 20:45:43 +02:00
dborupandClaude Sonnet 5 492d15c43b feat: list foreign-flagged nodes on the Foreign Traffic tab
Adds a second table to the tab: every node currently foreign_advert
-tagged (name, role, lat/lon, first/last seen), newest-heard first —
so there's something to look at while more foreign_advert data
accumulates, instead of only the one-sentence summary count.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 20:41:21 +02:00
dborupandClaude Sonnet 5 62f1023c2f fix: stop implying flood.max.unscoped 0 is the well-configured target
A little unscoped traffic through a repeater is normal — flood.max.unscoped
caps it rather than blocking it outright, and setting it to 0 isn't a
realistic target in practice. The Foreign Traffic tab's copy read as
"anything nonzero is misconfigured," which isn't true; reworded to
frame the view as spotting disproportionate volume, not achieving
zero-tolerance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 20:34:04 +02:00
dborupandClaude Sonnet 5 679ab2fb86 test: add DOM coverage for the Foreign Traffic tab + auto-refresh hook
Per bot review on PR #1852 (comment 5012304813): renderForeignTrafficTab
shipped without a test, repeating the same TDD-policy finding flagged
on the prior round, and the tab lacked the 60s auto-refresh + stop-hook
convention its Roles/Scopes siblings use (data would go stale until the
user re-navigated away and back).

- Added _stopForeignTrafficRefresh + a 60s setInterval loop, wired into
  the tab-switch handler and destroy(), matching _stopRolesRefresh/
  _stopScopesRefresh exactly.
- New test-analytics-foreign-traffic-tab.js: loads analytics.js in a vm
  sandbox (same pattern as test-frontend-helpers.js's
  makeAnalyticsSandbox), stubs fetchAllNodes with a fixed node mix, and
  asserts row sort order, role/zero-count exclusion, the empty state,
  both foreignCount note variants, and that the new stop-hook is
  exported and idempotent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 20:23:09 +02:00
dborupandClaude Sonnet 5 4a7dd6b795 feat: add Foreign Traffic tab surfacing unscoped relay volume per repeater
New "Foreign Traffic" tab under Analytics, sorted by
unscoped_relay_count_24h descending — the metric already existed in
the /api/nodes response (cmd/server/repeater_liveness.go,
repeater_enrich_bulk.go) with an openapi doc string calling out
exactly this: "A well-configured repeater sets flood.max.unscoped 0,
so a non-trivial count flags a base-config problem" — but had zero
frontend surface until now.

Phase 1 of an investigation into unscoped traffic from PL/DE/SE
leaking into the DK mesh. Phase 2 (cross-referencing which of this
volume traces back to a foreign-origin sender via path_json) needs
foreign_advert data to accumulate first — it's only set going forward
from when geo_filter was configured, not backfilled for existing
history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 19:44:39 +02:00
dborupandClaude Sonnet 5 8545af6269 fix: move Nodes Running This Region above Bridge Repeaters on Scopes tab
Bridge Repeaters renders as a flat, non-collapsible table — unlike
Repeaters by Region and Nodes Running This Region, which group into
collapsible <details> per region. On stg.meshview.dk it currently has
80 rows (~2700px tall), which buried Nodes Running This Region far
down the page, reading as if the section had vanished entirely.
Reordering doesn't touch the row-count problem itself, but it stops
the shortest, most specific section from being the one that pays for
it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 19:09:37 +02:00
dborupandClaude Sonnet 5 10eed4b2f6 fix: address remaining bot-review findings on PR #1852
Working through the outstanding non-blocker findings across all five
automated review passes on the PR:

- MAJOR: GetChannelMessages' rows.Scan() error was silently discarded
  — a schema mismatch would produce zero-valued messages instead of a
  visible error. Now returns the error, matching sibling query loops.
- Extracted the hashRegions name-normalization rule (trim, "#"-prefix,
  dedupe) shared between cmd/ingestor's loadRegionKeys and cmd/server's
  region-utilization diff into a new internal/regions package, so the
  two can no longer drift apart on the rule independently.
- Batched GetRepeaterNamesByKeys' SQL IN (...) clause in chunks of 500
  — an unbounded clause risks SQLITE_MAX_VARIABLE_NUMBER on very large
  deployments' byPathHop candidate sets.
- handleScopeStats' remaining silently-swallowed err==nil branches
  (GetMatchedRegionNames, GetNodesByDefaultScope,
  GetChannelMessageScopeStats, GetChannelScopeAdoption) now log a WARN
  breadcrumb on failure instead of failing invisibly.
- Scope Adoption table was rendering 3 of the 4 ChannelScopeAdoption
  fields (Unscoped omitted) — the visible numbers didn't reconcile to
  the message total without doing the subtraction by hand. Added the
  column.
- Removed a duplicate `vertical-align: middle` declaration on
  .badge-transport (dead, not a behavior change).
- Deleted ChannelMessageResp — flagged for interface{} vs *string/*int
  typing, but turned out to be completely unused dead code; removing
  it resolves the finding more directly than retyping something
  nothing constructs.

Left two NIT/MINOR items as-is with reasoning:
- renderRegionNodeGroups' inline styles match the same pattern used in
  15+ other places in analytics.js — "fixing" only this one function
  would make it less consistent with the file, not more.
- TransmissionResp's interface{} fields (a different struct than the
  one just removed) are an established, actively-used pattern for
  nullable SQL-scanned values across that whole response type;
  retyping it is a much larger, unrelated refactor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 19:02:20 +02:00
dborupandClaude Sonnet 5 696aecc10d test: add coverage for scopeCellHtml and the column-prefs backfill
Per bot review on PR #1852 (comment 5011643190): two changes in the
recent delta had zero test coverage, which the repo's TDD policy
(AGENTS.md § Test-First Development) requires for both net-new UI
helpers and bug fixes on existing UI.

- scopeCellHtml (public/app.js): unit tests for all three states
  (non-transport dash, resolved region, unresolved "unknown") plus an
  XSS-escaping check.
- Column-prefs backfill (public/packets.js): extracted the inline
  reconciliation logic into a standalone reconcileVisibleCols(),
  exposed via _packetsTestAPI like the rest of packets.js's pure
  helpers, and unit tested directly instead of only through the DOM.

Writing the test surfaced a real bug in the original backfill: it
couldn't distinguish "column didn't exist yet" from "user explicitly
unchecked this column" — both look identical as a missing key in the
saved array, so any existing column a user had hidden (e.g. Observer)
would get silently resurrected on next page load. Fixed by persisting
a second "known columns" baseline (packets-visible-cols-known)
alongside the visibility array: a missing key only gets backfilled as
newly-visible if it's also absent from the known-columns baseline: a
pre-existing key missing from the saved array is a deliberate user
choice and stays hidden. Falls back to the pre-#1852 column list for
visitors whose saved prefs predate persisting the baseline at all.

Also registers scopeCellHtml in .eslintrc.json's cross-file globals
list (same treatment as transportBadge) — packets.js referencing it
was tripping no-undef since app.js and packets.js are separate
non-module scripts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 18:48:15 +02:00
dborupandClaude Sonnet 5 bcbcae1d9d fix: retroactively show newly-added table columns for saved column prefs
The Packets column-visibility toggle persists an explicit array of
visible column keys to localStorage. A returning visitor with a
pre-existing saved array (from before the Scope column existed) never
got it shown, since the load path used the saved array verbatim
instead of reconciling it against the current COL_DEFS. Missing keys
now default to visible (unless narrow-viewport-hidden), same as a
fresh visitor gets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 15:39:02 +02:00