Commit Graph
3015 Commits
Author SHA1 Message Date
dborupandClaude Sonnet 5 b096f73662 fix: Areas tab Position-Fix Coverage Gaps table was overwhelming -- sort worst-first, collapse to top 10
dborup: the full 36-row dump buried the handful of areas with an actual
gap behind dozens already fully GPS-mapped (0% estimated). Sort by %
estimated descending and collapse to top 10 with a "Show all" toggle,
matching the existing pattern from the Wardriving tab's Top Senders /
Coverage by Observer sections.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 04:48:39 +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 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 55ccac451f fix: SNR history modal chart lines both rendered black
borderColor was set to raw 'var(--accent)'/'var(--status-yellow)'
strings, which Canvas 2D (unlike SVG/DOM) can't resolve as a strokeStyle
-- Chart.js silently fell back to its default black for both lines,
making the SNR and Heard(s ago) series indistinguishable (spotted live
on stg). Switched to this page's existing CHART_COLORS literal-hex
palette, matching the pattern the other 4 charts on this page already
use. The small inline SVG sparkline was unaffected -- SVG is real DOM
and does resolve CSS custom properties, just Canvas doesn't.
2026-07-27 08:06:29 +02:00
dborup 5063919daf feat: richer SNR history view -- axis labels, click-to-expand modal
dborup: "det ville være cool wih more history... jeg vil bare gerne se
mere" -- not more retention, more detail in the existing view.

- neighborSnrSparkline now labels min/max dB directly on the sparkline,
  readable without hovering.
- Clicking a sparkline (any row with data) opens a modal with a bigger
  Chart.js line chart showing both SNR and heard_secs_ago on dual y-axes,
  with native hover tooltips. Reuses the existing generic .modal-overlay/
  .modal CSS (previously only exercised by the channel-add modal).
- Metrics are cached per-pubkey when the sparkline loads, so opening the
  modal needs no second fetch.
- Click delegation is wired once at module load (not per-render), since
  renderDetail rewrites #obsDetailContent's innerHTML on every load.
2026-07-26 19:42:14 +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 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 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 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 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 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 47b28e738c fix: sync_areas.py migrates existing areas with the stray regionScope key too
The previous fix only stopped the script from writing the wrong field
going forward. SE12 -- the one area actually created by today's buggy
run -- falls through both remaining code paths: it's not in CROSSWALK
(so the enrich step never touches it) and it already exists (so the
"add new area" step skips it as already present). Without an explicit
migration pass it would stay silently unlinked forever.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 13:07:52 +02:00
dborupandClaude Sonnet 5 fbb0d8ed23 fix: sync_areas.py writes regionScope (unused), not regionScopes (what the app reads)
Caught live on stg: after running the script for real, the one genuinely
new area it added (SE12) never showed up as scoped via /api/config/areas,
while all 14 "enriched" existing areas looked fine. Turned out those 14
already had a correct regionScopes array from earlier manual config work --
the script's regionScope (singular) write was dead data riding along
next to it. SE12 had no prior regionScopes, so the script's only write
landed on a field cmd/server/routes.go's handleConfigAreas never reads,
leaving it silently unlinked.

Now appends to regionScopes (preserving any other scopes already on the
area) and drops a stray regionScope key if a prior buggy run left one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 13:07:00 +02:00
dborupandClaude Sonnet 5 67d48a2c4b fix: restore ops/meshguide-sync/sync_areas.py, silently dropped by a routine master sync
Routine "sync branch with master before deploying" (git merge origin/master
into areas-meshguide-sync) fast-forwarded cleanly and, in doing so, silently
carried master's exclusion of this deployment-specific script back into
areas-meshguide-sync too -- fast-forward doesn't distinguish "master never
had this" from "master deliberately deletes this", so the branch meant to be
this script's home lost it entirely instead of just staying ahead of master.

