Commit Graph
530 Commits
Author SHA1 Message Date
dborup 5b3b681dbc Merge branch 'areas-meshguide-sync' 2026-07-28 14:03:58 +02:00
dborup 2f044a6a00 feat: add "This Week's Best" rolling-7-day records to Ping Scores
The all-time records lock in forever once someone sets a big one (e.g.
a 364km farthest ping) -- ThisWeek mirrors the same 5 slots on a
rolling 7-day window so there's an achievable target to chase
regularly, shown alongside (not instead of) the all-time board.
2026-07-28 13:56:47 +02:00
dborup a76c7d3fe9 Merge branch 'areas-meshguide-sync' 2026-07-28 13:45:47 +02:00
dborup d5361d797f fix: label Top Senders leaderboard heading with its 30-day window
The board's scope silently differs from the other leaderboards
(all-time) -- make that explicit in the UI heading and API docs.
2026-07-28 13:42:03 +02:00
dborup 640c552181 fix: scope Top Senders leaderboard to a rolling 30-day window
Unlike the all-time records and the Top Relays/Top First-Hearers boards,
"who pings the most" is more meaningful as an ongoing thing than a
fixed leaderboard someone can win once and hold forever.
2026-07-28 13:37:32 +02:00
dborup f6ad495508 Merge branch 'areas-meshguide-sync' 2026-07-28 13:27:54 +02:00
dborupandClaude Sonnet 5 5180181083 feat: add Top Senders leaderboard to Ping Scores page
dborup asked for suggestions on what else to add to Ping Scores; picked
"Top Senders" from the shortlist -- the page had Top Relays and Top
First-Hearers but nothing ranking who actually sends the most pings.

Backend: new SenderLeaderboard field on PingScoresSnapshot, tallied
alongside the existing relay/observer leaderboards in
computeAllPingScores. Keyed by the sender display name straight from
the channel message (ping_triggers.sender) -- unlike relays/observers
there's no resolved pubkey to link back to a node, so entries
deliberately never carry one (PingLeaderboardEntry.Pubkey stays
omitted).

Frontend: leaderboardTableHtml gained an optional column-label param
("Sender" instead of the default "Node") since sender entries aren't
resolvable mesh nodes and "Node" would be misleading there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:23:53 +02:00
dborup 6feb65a0a0 Merge branch 'areas-meshguide-sync' 2026-07-28 06:31:30 +02:00
dborupandClaude Sonnet 5 f8c51ce8dc feat: surface unknown region-scopes discovered via observer neighbor reports
dborup: "kan vi have en panel med scopes vi ikke kender på corescope
som observer neighbors har fundet" -- a panel showing region-scope
names that turn up in neighbors' reported OTA scope lists but aren't
part of this deployment's configured hashRegions.

Backend: computeUnknownScopes (db.go) is a pure function over the same
AllObserverNeighborsEntry rows /api/observers/neighbors already fetches
-- no second query. Parses each neighbor's comma-joined scopes string,
excludes the "*" wildcard, normalizes missing "#" prefixes, diffs
against regionutil.NormalizeNames(cfg.HashRegions), and counts DISTINCT
neighbors per unknown scope (not raw rows, which would double-count a
neighbor seen by multiple observers) with up to 5 example neighbor
names. New unknownScopes field on the existing response.

Frontend: new "Scopes CoreScope Doesn't Know About Yet" panel on the
Observer Neighbors tool page, rendered above the main table, empty
(nothing shown) when there's nothing unknown -- absence is the normal
case, not an empty-state message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 06:27:53 +02:00
dborup 329388aa2a Merge branch 'areas-meshguide-sync' 2026-07-28 06:14:51 +02:00
dborupandClaude Sonnet 5 a4400cc19e feat: Observer Neighbors tool -- network-wide list of every observer's reported direct neighbors
dborup wanted a single place to see all direct neighbors observed
across every observer, rather than clicking into each one individually.
Discussed placement -- landed on Tools, matching the existing Path
Inspector/Trace Viewer pattern.

