Commit Graph

407 Commits

Author SHA1 Message Date
you b19b57cce8 fix: use current time for channel lastActivity on WS updates
packet.timestamp is first_seen — when the transmission was originally
observed. When multiple observers re-see the same old packet, the
broadcast carries the original (stale) first_seen. For channel list
display, what matters is 'activity happened now', not 'packet was
first seen 10h ago'.
2026-03-21 22:12:56 +00:00
you cb8e20ae7e fix: deduplicate observations with NULL path_json
The UNIQUE index on (hash, observer_id, path_json) didn't prevent
duplicates when path_json was NULL because SQLite treats NULL != NULL
for uniqueness. Fixed by:

1. Using COALESCE(path_json, '') in the UNIQUE index expression
2. Adding migration to clean up existing duplicate rows
3. Adding NULL-safe dedup checks in PacketStore load and insert paths
2026-03-21 22:06:43 +00:00
you 0dcf973e43 debug: log channel WS timestamp values 2026-03-21 22:05:52 +00:00
you 8a18e091e0 fix: use packet hash instead of sender_timestamp for channel message dedup
Device clocks on MeshCore nodes are wildly inaccurate (off by hours or
epoch-near values like 4). The channel messages endpoint was using
sender_timestamp as part of the deduplication key, which could cause
messages to fail deduplication or incorrectly collide.

Changed dedupe key from sender:timestamp to sender:hash, which is the
correct unique identifier for a transmission.

Also added TIMESTAMP-AUDIT.md documenting all device timestamp usage.
2026-03-21 21:53:38 +00:00
you 36c069b3f6 fix: use server timestamp for channel lastActivity, not device clock
WS messages from lincomatic bridge lack packet.timestamp, so the
code fell through to payload.sender_timestamp which reflects the
MeshCore device's clock (often wrong). Use current time as fallback.
2026-03-21 21:50:18 +00:00
you 4a04fbb750 fix: tick channel list relative timestamps every 30s
timeAgo labels were computed once on render and never updated,
showing stale '11h ago' until next WS message triggered re-render.
Added 30s interval to re-render channel list, cleaned up on destroy.
2026-03-21 21:41:30 +00:00
you 9b4a68051f fix: dedup live channel messages by packet hash, not sender+timestamp
Multiple observers seeing the same packet triggered separate message
entries. Now deduplicates by packet hash — additional observations
increment repeats count and add to observers list. Channel messageCount
also only bumps once per unique packet.
2026-03-21 21:38:32 +00:00
you cfcb441aa1 feat: zero-API live updates on channels page via WS
Instead of re-fetching /api/channels and /api/channels/:hash/messages
on every WebSocket event, the channels page now processes WS messages
client-side:

- Extract sender, text, channel, timestamp from WS payload
- Append new messages directly to local messages[] array
- Update channel list entries (lastActivity, lastSender, messageCount)
- Create new channel entries for previously unseen channels
- Deduplicate repeated observations of the same message

API calls now only happen on:
- Initial page load (loadChannels)
- Channel selection (selectChannel)
- Region filter change

This eliminates all polling and WS-triggered re-fetches.
2026-03-21 21:35:30 +00:00
you e58879830f fix: stop channels page from spamming API requests
The channels WS handler was calling invalidateApiCache() before
loadChannels()/refreshMessages(), which nuked the cache and forced
network fetches. Combined with the global WS onmessage handler also
invalidating /channels every 5s, this created excessive API traffic
when sitting idle on the channels page.

Changes:
- channels.js: Remove invalidateApiCache calls from WS handler, use
  bust:true parameter instead to bypass cache only when WS triggers
- channels.js: Add bust parameter to loadChannels() and refreshMessages()
- app.js: Remove /channels from global WS cache invalidation (channels
  page manages its own cache busting via its dedicated WS handler)