Restored verbatim from the last commit that had it (e81f89d), including the
matching AREAS.md section describing it. Master must never carry this file;
areas-meshguide-sync must never lose it to a master sync again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 12:45:34 +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
dborup 56f6e24c60 Merge branch 'areas-meshguide-sync' 2026-07-26 11:16:45 +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 b24a12f515 Merge branch 'areas-meshguide-sync' 2026-07-26 10:38:49 +02:00
dborup d25d0253d9 feat: View Path status line shows elapsed time and total spread duration
Three additions, all using secondsAfterFirst/distanceFromFirstKm data
the response already carried -- no backend changes needed:

- "deepest reached N hops (Xs)" -- the deepest branch's own elapsed
  time, appended to the existing stat.
- "farthest reached Xkm (Ys)" -- new stat: the actual distance the
  farthest branch reached, plus its elapsed time. Previously only
  implied via the "touched" area list or a marker's hover tooltip,
  never stated as its own number.
- "fully spread in Zs" -- the largest secondsAfterFirst across ALL
  branches, not just farthest/deepest (a middling branch stuck behind
  a slow relay can still be the last one reached).

All three degrade cleanly (omitted, not "NaNs") when a branch's
timing is unknown.
2026-07-26 10:30:21 +02:00
dborup e851a43564 feat: View Path highlights both farthest and deepest routes when they differ
Following up on the previous fix (highlight by real distance, not hop
count): dborup asked why not show both when they diverge, rather than
picking one and letting the other blend into the secondary stations.

Now tracks two independent roles per branch -- farthest (by
distanceFromFirstKm) and deepest (by hop count) -- and highlights each
with its own color (accent for farthest, purple for deepest) plus its
own legend entry, when they're genuinely different branches. When
they're the same branch (the common case) it's still shown once,
combined, exactly as before. When no branch has distance data at all,
falls back to the single "deepest (most hops)" highlight, also
unchanged.

Also fixes a gap this surfaced: an observer-only branch (no relay
hops, e.g. a 0-hop direct reception) has no polyline and always FILLS
yellow regardless of role, so farthest vs. deepest were previously
indistinguishable on such a marker. The ring (stroke) color now
carries the role instead, so a highlighted observer reads as an
accent- or purple-ringed yellow dot rather than a plain one.
2026-07-26 09:56:40 +02:00
dborup 4cd8282650 fix: View Path highlights the actually-farthest branch, not just the deepest
The highlighted "primary" route was always branches[0] (most hops),
labeled "farthest-traveled route" -- but more hops doesn't mean more
geographic distance. A dense area can take many short hops; a couple
of long-range links can cover more real distance in fewer. dborup
caught the mislabeling; we now pick the branch with the largest
distanceFromFirstKm when any branch has that data (ties break toward
more hops, since branches[] stays deepest-first).

Falls back to the old hops-based pick only when NO branch has usable
distance data (sparse GPS coverage) -- and the legend/checkbox
wording switches to an honest "deepest (most hops)" in that case
instead of still claiming "farthest-traveled" for a branch nobody
actually measured.

The "deepest reached N hops" footer stat is unrelated and unchanged
-- it was already correctly hop-based.
2026-07-26 09:41:01 +02:00
dborup 4090d697a1 Merge branch 'areas-meshguide-sync' 2026-07-26 09:34:23 +02:00
dborup 04cb9c955f feat: node detail page shows its own configured scope, not just transported
The Transported Scopes row shows regions a repeater has relayed for
OTHERS, but not what the node's own hashRegions scope actually is --
dborup asked for that distinction to be visible too. Added an "Own
scope" row right above it, for any node (not just repeater/room --
any node can configure its own scope). Mirrors live.js's existing
null (no schema support) / empty ("unknown scope") / value
distinction for default_scope.
2026-07-26 09:28:50 +02:00
dborup 1aaa044666 fix: Scope Adoption by Area section had no description, unlike its siblings
Region Utilization and the other Scope Statistics sections all have a
one-line description under the summary explaining what the numbers
mean; this one passed null. Added one matching the section's own
doc comment: of the real nodes in each configured area, how many
actually support its linked region.
2026-07-26 09:21:47 +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 a99b8f7b99 Merge branch 'areas-meshguide-sync' 2026-07-26 09:05:03 +02:00
dborup 51b76979ca feat: View Path adds show/hide toggles for area boundaries and approximate positions
Two more filter checkboxes alongside the existing declutter toggle:

- "Show area boundaries" -- starts checked (shapes visible by
  default, matching current behavior); unchecking removes them.
- "Show only approximate positions" -- appears only when at least
  one marker is approximate; filters down to just the
  neighbor-estimated stations, useful for sanity-checking how many
  (and which) positions on a packet are actually estimates rather
  than real fixes.

Both only appear when there's something to toggle (no area boundaries
= no checkbox, no approx markers = no checkbox), matching the
existing declutter toggle's convention.

Refactored the layer-visibility bookkeeping to support this: the
declutter and approx-only filters are independent and combinable, so
naively having each checkbox add/remove only its own layers breaks as
soon as both are active and one gets unchecked -- a layer that's
still excluded by the OTHER filter would incorrectly reappear.
Replaced with markerEntries/polylineEntries (tagged primary/approx)
and a single applyMarkerFilters() that recomputes every layer's
visibility from both checkboxes' current state together. Area
boundary visibility stays a separate, independent toggle since it's
an unrelated layer type.
2026-07-26 08:59:19 +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 3f903586b1 Merge branch 'areas-meshguide-sync' 2026-07-26 08:24:56 +02:00
dborup d1743c6aaa fix: View Path "N approximate" counted chain appearances, not distinct nodes
A shared entry-point repeater near the sender commonly appears in
many branches' chains (one relay a dozen stations all heard the
packet through). The status count summed every chain appearance
instead of deduping by node identity, so a single approximate-
position node showing up in 11 branches reported "11 approximate"
-- dborup spotted this on a real packet and asked whether it could
be right; it wasn't, it was the same node counted 11 times.

Dedupes by publicKey (falling back to name when a point has none).
2026-07-26 08:20:13 +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 07a2e1f2cb Merge branch 'areas-meshguide-sync' 2026-07-24 20:14:38 +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
dborup fa99231cdb Merge branch 'areas-meshguide-sync' 2026-07-24 19:50:34 +02:00
dborup ae16d23263 fix: View Path tooltip renders as a tall single-column rectangle
The tooltip's containing pane has no intrinsic width, so a plain
width:auto block collapsed to min-content (one word per line) and
never grew toward max-width at all. width:max-content makes it size
to its content up to max-width, producing a normal wrapped box.

Verified live on stg by patching the CSS in-page and re-triggering a
tooltip before shipping.
2026-07-24 19:44:58 +02:00
dborup d1888618ce fix: View Path tooltip still wrapped too narrow at larger font sizes
max-width was a fixed 220px; at bigger font/zoom settings that left
room for barely one word per line. Use 28ch instead so the box scales
with the actual font size.
2026-07-24 19:36:07 +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 6718c246b9 fix: View Path tooltips wrap instead of stretching across the whole map
Tooltip text grew long once role, approx/confidence, bridge, and
distance/timing info were all combined into one line. Leaflet's
default tooltip is white-space:nowrap, so a long one stretched into a
single unreadable line spanning the map instead of wrapping.

Adds a packet-path-tooltip CSS class (white-space:normal, max-width)
passed to every bindTooltip call in this component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 19:19:57 +02:00
dborupandClaude Sonnet 5 d0205b0edd feat: View Path markers are clickable, navigate to node detail
Hop points already carried publicKey; PacketPathObserver got it in the
bridge-highlighting commit. Clicking any marker with one now closes
the modal and navigates to #/nodes/{pubkey} -- the same hash route the
rest of the app already links to (see e.g. public/channels.js). A
marker with no publicKey (e.g. a bridge-type observer keyed by device
name, not pubkey) gets no click handler and stays inert. Tooltip gets
a "click for node detail" hint when applicable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 18:34:50 +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