Adds a "spread up to Nkm" part to the ping-bot's reply, computed as the
farthest any hearing station's own GPS fix was from whoever heard it
first -- the same signal View Path's map shows, surfaced directly in
the chat bubble so it's visible without opening the map. Deliberately
skips View Path's neighbor-centroid position fallback (too expensive
to run per-ping across a page of messages); a station without its own
GPS fix just doesn't contribute, so the part is silently omitted when
fewer than two stations have one.
"heard by N observers" already reported total station breadth per
transmission, so no separate count was added.
The "relays for 2+ distinct region scopes" definition doesn't hold up
in practice: most nodes end up carrying both a broad national/regional
scope (e.g. #dk, #eu) and several local ones, so the vast majority of
repeaters would eventually qualify as "bridge" -- the flag stops
meaning anything useful.
Removes IsBridge from PacketPathPoint/PacketPathObserver, the
markBridgeRepeaters handler step, the purple-outline styling, and the
dedicated test file -- keeps PublicKey on PacketPathObserver, since
the click-to-node-detail feature depends on it independently.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds DistanceFromFirstKm to PacketPathBranch: the great-circle
distance (haversine) between a branch's own Observer and First's
Observer. Zero for First itself. Deliberately omitted when either
side is positioned via Approx (a neighbor-centroid estimate) -- a
distance computed against a guess isn't a real measurement worth
surfacing.
Rendered in the observer tooltip as "42.3 km away" alongside the
elapsed-time label, giving a concrete sense of how far the flood
physically reached, not just how many hops or how long it took.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds IsBridge to PacketPathPoint/PacketPathObserver and PublicKey to
PacketPathObserver (needed to look bridges up, and useful on its own
for future features). Set by a new markBridgeRepeaters handler-level
step in routes.go, not GetPacketPath itself, since the underlying data
(TransportedScopes per repeater) lives in the in-memory store, not
SQL -- same "relays for 2+ distinct regions" definition and data
source as the Foreign Traffic tab's existing Bridge badge
(ScopeStatsResponse.bridgeRepeaters), just applied to whichever
handful of pubkeys one packet's path touches.
The map gives a bridge repeater a bold purple outline (on top of
whatever primary/approx styling already applies) plus a tooltip note,
so the mesh's backbone nodes stand out regardless of which branch
they're in.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PacketPathPoint already carried Role; PacketPathObserver now does too
(from the observer's own nodes row, when it's known as a mesh node
itself and not just an MQTT/API listener). Rendered as a small icon
prefix in the tooltip (📡 repeater, 🏠 room, 📱 client, 🌡️ sensor) --
markers stay plain circleMarker dots throughout, since a role-specific
shape would clash with the color/dash coding already carrying primary,
approx, and observer meaning.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
nearestPositionedNeighbor now also returns how many positioned
neighbors fed the weighted centroid and the widest distance between
any two of them (0 with a single contributor). Exposed via new
ApproxNeighborCount/ApproxSpreadKm fields on PacketPathPoint and
PacketPathObserver.
The map scales the approximate marker's size/opacity accordingly: one
neighbor (or several that disagree widely) renders as a bigger,
fainter ring; several agreeing neighbors render tighter and more
solid. The tooltip also states the contributor count.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds SecondsAfterFirst to PacketPathBranch: the gap between the
earliest-arriving observation (First) and this branch's own deepest
observation. Zero for First itself. Rendered in the observer's tooltip
as "+4.7s" (or "Nm Ss" for longer gaps, "first to arrive" for zero) --
gives a sense of the propagation order across the flood, not just
depth/breadth.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
nearestPositionedNeighbor picked exactly one neighbor (the strongest by
edge count) and used its exact coordinates -- so the approximate marker
always landed precisely on top of that neighbor's own dot. Since each
neighbor's own position is a real, precise fix, and only the
unpositioned node's position relative to them is unknown, more
positioned neighbors should narrow the estimate rather than being
ignored in favor of just the top one.
Now sums every positioned neighbor's coordinates weighted by
neighbor_edges.count and returns the centroid. With exactly one
positioned neighbor this is unchanged (reduces to that neighbor's exact
position); with several, the marker settles somewhere among them
instead of overlapping a single specific node.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A hop or observer with no position of its own (no self-advertised GPS,
no name match, no IATA) previously stayed unplotted entirely. Adds a
last-resort fallback: look up its strongest neighbor_edges neighbor
(ranked by observation count) and, if that neighbor has a real
position, borrow it as an approximate stand-in -- flagged via a new
`approx` field so callers never mistake it for a real fix.
The View Path map renders approximate markers hollow and dashed
instead of solid, with a status-line callout ("N approximate (via
nearest neighbor)"), and the "N hops without a known position" count
drops accordingly for whichever ones now have a stand-in.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GetPacketPath's node-position lookups (relay-hop points and observer
positioning) read raw lat/lon straight off the nodes row, so a node
with (0,0) stored literally instead of NULL -- MeshCore's "never
actually reported a GPS position" case in practice -- rendered as a
real point off the coast of Ghana instead of no position at all.
Excludes (0,0) the same way GetNodesForScopeAdoption and
geofilter.PassesFilter already do; the node's name is kept, just not
the bogus position.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GetPacketPath's branches are sorted deepest-first, so the map had no
natural "where did this start" anchor. Adds a `first` field: the
single earliest-arriving observation across every station regardless
of observer or hop depth (the same "first observation wins" pick
already used for a ping message's own meta line), positioned the same
way as any other observer (own GPS, then name match, then IATA).
The map draws it as a green ring on top of everything else, since it
usually coincides with one of the already-plotted branch dots, plus a
status-line callout -- an approximate landmark for where the message
entered the visible mesh.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Some bridge-type observers (seen in the wild on openHop-Repeater
firmware) publish their MQTT status keyed by device name instead of
their mesh pubkey, so observers.id never matches nodes.public_key for
them even though the same physical device has a real, positioned node
row under its actual pubkey. The pubkey-based GPS lookup added in
94be933 silently missed these.
Now falls back to matching the observer's display name against nodes
when the pubkey lookup finds nothing, skipping the match entirely if
more than one positioned node shares that name rather than guessing
which one is the real observer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GetPacketPath only ever tried the hardcoded iataCoords table to place
an observer. Any station whose configured IATA code isn't a real
airport (a custom/regional code, or a typo) fell out of the map
entirely -- even when the station is itself a mesh node that has
self-advertised a real GPS position, the same source /api/observers
and the Wardriving tab already treat as authoritative.
Now checks the observer's own node-table position first (folded into
the existing bulk pubkey lookup, no extra query) and only falls back
to the IATA table when it has none.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GetPacketPath used to resolve only the single farthest-traveled
observation and discard the rest. It now returns one branch per
distinct station (kept at that station's own deepest observation),
each with its own hop count and, where resolvable, relay chain --
so the map visualizes the packet's full flood spread instead of one
route. Stations whose path never resolved, or who heard the packet
directly (0 hops), still contribute a branch via their own position,
rather than being silently dropped.
The "View path" link in the ping-bot reply no longer requires hops > 0
to show, since even an all-direct ping still has observer spread worth
plotting.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both are already shown on the triggering message's own meta line right
above it, so repeating them in the pong reply was redundant. Drops the
handler-level appendAreaToBotReply pass (routes.go) entirely along with
its now-unused scope plumbing through pendingPing (db.go) and the
client-side pingBotReply (channels.js).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Trigger check moved from a single string comparison to a small
pingTriggerWords set (mirrored by hand in db.go and channels.js), so
adding more trigger words later is a one-line change in each. Still an
exact match after the existing @mention-stripping -- "/pingx" etc. don't
match.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New GET /api/packets/{hash}/path resolves a packet's DEEPEST observation
(same "farthest leg is more informative" reasoning as the ping-bot reply
itself) to a geographic point sequence: each relay's name/role/lat/lon in
path order, plus the hearing observer's position (from its configured
IATA code, like the Wardriving tab). Hops that have never advertised a
GPS position come back with null lat/lon rather than being dropped, so
the frontend can draw a gap instead of guessing.
Frontend: public/packet-path-map.js is a small on-demand Leaflet modal
(reuses node-reach-map.js's tile/marker conventions, but draws an ordered
chain instead of a star) opened via a new "View path" link on the
ping-bot reply -- shown only when there's an actual multi-hop route and
packet hash to look up. Kept general (keyed by packet hash, not
ping-specific) since any packet with a resolved path could use it later.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
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>
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>
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.
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>
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>
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>
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 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>
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>
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>
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.
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.
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>
Adds channelScopeAdoption to /api/scope-stats: the existing
ChannelMessages aggregate (scoped/unscoped/unknown for channel chat)
broken down PER CHANNEL — which specific channels (#test,
#wardriving, ...) actually use region scoping vs which never do.
Ordered by message volume, capped at the top 30 channels.
Frontend renders a compact table under "Channel Messages": channel
name, total messages, scoped count+%, unknown count.
Adds hourlyActivityByRegion to /api/scope-stats: each region's message
counts bucketed by hour-of-day (0-23 UTC), aggregated across every day
in the window — answers "when during a typical day is this region
active" rather than "how did volume change over the window" (that's
the existing chronological TimeSeries chart).
Frontend renders a compact heatmap: one row per region, 24 hour
columns, color intensity normalized per-row (each region's own busiest
hour) so a quiet region's daily shape stays visible next to a loud
one instead of being crushed toward zero.
Adds channelMessages to /api/scope-stats: the same scoped/unscoped/
unknown question as the main Summary, but restricted to payload_type=5
(channel chat) instead of all observed traffic. Most channel chat is
plain FLOOD rather than transport-scoped, so this can read very
differently from the all-traffic numbers — answers "how many of our
actual channel messages carry a region scope" directly instead of
requiring the reader to infer it from the broader stats.
New GetChannelMessageScopeStats() mirrors GetScopeStats' query shape
but scopes TotalMessages to ALL route types for payload_type=5 (not
just route_type 0/3), since restricting to transport routes would
answer a different question than "how many channel messages, period".
Frontend renders a small "Channel Messages" stat-card row under the
main summary cards, window-scoped like the rest of the tab.
Adds originatingNodesByRegion to /api/scope-stats: nodes whose OWN
default_scope (#899) is a given region, complementing the existing
repeatersByRegion (transported_scopes) breakdown. The distinction
matters — a repeater can relay traffic for a region it isn't itself
configured with, so "who runs this region" and "who has carried this
region's traffic" are different, both useful questions.
Frontend: refactored the per-region collapsible-list rendering (used
by both breakdowns) into a shared renderRegionNodeGroups() helper
instead of duplicating the HTML-building logic, and added a "Nodes
Running This Region" section alongside "Repeaters by Region".