2026-03-21 21:34:05 +00:00
you 06f1d286d6 fix: remove ADVERT timestamp validation — field isn't stored or used
Timestamp is decoded from the ADVERT but never persisted to the nodes
table. The validation was rejecting valid nodes with slightly-off clocks
(28h future) and nodes broadcasting timestamp=4. No reason to gate on it.
2026-03-21 21:31:25 +00:00
you 54d0259708 perf: reduce 3 API calls to 1 when expanding grouped packet row
Expanding a grouped row fired: packets?hash=X&expand=observations,
packets?hash=X&limit=1, and packets/HASH — all returning the same
data. Now uses single /packets/HASH call and passes prefetched data
to selectPacket() to skip redundant fetches.
2026-03-21 21:17:24 +00:00
you 7315ce08d5 fix: invalidate channel cache before re-fetching on WS update
The WS handler's 250ms debounce fired loadChannels() before the
global 5s cache invalidation timer cleared the stale entry, so
the fetch returned cached data. Now channels.js invalidates its
own cache entries immediately before re-fetching.
2026-03-21 21:09:48 +00:00
you b81e2b7e56 fix: build insert row from packetData instead of DB round-trip
packets_v view uses observation IDs, not transmission IDs, so
getPacket(transmissionId) returned null. Skip the DB lookup entirely
and construct the row directly from the incoming packetData object
which already has all needed fields.
2026-03-21 21:02:12 +00:00
you 24fa68895a fix: use transmissionId for packet lookup after insert
packets_v view uses transmission IDs, not packets table IDs.
insertPacket returns a packets table ID which doesn't exist in
packets_v, so getPacket returned null and new packets never got
indexed in memory. Use transmissionId from insertTransmission instead.
2026-03-21 20:54:59 +00:00
you ec4ebf2ede fix: re-export insertPacket from db.js (packet-store.js needs it)
Migration subagent removed it from exports but packet-store.js
calls dbModule.insertPacket() on every MQTT ingestion.
2026-03-21 20:46:15 +00:00
you cbec6b3108 Move hop resolution to client side
Create public/hop-resolver.js that mirrors the server's disambiguateHops()
algorithm (prefix index, forward/backward pass, distance sanity check).

Replace all /api/resolve-hops fetch calls in packets.js with local
HopResolver.resolve() calls. The resolver lazily fetches and caches the
full nodes list via /api/nodes on first use.

The server endpoint is kept as fallback but no longer called by the UI,
eliminating 40+ HTTP requests per session.
2026-03-21 20:39:28 +00:00
you 3a1b0b544b refactor: migrate all SQL queries from packets table to packets_v view (transmissions+observations)
- Create packets_v SQL view joining transmissions+observations to match old packets schema
- Replace all SELECT FROM packets with packets_v in db.js, packet-store.js, server.js
- Update countPackets/countRecentPackets to query observations directly
- Update seed() to use insertTransmission instead of insertPacket
- Remove insertPacket from exports (no longer called)
- Keep packets table schema intact (not dropped yet, pending testing)
2026-03-21 20:38:58 +00:00
you 016c4091db perf: disable SQLite auto-checkpoint, use manual PASSIVE checkpoint
SQLite WAL auto-checkpoint (every 1000 pages/4MB) was causing 200ms+
event loop spikes on a 381MB database. This is synchronous I/O that
blocks the Node.js event loop unpredictably.

Fix: disable auto-checkpoint, run PASSIVE (non-blocking) checkpoint
every 5 minutes. PASSIVE won't stall readers or writers — it only
checkpoints pages that aren't currently in use.
2026-03-21 20:19:22 +00:00
you 0ee8992e09 perf: eliminate synchronous blocking during startup pre-warm
Previous pre-warm called computeAllSubpaths() synchronously (500ms+)
directly in the setTimeout callback, then sequentially warmed 8 more
endpoints. Any browser request arriving during this 1.5s+ window
waited the full duration (user saw 4816ms for a 3-hop resolve-hops).

Fix: ALL pre-warm now goes through self-HTTP-requests which yield the
event loop between each computation. Delayed to 5s so initial page
load requests complete first (they populate cache on-demand).