Backend: GetAllObserverNeighbors flattens every observer_neighbors row
network-wide, joined with observer/neighbor display names, cross-referenced
against the packet-derived neighbor_edges graph exactly like the
per-observer endpoint (memoized per observer_id to avoid redundant
queries). Reads the full result set into memory before running any
packetGraphNeighbors lookups -- issuing a query per row while the main
cursor is still open is the single-connection-pool deadlock class from
earlier this session. New GET /api/observers/neighbors, registered
before /api/observers/{id} since both are 3-segment patterns and
gorilla/mux matches by registration order. Blacklisted observers excluded.

Frontend: new Tools > Observer Neighbors page (observer-neighbors-tool.js),
sortable by any column + a client-side observer/neighbor name filter,
reusing the col-scope-list wrap fix from the per-observer panel and the
same row semantics (unresolved pubkey truncated/unlinked, "no reply" for
scope-query timeouts, packet-evidence confirmed/not-seen-yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 06:06:29 +02:00
dborup f2418875ee Merge branch 'areas-meshguide-sync' 2026-07-28 05:38:39 +02:00
dborupandClaude Sonnet 5 906051fc09 feat: "View Estimated Nodes on Map" for the Areas tab, reusing View Path's approx-marker technique
dborup asked for a map view of the estimated-position nodes, like View
Path's approximate markers off the Pong reply. Backend now returns
estimatedNodes -- the flat, network-wide list behind Position-Fix
Coverage Gaps' Approximated counts, with each node's actual estimated
lat/lon (computeAreaPositionGaps previously only tallied per-area
counts and discarded the coordinates nearestPositionedNeighbor already
computed). A new button on the Areas tab stores that list in
sessionStorage and opens the Map page, where a new drawEstimatedNodes
renderer (modeled on drawGPSTrail) plots each as a dashed, semi-transparent
circleMarker -- same "this position is a guess" visual language as View
Path's approx markers, sized/opacity-scaled by contributor count.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 05:29:15 +02:00
dborup eaa0c040e9 Merge branch 'areas-meshguide-sync' 2026-07-28 04:59:01 +02:00
dborupandClaude Sonnet 5 1e01d04c8a feat: Area analytics -- node density/health, cross-area bridge nodes, position-fix coverage gaps
New GET /api/analytics/areas endpoint and "Areas" analytics tab, built on
the configured drawn-polygon Areas (meshguide.dk sync), distinct from
hashRegion scope adoption:

- Density: node count + active/degraded/silent health + role mix per
  area, multi-membership via AreaKeysForPoint (a node in a sub-area also
  rolls up into its parent region).
- Bridge nodes: which nodes' packet-derived neighbor_edges reach into
  another area, ranked by how many other areas they touch -- distinct
  from the network-wide, area-unaware bridge_score.
- Position gaps: per area, real GPS fix vs. neighbor-centroid-estimated
  position, reusing nearestPositionedNeighbor (the same technique View
  Path's approx markers use) purely as an internal coverage signal.

30s TTL cache on the handler since position-gap computation calls
nearestPositionedNeighbor once per unpositioned node.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 11:54:25 +02:00
dborup f874f34ae4 Merge branch 'areas-meshguide-sync' 2026-07-27 09:47:18 +02:00
dborup de5cd11d5f feat: self-healing schema-detection flags, closes the startup race class
SaarMesh-Bot flagged on PR #1867 that the server's one-time detectSchema()
PRAGMA snapshot racing the ingestor's ALTER TABLE migrations isn't specific
to configured_scope -- it's a general property of every optional column,
and offered to send a fix. Built it ourselves instead.

hasResolvedPath/hasObsRawHex/hasScopeName/hasDefaultScope/
hasConfiguredScope/hasMultibyteSupCols/hasLastSeen are now schemaFlag
(atomic.Bool) methods instead of plain bools, self-healed by a background
ticker (healSchemaFlags) started once in OpenDB and stopped in Close().
Unlike an eager reprobe-on-read, the healer never nests inside another
caller's already-open *sql.Rows cursor, so it can't self-deadlock a
single-connection pool the way a naive "reprobe inside get()" version of
this fix did (caught immediately by the existing test suite hanging).
detectSchemaWithRetry's fixed 150ms budget is gone -- replaced by an
unbounded self-heal that catches the migration whenever it actually lands.

Every read site across db.go/store.go/chunked_load.go/main.go is now a
method call; test fixtures that previously force-set the old bool fields
now call .forceTrue() on the underlying schemaFlag.
2026-07-27 09:21:34 +02:00
dborup b7e9e89b89 Merge branch 'areas-meshguide-sync' 2026-07-26 19:27:10 +02:00
dborup 27628fbc3a feat: capture and chart SNR history per direct neighbor (#1865 follow-up)
dborup spotted the raw /neighbors payload also carries snr and
heard_secs_ago per neighbor -- previously dropped entirely by
handleNeighborsReport's parsing loop. Both fields are present regardless
of scope-query status (responded or timeout).

New append-only observer_neighbor_metrics table, inspired by the existing
RF Health tab's observer_metrics pattern: one row per neighbor per
/neighbors report, pruned on the same 30-day MetricsRetentionDays
schedule. Deliberately no ordering guard on the write side (unlike the
current-only observer_neighbors snapshot) -- every report is valid
history at its own timestamp.

GET /api/observers/{id}/neighbors/{pubkey}/metrics serves the raw
(undownsampled) history. Frontend renders a per-row SNR sparkline on the
Direct Neighbors panel using the same lightweight inline-SVG technique as
the RF Health grid's noise-floor sparklines (no Chart.js), loaded async
after the panel paints.
2026-07-26 19:21:54 +02:00
dborup 8f3ce2f6eb feat: cross-reference Direct Neighbors against the packet-derived graph
dborup's suggested "more ambitious use": flag firmware-confirmed neighbors
that our packet-path-inferred neighbor_edges graph has never seen adjacent
-- a diagnostic for coverage gaps / packet loss, not itself a fault.

GetObserverNeighbors now annotates each entry with seenViaPackets, checked
against neighbor_edges in both column directions (canonEdge orders
node_a<=node_b). Surfaced as a neutral "confirmed"/"not seen yet" column
on the Direct Neighbors panel, explaining the two possible causes in the
tooltip. This surfaces the mismatch only -- it does not yet feed the
path-hop disambiguator's own candidate scoring, which would be a separate,
larger change.
2026-07-26 18:58:29 +02:00
dborup 41b6d93d53 Merge branch 'areas-meshguide-sync' 2026-07-26 18:48:15 +02:00
dborup 156ba7c4e2 feat: Direct Neighbors panel on observer detail (#1865 follow-up)
Surfaces the observer's own firmware-reported zero-hop neighbor set --
ground truth from /neighbors, distinct from the packet-path-inferred
neighbor_edges graph. Requested by dborup after shipping the last-report
tracking: "should we show what neighbors it has under observer stat".

New observer_neighbors table (current-only snapshot, full replace on every
report so a dropped neighbor disappears rather than lingering). Guarded
against out-of-order reports by checking the report timestamp against the
already-touched observers.last_neighbors_report_at before replacing.

GET /api/observers/{id}/neighbors joins against nodes for name/role;
absence (never reported, or an unresolved pubkey) always renders neutrally
-- empty array not null, no warning icon -- consistent with the last
feature's no-shame requirement.
2026-07-26 18:40:21 +02:00
dborup d34089e979 Merge branch 'areas-meshguide-sync' 2026-07-26 18:19:40 +02:00
dborup 96059a5b11 fix: wire last_neighbors_report_at through the ObserverResp API DTO
The Observer DB struct carried the field correctly, but handleObservers/
handleObserverDetail build a separate field-by-field ObserverResp DTO for
the actual JSON response, which never picked it up -- caught live on stg
(field missing from /api/observers entirely). Added handler-level tests
that decode the JSON response, since the existing DB-layer test for this
class of field didn't reach the DTO conversion at all.
2026-07-26 18:13:23 +02:00
dborup 0a55abe533 feat: track and surface which observers send /neighbors reports (#1865)
cwichura asked on PR #1867 for a way to identify which observers have the
opt-in /neighbors firmware feature enabled, to help contact operators about
enabling it -- explicitly asking that the UI not "shame" observers that
lack it (non-PSRAM hardware can't send it; other MQTT uploaders haven't
been updated to include it).

Adds observers.last_neighbors_report_at, touched by the ingestor's new
TouchObserverNeighborsReport once per /neighbors MQTT message regardless
of whether any neighbor carried scope evidence. Added to dbschema's
AssertReady mustCol list from day one so the server can read it
unconditionally with no PRAGMA-detection race (#1321 pattern).

Surfaced as a sortable "Neighbors" column on the observers list and a
stat card on the observer detail page -- both render a neutral dash/
"never" with an explanatory tooltip when absent, never a warning icon.
2026-07-26 17:54:11 +02:00
dborup 24962e94fc Merge branch 'areas-meshguide-sync' 2026-07-26 17:23:36 +02:00
dborup d9c454030c fix: normalize configured_scope_at to canonical UTC before last-write-wins
SaarMesh-Bot flagged on #1865 (PR #1867 commit 631686a) that
UpdateNodeConfiguredScope stored the report timestamp raw and compared it
lexicographically. Real firmware emits microsecond+offset timestamps and
different observers may use "Z" or non-UTC offsets, so string comparison
can diverge from chronological order and let a stale report win.

normalizeReportTS parses RFC3339Nano/RFC3339 and stores canonical UTC
RFC3339, used for both storage and the comparison.
2026-07-26 17:19:28 +02:00
dborup 5dc7c85b8b Merge branch 'areas-meshguide-sync' 2026-07-26 16:24:16 +02:00
dborup a7f4a85009 fix: normalize configured_scope to #-prefixed form like default_scope
cwichura flagged on PR #1867 that nodes.configured_scope (from the
/neighbors OTA report) was stored as the observer's raw scope string
("dk") while default_scope and every other scope display use the
#-prefixed hashRegion form ("#dk"). Apply the same regions.Normalize
already used for default_scope, with "*" passed through unprefixed
since it's a protocol wildcard, not a region name.
2026-07-26 16:03:38 +02:00
dborupandClaude Sonnet 5 e6cc7a5d99 chore: exclude meshguide sync script + ping-scores backfill from master
Two dborup-specific, already-used pieces held back from the generic
master branch:

- ops/meshguide-sync/sync_areas.py: fetches Danish region boundaries
  from meshguide.dk, useless for any other CoreScope deployment. This
  fast-forward merge brought it in again (areas-meshguide-sync had it
  restored after an earlier accidental deletion); excluding it here,
  same as every previous merge round.
- cmd/ingestor/db.go's ping_triggers_backfill_v1 async migration: a
  one-time historical scan that already ran and completed on stg
  (298 pings recorded from before the feature existed). Per dborup:
  other deployments merging from master didn't ask for a full-table
  CHAN-message scan on their own database. The ping-scores feature
  itself (detection, scoring, leaderboards, frontend) stays -- only
  the backfill-specific function + its tests are removed.

Both pieces remain intact on areas-meshguide-sync, where they've
already served their purpose.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 15:34:31 +02:00
dborupandClaude Sonnet 5 bcb190bffa feat: backfill ping_triggers from historical CHAN messages
Requested by dborup after seeing the highscore board start empty on
deploy. A new async migration (ping_triggers_backfill_v1, following the
existing tx_last_seen_backfill_v1 pattern) scans payload_type=5
transmissions for the ping trigger once, catching CHAN messages sent
before this feature existed -- they never went through
InsertTransmission's isNew detection hook, since that only fires for
brand-new transmissions going forward.

Pulled the migration body into a named backfillPingTriggers function
(unlike its inline siblings) so tests can call it directly instead of
faking the async marker/goroutine machinery.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 15:18:31 +02:00
dborupandClaude Sonnet 5 e9f5b13b70 feat: Ping Scores — global highscore board and leaderboards
New feature: every "ping" channel message gets recorded and scored,
producing records (farthest reach, most hops, widest simultaneous
spread, fastest full spread, most airtime-efficient) plus leaderboards
(top relay nodes, top first-hearer observers). Global, not scoped by
region/area, per dborup's request.

Architecture:
- Ingestor writes a lightweight ping_triggers row (tx_id, hash,
  channel_hash, sender, first_seen) at ingest time when a new CHAN
  transmission matches the same isPingTrigger logic the pong-reply
  feature already uses (mirrored, kept in sync by hand -- now a 3rd
  copy alongside public/channels.js and cmd/server/db.go).
- Server periodically (every 2 min) joins ping_triggers with the same
  GetPacketPath + LoRa-airtime-estimate logic behind View Path,
  computing the full snapshot in memory (no write access needed from
  the read-only server) and caching it, matching the steady-state
  recomputer pattern used throughout cmd/server.
- New GET /api/ping-scores endpoint + Ping Scores page (linked from
  Tools and from every pong reply bubble in Channels).

Schema change follows the dbschema.go INVARIANT from #1321 exactly:
migration in Apply() (ingestor-only), assertion in AssertReady()
(server fatal-exits if ping_triggers is missing rather than silently
degrading) -- the same pattern #1865/#1867's configured_scope should
have used but didn't fully wire up (see the ping_scores.go doc
comment and today's earlier startup-race fix for the story).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 15:01:21 +02:00
dborupandClaude Sonnet 5 0f3b7df900 fix: close ingestor/server startup race in schema column detection
Found while live-testing #1867 on stg: the server and ingestor are
separate processes started ~simultaneously by supervisor, sharing one
SQLite file. The server's PRAGMA-based column detection ran exactly
once at OpenDB(), and could fire before the ingestor's additive ALTER
TABLE migrations landed -- reproduced on a fresh stg deploy, where
configured_scope silently never appeared in the API until the
container was manually restarted.

detectSchemaWithRetry now re-scans a few more times on a short fixed
schedule after the first pass. detectSchema's booleans are monotonic
(never reset to false once found), so repeated calls merge safely.
Deliberately does not stop early on two agreeing scans -- a migration
landing between two poll points would make an early scan look stable
right before the real change arrives.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 12:25:42 +02:00
SaarMesh-BotandClaude e7e51fa546 feat: ingest observer /neighbors report as confirmed scope evidence (#1865)
The ESP32 observer firmware now emits a periodic /neighbors report carrying
the observer's own configured region scopes (`self`) plus, for each zero-hop
neighbor, the scopes fetched via an OTA scope query. This records that
CONFIRMED configuration into a new nodes.configured_scope column, kept
strictly separate from the existing inferred `default_scope` (observed advert
transport scope) and transported_scopes (transmissions.scope_name).

Provenance is modelled explicitly rather than overloading default_scope:
default_scope is overwritten on every advert observation, so writing neighbor
scopes there would let the next inferred observation clobber a confirmed
value. A dedicated configured_scope (+ configured_scope_at) column preserves
the distinction and structurally satisfies the report contract.

Report semantics honored:
- Only neighbors with status=="responded" update configured_scope. A timeout
  is NOT evidence the scopes were cleared, so it never writes.
- Absence of a neighbor is never a signal: the report is size-capped and
  truncates by ordering, so missing != gone — no deletes ever happen.
- A responded neighbor with empty scopes is a valid "no scopes configured"
  statement and IS stored (the handler gates on status, not emptiness).
- self scopes are keyed by origin_id (the observer node pubkey) and need no
  OTA query. Report pubkeys are uppercase; nodes.public_key is lowercase hex,
  so keys are lowercased before the UPDATE. Unknown neighbors are a no-op
  until a later advert creates the node.
- Out-of-order reports can't clobber newer data (last-write-wins on
  configured_scope_at).

Changes:
- dbschema: additive ensureConfiguredScopeColumns migration on nodes +
  inactive_nodes (marker nodes_configured_scope_v1), asserted via mustCol.
- ingestor: handleNeighborsReport dispatch on topic
  meshcore/<region>/<observer_id>/neighbors (analogous to /status);
  Store.UpdateNodeConfiguredScope writer.
- server: PRAGMA-detect configured_scope (hasConfiguredScope) and expose it +
  configured_scope_at on the node read path.
- UI: node-detail (nodes.js + live.js) shows a "Configured scope" row marked
  confirmed, with last-confirmed timestamp, distinct from the observed scope.
- tests: handleNeighborsReport (responded writes, timeout/absence never
  clears, empty-responded stored, unknown no-op) + last-write-wins.

Topic format meshcore/<region>/<observer_id>/neighbors is assumed by analogy
to the /status topic; noted for reviewer confirmation against the firmware.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-26 12:09:54 +02:00
dborupandClaude Sonnet 5 e81f89da1e fix: View Path airtime estimate omits fields on 0-relay (direct) packets
Caught on real stg data: a directly-received packet has relays=0, and
while the plain int AirtimeRelayCount's omitempty correctly dropped it
from JSON, the *float64 EstimatedAirtimeMs still encoded as a bare
"estimatedAirtimeMs":0 (a non-nil pointer isn't "empty" to omitempty
even when it points at zero). The frontend's typeof-number check then
rendered "~0ms estimated airtime (undefined relays)". Now both fields
are omitted together whenever there's nothing to relay.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 11:10:54 +02:00
dborupandClaude Sonnet 5 e9e828c7e9 feat: View Path shows estimated RF airtime for the whole flood
Reuses the Relay Airtime Share formula (LoRa Time-on-Air x distinct
relay count, issue #1768) against the single transmission a View Path
packet resolves to, sourced from the in-memory PacketStore via the
transmission ID GetPacketPath now captures. Rendered in the footer as
"~340ms estimated airtime (3 relays)", omitted cleanly when the store
doesn't have the transmission.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 10:59:09 +02:00
dborup ed02d8409b feat: show which regions are used under Region Utilization
The "Region Utilization (39 of 1,098 used)" stat already listed the
*unused* regions in a collapsed details block, but not which specific
ones counted toward the "used" side -- dborup asked to see them too.

Added UsedRegions (ScopeStatsResponse) alongside the existing
UnusedRegions, computed in the same matched-regions pass so it's a
free byproduct, not a second query. Frontend adds a matching "Show N
used regions" details block next to the existing unused one.
2026-07-26 09:10:16 +02:00
dborup fe68991102 feat: View Path legend, declutter toggle, and shaded touched-area boundaries
Three follow-up improvements to the View Path map, all requested
together:

- A compact color/symbol legend (route color, approx marker, first-
  heard ring) replaces the old prose paragraph explaining the same
  things -- shorter intro text, faster to scan.
- A "show only farthest-traveled route" checkbox appears whenever a
  packet was heard by more than one station, to declutter busy
  20+-branch packets. Only added/removed as a Leaflet layer toggle;
  the primary branch's own markers/polyline are never touched.
- Every touched area is now shaded directly on the map (a real
  polygon when the area was drawn as one, a rectangle fallback for
  bbox-only areas) instead of only listed as text -- ties the
  "touched: X, Y" footer line to actual geography. Non-interactive so
  shapes never steal a click meant for a branch marker.

The shading needed PacketPathResponse.TouchedAreas to carry geometry,
not just labels -- changed from []string to []TouchedAreaShape
(label + polygon or bbox). annotatePacketPathTouchedAreas now looks
up the matched area's full config entry via AreaKeyForPoint instead
of just its label via AreaForPoint.
2026-07-26 08:44:20 +02:00
dborup 4652162149 feat: View Path shows every area the packet touched, uncapped
The ping-bot reply's "touched" list caps at 3 to keep a chat bubble
readable, but View Path's map has room to show the full set: every
configured area any point or observer on the path resolved to,
deduped and alphabetized.

No extra DB round-trip needed here (unlike the pong reply's version)
-- GetPacketPath already resolved every position, including the
neighbor-centroid approximation fallback, so annotatePacketPathTouchedAreas
just reads the lat/lon already on the response.
2026-07-26 08:05:05 +02:00
dborup 6d6062875a feat: pong reply lists the named areas the packet touched
Adds a "touched Area A, Area B, +N more" part to the ping-bot's
reply: the distinct configured areas any hearing station (with its
own GPS fix) was in, deduped and alphabetized, capped at 3 shown to
keep a broadly-flooded packet's reply from growing unboundedly. Sits
alongside the existing "spread up to Nkm" as a named-place view of
the same "how wide did this go" question.

Area resolution needs config.Areas, not available at the SQL-only DB
layer where the reply text is otherwise built -- GetChannelMessages
exposes the raw observer pubkey set via botReply.touchedObserverPubkeys
(never reaching the client), and a new Server-level
annotateBotReplyTouchedAreas resolves and appends the area list, same
layering annotateMessageAreas already uses for the per-message "area"
field.
2026-07-26 07:46:17 +02:00
dborup c82d9afdae feat: resolve channel message Area from the hearing station's own GPS when heard direct
A 0-hop (direct) message has no relay path at all, so the existing
entry-point-repeater area resolution (path[0]) has nothing to work
with -- even though the hearing station's own position is a
reasonable stand-in for "where this happened" at 0 hops. Falls back
to the station's own GPS fix in that case only; a multi-hop message
whose path just failed to resolve still gets no guess.

Bypasses the path-hop prefix machinery (buildPrefixMap/
resolveEntryPointArea) on purpose: that only indexes repeater/room-
server roles as path-hop candidates, but a listening station's own
position shouldn't depend on whether it could ever appear as a relay
hop in someone else's path. New gpsByPubkeysExact does a plain exact
public_key match instead.

Applied to both the REST channel message list (annotateMessageAreas)
and the live WebSocket broadcast path, which already mirrored each
other for the path[0] case and would otherwise drift.
2026-07-24 21:17:23 +02:00
dborup fecb53dd00 feat: pong reply reports how far the packet spread geographically
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.
2026-07-24 20:06:40 +02:00
dborupandClaude Sonnet 5 4b4dc9f27f revert: remove View Path bridge-repeater highlighting
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>
2026-07-24 19:27:56 +02:00
dborupandClaude Sonnet 5 3c8ec35ea2 feat: View Path shows distance from 'first' in km
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>
2026-07-24 18:23:05 +02:00
dborupandClaude Sonnet 5 575743efda feat: View Path highlights confirmed bridge repeaters
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>
2026-07-24 18:16:15 +02:00
dborupandClaude Sonnet 5 d5214006d6 feat: View Path shows a role icon for observers with a known node role
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>
2026-07-24 18:03:25 +02:00
dborupandClaude Sonnet 5 4ecb9c3902 feat: scale View Path approximate markers by neighbor confidence
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>
2026-07-24 17:44:19 +02:00
dborupandClaude Sonnet 5 8e61a78d32 feat: View Path shows how long after 'first' each station heard the packet
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>
2026-07-24 17:38:15 +02:00
dborupandClaude Sonnet 5 fb69681de7 feat: View Path approximates unpositioned nodes from a weighted centroid of neighbors, not just one
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>
2026-07-24 17:19:47 +02:00
dborupandClaude Sonnet 5 5e6e6252b4 feat: View Path falls back to the nearest positioned neighbor for unpositioned hops/observers
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>
2026-07-24 17:02:38 +02:00