Matches the term used everywhere else in CoreScope (/api/observers,
observer names, etc.) instead of an inconsistent synonym.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GetChannelMessages previously used "first observation wins" for a ping's
botReply -- if the same flood was heard by multiple stations, only
whichever one happened to be scanned first shaped the hop count, SNR, and
relay path. That understates reach: different stations legitimately hear
the same flood at different hop depths depending on which relay leg
reached them.
Now tracks, across every observation of the ping: the DEEPEST (max-hop)
observation's hops/SNR/relay path, and every DISTINCT station that heard
it. The reply shows the deepest leg's path and reports "heard by N
stations" once more than one did (falling back to the single station's
name when there's exactly one, same as before).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
REST-loaded channel history (GetChannelMessages) now resolves the ping
message's relay path to node names ("via RepeaterA → RepeaterB"), bulk-
resolving every referenced pubkey across the page in one query rather
than per-message. Falls back to the raw pubkey when a hop's node isn't
known. Also includes the message's region scope and (via a handler-level
pass, since area resolution needs server config db.go doesn't have)
its resolved area.
The client-side pingBotReply (WebSocket live-push + PSK-channel decrypt
paths) gains scope/area too, since both are already present in that data.
Relay-path names are REST-only for now: the live WS broadcast doesn't
carry a resolved_path, only historical/REST-loaded messages do.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The ping->pong synthesis only ran in GetChannelMessages (server-side,
REST-loaded history). Messages that arrive while a channel is already
open -- via the WebSocket live-push path, or the client-side decrypt path
for user-added PSK channels -- are built directly from the broadcast
payload and never round-trip through that endpoint, so typing "ping" in
an already-open channel showed nothing until a manual reload.
Adds a client-side pingBotReply() mirroring the Go version's trigger rule
and reply format, wired into both paths.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A channel message whose text is exactly "ping" (mention-prefix like
"@MeshviewBot ping" stripped first) now gets a synthesized "pong" reply
showing hop count, SNR, and hearing observer -- computed at read time
from that message's own already-stored data, no new table.
Deliberately NOT transmitted back onto the mesh: CoreScope has no publish
path to a MeshCore broker/radio (confirmed: it only ever subscribes to
MQTT, never publishes). The reply is visible only in CoreScope's own
Channels view, rendered as a visually distinct dashed-border bubble with
an explicit "Not sent to the mesh" caveat so it's never mistaken for a
real bot reply the sender's own radio received.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MeshCore firmware sends two capability bytes on ADVERT packets (wire bits
per AdvertDataHelpers.h) whenever the HasFeat1/HasFeat2 flags are set.
CoreScope already decoded them per-packet but discarded them rather than
storing a per-node value.
- New feat1/feat2 columns on nodes/inactive_nodes (internal/dbschema,
idempotent ALTER like the existing multibyte_sup/multibyte_evidence
columns).
- Ingestor's UpdateNodeTelemetry now writes feat1/feat2 alongside
battery_mv/temperature_c in the same COALESCE-based UPDATE, from the
same ADVERT payload.
- Server exposes them on /api/nodes and /api/nodes/{pubkey} (nullable,
same shape as battery_mv/temperature_c).
- Node detail page shows them as raw hex (0x....) in the Overview panel
when present -- undecoded, since CoreScope doesn't know the individual
bit meanings, but no longer silently discarded.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Was rendered last, below the stat cards and bar chart -- moved above them
since it's the most at-a-glance signal (is containment trending better or
worse) and shouldn't require scrolling past a static snapshot to see.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GetHopDepthAnalytics now buckets its scoped/unscoped hop-index tallies by
time (same 5min/1h/6h bucketing as GetScopeStats' TimeSeries) and computes
a per-bucket median for each series, exposed as a new TimeSeries field.
Median is a *int, nil when a bucket had no traffic of that kind -- 0 is a
valid median hop, so absence has to stay distinguishable from zero.
Frontend renders it as a two-line SVG trend chart on the Scopes > Hop
Depth sub-tab (same visual language as Overview's scoped/unscoped
time-series), answering "is containment getting better or worse over the
window" rather than just a single window-wide snapshot. Each series'
polyline is built from contiguous non-null segments so a gap renders as
a visible break instead of silently interpolating through missing data.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A bridge repeater (confirmed relaying for 2+ regions, ScopeStatsResponse.
bridgeRepeaters) is supposed to see traffic that already traveled far from
another region's scope -- a high hop-depth reading there is expected, not
a containment leak. Adds a small "Bridge" badge next to those repeaters'
names so the min/median/max hop columns added earlier can be read in that
context, and a plain regional repeater with the same pattern stands out
as the one actually worth investigating.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a third stat card to the Scopes > Hop Depth section: the P95 of the
unscoped hop-depth histogram, framed as a concrete flood.max.unscoped
starting point instead of leaving the operator to eyeball the bar chart.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Was crowding the Overview panel; gets its own "Hop Depth" sub-tab next to
Overview/Regions/Hygiene, with its own 1h/24h/7d window picker wired to
the same selectedWindow/load() as the rest of the tab.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extends the #1812 per-node relay hop-count work with a network-wide view:
GET /api/analytics/hop-depth answers (1) whether scoped traffic actually
travels fewer hops than unscoped before hitting a repeater's flood.max cap
(Scopes tab Overview: new "Flood Containment" comparison), and (2) which
repeaters relay unscoped traffic that already traveled far vs merely
locally (Foreign Traffic tab: min/median/max hop columns joined onto the
existing unscoped-relay table by public key).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New GET /api/nodes/{pubkey}/hop_analytics?days= returns, for each recent
transmission that passed through this node as a relay, the node's own
0-based index within the packet's resolved path -- the value MeshCore
firmware compares against flood_max/flood_max_advert/flood_max_unscoped
in allowPacketForward (Packet.cpp path_len = header & 63; MyMesh.cpp
getPathHashCount() >= flood_max). Deliberately distinct from the
existing hopDistribution field on /analytics, which measures path
length to whichever observer reported the packet -- a different,
unrelated number, confirmed via the upstream issue's multi-round
firmware-source discussion.
Candidate-finding reuses the same byPathHop-index + resolved_path
confirmation approach as handleNodePaths (routes.go), written fresh
rather than refactored out of that function to avoid any regression
risk in its older, correctness-critical prefix-collision handling
(#929, #1197, #1278, #1352). Only transmissions with a canonical
resolved_path contribute a data point -- no resolved_path means no
reliable hop index, so it's skipped rather than guessed.
Frontend: new "Relay Hop-Count" card on the node analytics page --
histogram (Chart.js) + a hand-drawn boxplot canvas above it sharing the
same x-axis (no Chart.js boxplot plugin is loaded), filterable by chip
matching the firmware knob names (flood default, flood_advert,
flood_unscoped, direct).
The old single "Mesh live area" checkbox actually showed the
homeArea-linked geo_filter boundary, which was confusing paired with
the already-existing "Area: X" dropdown (AreaFilter) that filters
which nodes show on the map -- picking an area there drew nothing.
Now two independent checkboxes on both Map and Live:
- "Geo filter boundary" -- unchanged behavior, the geo_filter/homeArea
polygon (with buffer zone if configured).
- "Selected area outline" -- new, draws whichever area is currently
picked via the Area dropdown (falls back to a box when the area has
no polygon), checked by default whenever an area is selected.
Same "Show all N / Show fewer" pattern as the Wardriving tab's
Sessions/Entry Points sections -- both lists on Foreign Traffic can
grow long on a busy network, dwarfing the rest of the tab.
?hasScope=true|false keeps only nodes that have/haven't ever
transported a region-scoped packet; ?hashRegion=eu,be (comma-separated,
leading # optional) keeps only nodes that have transported at least
one of the given regions. Both combine with each other (AND) and with
the existing ?role= filter, matching the suggested API shape from
https://github.com/Kpa-clawbot/CoreScope/issues/1862.
Implemented as two new predicates in the existing nodeListPostFilters
(same post-SQL-LIMIT compensation-loop machinery geo_filter/blacklist/
area already use), backed by the same bulk relay-info map
(GetRepeaterRelayInfoMap) the Scopes tab's "Repeaters Never Relaying
Any Scope" section reads -- fetched once per request, not per DB page.
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.
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.
"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.
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.
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.
Keeping the scope tag plain ("Scope: #dk") -- the path[0]-resolved
"From: <area>" 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 <noreply@anthropic.com>
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: <area>" 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 <noreply@anthropic.com>
"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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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>