Removed the sync computeAllSubpaths() call and inline subpath cache
population — the /api/analytics/subpaths endpoint handles this itself.
2026-03-21 20:07:27 +00:00
you a3d94e74c6 perf: node paths endpoint uses disambiguateHops with prefix index
Replaced inline resolveHopsInternal (allNodes.filter per hop) with
shared disambiguateHops (prefix-indexed). Also uses _parsedPath cache
and per-request disambig cache. /api/nodes/:pubkey/paths was 560ms cold,
should now be much faster.
2026-03-21 19:59:11 +00:00
you a16bd34a1f perf: revert 200ms gap (made it worse), warm at 100ms instead of 1s
200ms gaps meant clients hit cold caches themselves (worse).
Pre-warm should be fast and immediate — start at 100ms after listen,
setImmediate between endpoints to yield but not delay.
2026-03-21 19:54:10 +00:00
you e95da27555 perf: 200ms gap between pre-warm requests to drain client queue
setImmediate wasn't enough — each analytics computation blocks for
200-400ms synchronously. Adding a 200ms setTimeout between pre-warm
requests gives pending client requests a window to complete between
the heavy computations.
2026-03-21 19:53:02 +00:00
you 88e4287b3e perf: shared cached node list + cached path JSON parse
- 8 separate 'SELECT * FROM nodes' queries replaced with getCachedNodes()
  (refreshes every 30s, prefix index built once and reused)
- Region-filtered subpaths + master subpaths use _parsedPath cache
- Eliminates repeated SQLite queries + prefix index rebuilds across
  back-to-back analytics endpoint calls
2026-03-21 19:49:54 +00:00
you 961be4fd80 perf: yield event loop between pre-warm requests via setImmediate 2026-03-21 19:38:59 +00:00
you 95185a381d perf: add observers, nodes, distance to startup pre-warm
These endpoints were missing from the sequential pre-warm,
causing cold cache hits when clients connect before warm completes.
observers was 3s cold, distance was 600ms cold.
2026-03-21 19:36:46 +00:00
you 74fd97f761 Optimize analytics endpoints: prefix maps, cached JSON.parse, reduced scans
- Topology: replace O(N) allNodes.filter with prefix map + hop cache for resolveHop
- Topology: use _parsedPath cached JSON.parse for path_json (3 call sites)
- Topology: build observer map from already-filtered packets instead of second full scan
- Hash-sizes: prefix map for hop resolution instead of allNodes.find per hop
- Hash-sizes: use _parsedPath and _parsedDecoded cached parses
- Channels: use _parsedDecoded cached parse for decoded_json
2026-03-21 19:35:06 +00:00
you 9867b62872 Optimize /api/analytics/distance cold cache performance
- Build prefix map for O(1) hop resolution instead of O(N) linear scan per hop
- Cache resolved hops to avoid re-resolving same hex prefix across packets
- Pre-compute repeater set for O(1) role lookups
- Cache parsed path_json/decoded_json on packet objects (_parsedPath/_parsedDecoded)
2026-03-21 19:22:37 +00:00
you 0fb3553762 perf: precompute hash_size map at startup, update incrementally
The hash_size computation was scanning all 19K+ packets with JSON.parse
on every /api/nodes request, blocking the event loop for hundreds of ms.
Event loop p95 was 236ms, max 1732ms.

Now computed once at startup and updated incrementally on each new packet.
/api/nodes just does a Map.get per node instead of full scan.
2026-03-21 19:00:28 +00:00
you 8811efdb24 fix: hash_size must use newest ADVERT, not oldest
Packets array is sorted newest-first. The previous 'last-wins'
approach (unconditional set) gave the OLDEST packet's hash_size.
Switched to first-wins (!has guard) which correctly uses the
newest ADVERT since we iterate newest-first.

Verified: Kpa Roof Solar has 1-byte ADVERTs (old firmware) and
2-byte ADVERTs (new firmware) interleaved. Newest are 2-byte.
2026-03-21 18:52:06 +00:00
you e50ce03414 fix: Pass 2 hash_size was overwriting ADVERT-derived values
Pass 1 correctly uses last-wins for ADVERT packets. But Pass 2
(fallback for nodes without ADVERTs) was also unconditionally
overwriting, so a stale 1-byte non-ADVERT packet would clobber
the correct 2-byte value from Pass 1.

Restored the !hashSizeMap.has() guard on Pass 2 only — it should
only fill gaps, never override ADVERT-derived hash_size.
2026-03-21 18:50:50 +00:00
you ee58e648a6 Fix hash_size using first-seen-wins instead of last-seen-wins
The hashSizeMap was guarded by !hashSizeMap.has(pk), meaning the oldest
ADVERT determined a node's hash_size permanently. If a node upgraded
firmware from 1-byte to 2-byte hash prefix, the stale value persisted.

Remove the guard so newer packets overwrite older ones (last-seen-wins).
2026-03-21 18:43:48 +00:00
you 2170dd7743 security: require API key for POST /api/packets and /api/perf/reset
- New config.apiKey field — when set, POST endpoints require X-Api-Key header
- If apiKey not configured, endpoints remain open (dev/local mode)
- GET endpoints and /api/decode (read-only) remain public
- Closes the packet injection attack surface
2026-03-21 18:40:06 +00:00
you 804c39504c feat: View on Map buttons for distance leaderboard hops and paths
- 🗺️ button on each top hop row → opens map with from/to markers + line
- 🗺️ button on each top path row → opens map with full multi-hop route
- Server now includes fromPk/toPk in topPaths hops for map resolution
- Uses existing drawPacketRoute() via sessionStorage handoff
2026-03-21 17:56:44 +00:00
you 27914fbd62 fix: cap max hop distance at 300km, link to node detail not analytics
- 1000km filter was too generous for LoRa (record ~250km)
- Uhuru kwa watu 📡 ↔ Bay Area hops at 880km were obviously wrong
- Node links in leaderboard now go to #/nodes/:pk (detail) not /analytics
2026-03-21 17:45:51 +00:00
you 110287b9f1 fix: remove dead histogram(null) call crashing distance tab
The subagent left a stray histogram(null, 0, ...) call that fell through
to the legacy path which does Math.min(...null) → Symbol.iterator error.
2026-03-21 17:41:24 +00:00
you 5720d0d948 Add Distance/Range analytics tab
New /api/analytics/distance endpoint that:
- Resolves path hops to nodes with valid GPS coordinates
- Calculates haversine distances between consecutive hops
- Separates stats by link type: R↔R, C↔R, C↔C
- Returns top longest hops, longest paths, category stats, histogram, time series
- Filters out invalid GPS (null, 0/0) and sanity-checks >1000km
- Supports region filtering and caching

New Distance tab in analytics UI with:
- Summary cards (total hops, paths, avg/max distance)
- Link type breakdown table
- Distance histogram
- Average distance over time sparkline
- Top 20 longest hops leaderboard
- Top 10 longest multi-hop paths table
2026-03-21 17:33:33 +00:00
you ca4aa72574 fix: region filter nodes by ADVERT observers, not data packets
The previous approach matched nodes via data packet hashes seen by
regional observers — but mesh packets propagate everywhere, so nearly
every node matched every region (550/558).

New approach: _advertByObserver index tracks which observers saw each
node's ADVERT packets. ADVERTs are local broadcasts that indicate
physical presence, so they're the correct signal for geographic filtering.

Also fixes role counts to reflect filtered results, not global totals.
2026-03-21 08:31:55 +00:00
you 63a525ecc1 fix: stack overflow in /analytics/rf — replace Math.min/max spread
Math.min(...arr) and Math.max(...arr) blow the call stack when arr
has tens of thousands of elements. Replaced with simple for-loop
arrMin/arrMax helpers.
2026-03-21 08:26:34 +00:00
you d2dbb1d8e6 fix: region dropdown matches btn-icon style, says 'All Regions'
- border-radius 6px (rectangle) instead of 16px (pill)
- Same padding/font-size as btn-icon
- Removed redundant 'Region:' label from dropdown mode
- Default label: 'All Regions' instead of 'All'
2026-03-21 08:24:36 +00:00
you 5f06d967d3 fix: btn-icon contrast — add color: var(--text)
BYOP button text was nearly invisible on dark background due to
missing explicit color on .btn-icon class.
2026-03-21 08:23:15 +00:00
you ace705ef8e fix: use dropdown for region filter on packets page
Pills look out of place in the dense toolbar. Added { dropdown: true }
option to RegionFilter.init() to force dropdown mode regardless of
region count.
2026-03-21 08:22:12 +00:00
you 9345dbf50f fix: bump nodes.js cache buster so region filter loads
RegionFilter was added to nodes page but cache buster wasn't bumped,
so browsers served the old cached nodes.js without the filter.
2026-03-21 08:09:33 +00:00
you debc873b26 fix: BYOP button accessibility compliance
- Add aria-label and aria-haspopup='dialog' to BYOP trigger button
- Add aria-label to close button and textarea
- Add role='status' and aria-live='polite' to result container
- Add role='alert' to error messages for screen reader announcement
- Fix textarea focus style: visible outline instead of outline:none
- Update cache busters
2026-03-21 08:06:37 +00:00
you 4ffeb8204e fix: analytics RF stats respect region filter
- Separate region filtering from SNR filtering in /api/analytics/rf
- totalAllPackets now shows regional observation count (was global)
- Add totalTransmissions (unique hashes in regional set)
- Payload types and packet sizes use all regional data, not just SNR-filtered
- Signal stats (SNR, RSSI, scatter) use SNR-filtered subset
- Handle empty SNR/RSSI arrays gracefully (no Infinity/-Infinity)
2026-03-21 08:01:30 +00:00
you 39f5b40322 packets: replace single-select region dropdown with shared RegionFilter component
- Fixes empty region dropdown (was populated before async regionMap loaded)
- Now uses multi-select RegionFilter component consistent with other pages
- Loads regions from /api/observers with proper async handling
- Supports multi-region filtering
2026-03-21 08:00:44 +00:00
you d2480f15c2 fix: null safety for analytics stats when region has no signal data
rf.snr.min/max/avg etc can be null when a region filter excludes all
packets with signal data. Added sf() helper for safe toFixed.
2026-03-21 07:43:14 +00:00
you 9cbe275828 fix: network-status missing ? in region query string
/nodes/network-status + &region=X was producing an invalid URL.
Now correctly uses ? as separator when it's the first param.
2026-03-21 07:42:07 +00:00
you c2acf40951 fix: revert broken SQL region filtering for nodes — use in-memory index
The subagent used a non-existent column (sender_key) in the SQL join.
Reverted to the same byObserver + _nodeHashIndex approach used by
bulk-health and network-status endpoints.
2026-03-21 07:15:22 +00:00
you 80b6e1cac1 fix: use SQL for region filtering on nodes page
The previous approach used pktStore._nodeHashIndex which only tracks
nodes appearing as sender/dest in decoded packet JSON. Most nodes only
send ADVERTs, so they had no entries in _nodeHashIndex and were filtered
out when a region was selected (showing 0 results).

Now uses a direct SQL join between observations and transmissions to find
all sender_keys observed by regional observers, which correctly includes
ADVERT-only nodes.
2026-03-21 07:10:56 +00:00
you 0ac7687313 Fix region filtering in Route Patterns, Nodes, and Network Status tabs
- Add RegionFilter.regionQueryString() to all API calls in renderSubpaths and renderNodesTab
- Add region filtering to /api/analytics/subpaths (filter packets by regional observer hashes)
- Add region filtering to /api/nodes/bulk-health (filter nodes by regional presence)
- Add region filtering to /api/nodes/network-status (filter node counts by region)
- Add region param to nodes lookup in hash collision tab
- Update cache keys to include region param for proper cache separation
2026-03-21 07:10:38 +00:00