Compare commits

...

69 Commits

Author SHA1 Message Date
you ef7dec8aa5 feat: add channel discovery tool for hashtag brute-forcing
Adds cmd/channel-discover, a CLI tool that discovers MeshCore hashtag
channel names by brute-forcing SHA256-derived keys against captured
GRP_TXT packets.

How it works:
1. Extracts undecrypted GRP_TXT packets from CoreScope SQLite DB
2. Generates candidate #channel names from a 480+ word default wordlist
3. Filters by 1-byte channelHash match (1/256 false positive rate)
4. Verifies via full HMAC + AES-128-ECB decryption
5. Reports discovered channels with sample decrypted messages

CLI: channel-discover -db <path> [-wordlist file] [-name '#guess'] [-verbose] [-json]

Reuses the same crypto from cmd/ingestor/decoder.go (HMAC-SHA256 MAC +
AES-128-ECB). Includes tests for key derivation, hash computation, and
decryption validation.
2026-04-05 01:17:38 +00:00
you 0a55717283 docs: add PSK brute-force attack with timestamp oracle to security analysis
Weak passphrases with no KDF stretching are the #1 practical threat.
Timestamp in plaintext block 0 serves as known-plaintext oracle for
instant key verification from a single captured packet.

Key findings:
- decode_base64() output used directly as AES key, no KDF
- Short passphrases produce <16 byte keys (reduced key space)
- No salt means global precomputed attacks work
- 3-word passphrase crackable in ~2 min on commodity GPU

Reviewed by djb and Dijkstra personas. Corrections applied:
- GPU throughput upgraded from 10^9 to 10^10 AES/sec baseline
- Oracle strengthened: bytes 4+ (type byte, sender name) also predictable
- Dictionary size assumptions made explicit
- Zipf's law caveat added (humans don't choose uniformly)
- base64 short-passphrase key truncation issue documented
2026-04-05 00:58:57 +00:00
you bcab31bf72 docs: AES-128-ECB security analysis — block-level vulnerability assessment
Formal analysis of MeshCore's ECB encryption for channel and direct messages.
Reviewed by djb and Dijkstra expert personas through 3 revisions.

Key findings:
- Block 0 has accidental nonce (4-byte timestamp) preventing repetition
- Blocks 1+ are pure deterministic ECB with no nonce — vulnerable to
  frequency analysis for repeated message content
- Partial final block attack: zero-padding reduces search space
- HMAC key reuse: AES key is first 16 bytes of HMAC key (same material)
- Recommended fix: switch to AES-128-CTR mode
2026-04-05 00:44:21 +00:00
Kpa-clawbot 6ae62ce535 perf: make txToMap observations lazy via ExpandObservations flag (#595)
## Summary

`txToMap()` previously always allocated observation sub-maps for every
packet, even though the `/api/packets` handler immediately stripped them
via `delete(p, "observations")` unless `expand=observations` was
requested. A typical page of 50 packets with ~5 observations each caused
300+ unnecessary map allocations per request.

## Changes

- **`txToMap`**: Add variadic `includeObservations bool` parameter.
Observations are only built when `true` is passed, eliminating
allocations when they'd just be discarded.
- **`PacketQuery`**: Add `ExpandObservations bool` field to thread the
caller's intent through the query pipeline.
- **`routes.go`**: Set `ExpandObservations` based on
`expand=observations` query param. Removed the post-hoc `delete(p,
"observations")` loop — observations are simply never created when not
requested.
- **Single-packet lookups** (`GetPacketByID`, `GetPacketByHash`): Always
pass `true` since detail views need observations.
- **Multi-node/analytics queries**: Default (no flag) = no observations,
matching prior behavior.

## Testing

- Added `TestTxToMapLazyObservations` covering all three cases: no flag,
`false`, and `true`.
- All existing tests pass (`go test ./...`).

## Perf Impact

Eliminates ~250 observation map allocations per /api/packets request (at
default page size of 50 with ~5 observations each). This is a
constant-factor improvement per request — no algorithmic complexity
change.

Fixes #374

Co-authored-by: you <you@example.com>
2026-04-04 10:39:30 -07:00
Kpa-clawbot 6e2f79c0ad perf: optimize QueryGroupedPackets — cache observer count, defer map construction (#594)
## Summary

Optimizes `QueryGroupedPackets()` in `store.go` to eliminate two major
inefficiencies on every grouped packet list request:

### Changes

1. **Cache `UniqueObserverCount` on `StoreTx`** — Instead of iterating
all observations to count unique observers on every query
(O(total_observations) per request), we now track unique observers at
ingest time via an `observerSet` map and pre-computed
`UniqueObserverCount` field. This is updated incrementally as
observations arrive.

2. **Defer map construction until after pagination** — Previously,
`map[string]interface{}` was built for ALL 30K+ filtered results before
sorting and paginating. Now the grouped cache stores sorted `[]*StoreTx`
pointers (lightweight), and `groupedTxsToPage()` builds maps only for
the requested page (typically 50 items). This eliminates ~30K map
allocations per cache miss.

3. **Lighter cache footprint** — The grouped cache now stores
`[]*StoreTx` instead of `*PacketResult` with pre-built maps, reducing
memory pressure and GC work.

### Complexity

- Observer counting: O(1) per query (was O(total_observations))
- Map construction: O(page_size) per query (was O(n) where n = all
filtered results)
- Sort remains O(n log n) on cache miss, but the cache (3s TTL) absorbs
repeated requests

### Testing

- `cd cmd/server && go test ./...` — all tests pass
- `cd cmd/ingestor && go build ./...` — builds clean

Fixes #370

---------

Co-authored-by: you <you@example.com>
2026-04-04 10:39:04 -07:00
Kpa-clawbot b0862f7a41 fix: replace time.Tick with NewTicker in prune goroutine for graceful shutdown (#593)
## Summary

Replace `time.Tick()` with `time.NewTicker()` in the auto-prune
goroutine so it stops cleanly during graceful shutdown.

## Problem

`time.Tick` creates a ticker that can never be garbage collected or
stopped. While the prune goroutine runs for the process lifetime, it
won't stop during graceful shutdown — the goroutine leaks past the
shutdown sequence.

## Fix

- Create a `time.NewTicker` and a done channel
- Use `select` to listen on both the ticker and done channel
- Stop the ticker and close the done channel in the shutdown path (after
`poller.Stop()`)
- Pattern matches the existing `StartEvictionTicker()` approach

## Testing

- `go build ./...` — compiles cleanly
- `go test ./...` — all tests pass

Fixes #377

Co-authored-by: you <you@example.com>
2026-04-04 10:38:37 -07:00
Kpa-clawbot 45991eca09 perf: combine chained filterPackets passes into single scan (#592)
## Summary

Combines the chained `filterTxSlice` calls in `filterPackets()` into a
single pass over the packet slice.

## Problem

When multiple filter parameters are specified (e.g.,
`type=4&route=1&since=...&until=...`), each filter created a new
intermediate `[]*StoreTx` slice. With N filters, this meant N separate
scans and N-1 unnecessary allocations.

## Fix

All filter predicates (type, route, observer, hash, since, until,
region, node) are pre-computed before the loop, then evaluated in a
single `filterTxSlice` call. This eliminates all intermediate
allocations.

**Preserved behavior:**
- Fast-path index lookups for hash-only and observer-only queries remain
unchanged
- Node-only fast-path via `byNode` index preserved
- All existing filter semantics maintained (same comparison operators,
same null checks)

**Complexity:** Single `O(n)` pass regardless of how many filters are
active, vs previous `O(n * k)` where k = number of active filters (each
pass is O(n) but allocates).

## Testing

All existing tests pass (`cd cmd/server && go test ./...`).

Fixes #373

Co-authored-by: you <you@example.com>
2026-04-04 10:38:10 -07:00
Kpa-clawbot 76c42556a2 perf: sort snrVals/rssiVals once in computeAnalyticsRF (#591)
## Summary

Sort `snrVals` and `rssiVals` once upfront in `computeAnalyticsRF()` and
read min/max/median directly from the sorted slices, instead of copying
and sorting per stat call.

## Changes

- Sort both slices once before computing stats (2 sorts total instead of
4+ copy+sorts)
- Read `min` from `sorted[0]`, `max` from `sorted[len-1]`, `median` from
`sorted[len/2]`
- Remove the now-unused `sortedF64` and `medianF64` helper closures

## Performance impact

With 100K+ observations, this eliminates multiple O(n log n) copy+sort
operations. Previously each call to `medianF64` did a full copy + sort,
and `minF64`/`maxF64` did O(n) scans on the unsorted array. Now: 2
in-place sorts total, O(1) lookups for min/max/median.

Fixes #366

Co-authored-by: you <you@example.com>
2026-04-04 10:37:42 -07:00
Kpa-clawbot 6f8378a31c perf: batch-remove from secondary indexes in EvictStale (#590)
## Summary

`EvictStale()` was doing O(n) linear scans per evicted item to remove
from secondary indexes (`byObserver`, `byPayloadType`, `byNode`).
Evicting 1000 packets from an observer with 50K observations meant 1000
× 50K = 50M comparisons — all under a write lock.

## Fix

Replace per-item removal with batch single-pass filtering:

1. **Collect phase**: Walk evicted packets once, building sets of
evicted tx IDs, observation IDs, and affected index keys
2. **Filter phase**: For each affected index slice, do a single pass
keeping only non-evicted entries

**Before**: O(evicted_count × index_slice_size) per index — quadratic in
practice
**After**: O(evicted_count + index_slice_size) per affected key — linear

## Changes

- `cmd/server/store.go`: Restructured `EvictStale()` eviction loop into
collect + batch-filter pattern

## Testing

- All existing tests pass (`cd cmd/server && go test ./...`)

Fixes #368

Co-authored-by: you <you@example.com>
2026-04-04 10:37:27 -07:00
Kpa-clawbot 56115ee0a4 perf: use byNode index in QueryMultiNodePackets instead of full scan (#589)
## Summary

`QueryMultiNodePackets()` was scanning ALL packets with
`strings.Contains` on JSON blobs — O(packets × pubkeys × json_length).
With 30K+ packets and multiple pubkeys, this caused noticeable latency
on `/api/packets?nodes=...`.

## Fix

Replace the full scan with lookups into the existing `byNode` index,
which already maps pubkeys to their transmissions. Merge results with
hash-based deduplication, then apply time filters.

**Before:** O(N × P × J) where N=all packets, P=pubkeys, J=avg JSON
length
**After:** O(M × P) where M=packets per pubkey (typically small), plus
O(R log R) sort for pagination correctness

Results are sorted by `FirstSeen` after merging to maintain the
oldest-first ordering expected by the pagination logic.

Fixes #357

Co-authored-by: you <you@example.com>
2026-04-04 10:36:59 -07:00
Kpa-clawbot 321d1cf913 perf: apply time filter early in GetNodeAnalytics to avoid full packet scan (#588)
## Problem

`GetNodeAnalytics()` in `store.go` scans ALL 30K+ packets doing
`strings.Contains` on every JSON blob when the node has a name, then
filters by time range *after* the full scan. This is `O(packets ×
json_length)` on every `/api/nodes/{pubkey}/analytics` request.

## Fix

Move the `fromISO` time check inside the scan loop so old packets are
skipped **before** the expensive `strings.Contains` matching. For the
non-name path (indexed-only), the time filter is also applied inline,
eliminating the separate `allPkts` intermediate slice.

### Before
1. Scan all packets → collect matches (including old ones) → `allPkts`
2. Filter `allPkts` by time → `packets`

### After
1. Scan packets, skip `tx.FirstSeen <= fromISO` immediately → `packets`

This avoids `strings.Contains` calls on packets outside the requested
time window (typically 7 days out of months of data).

## Complexity
- **Before:** `O(total_packets × avg_json_length)` for name matching
- **After:** `O(recent_packets × avg_json_length)` — only packets within
the time window are string-matched

## Testing
- `cd cmd/server && go test ./...` — all tests pass

Fixes #367

Co-authored-by: you <you@example.com>
2026-04-04 10:36:49 -07:00
Kpa-clawbot 790a713ba9 perf: combine 4 subpath API calls into single bulk endpoint (#587)
## Summary

Consolidates the 4 parallel `/api/analytics/subpaths` calls in the Route
Patterns tab into a single `/api/analytics/subpaths-bulk` endpoint,
eliminating 3 redundant server-side scans of the subpath index on cache
miss.

## Changes

### Backend (`cmd/server/routes.go`, `cmd/server/store.go`)
- New `GET
/api/analytics/subpaths-bulk?groups=2-2:50,3-3:30,4-4:20,5-8:15`
endpoint
- Groups format: `minLen-maxLen:limit` comma-separated
- `GetAnalyticsSubpathsBulk()` iterates `spIndex` once, bucketing
entries into per-group accumulators by hop length
- Hop name resolution is done once per raw hop and shared across groups
- Results are cached per-group for compatibility with existing
single-key cache lookups
- Region-filtered queries fall back to individual
`GetAnalyticsSubpaths()` calls (region filtering requires
per-transmission observer checks)

### Frontend (`public/analytics.js`)
- `renderSubpaths()` now makes 1 API call instead of 4
- Response shape: `{ results: [{ subpaths, totalPaths }, ...] }` —
destructured into the same `[d2, d3, d4, d5]` variables

### Tests (`cmd/server/routes_test.go`)
- `TestAnalyticsSubpathsBulk`: validates 3-group response shape, missing
params error, invalid format error

## Performance

- **Before:** 4 API calls → 4 scans of `spIndex` + 4× hop resolution on
cache miss
- **After:** 1 API call → 1 scan of `spIndex` + 1× hop resolution
(shared cache)
- Cache miss cost reduced by ~75% for this tab
- No change on cache hit (individual group caching still works)

Fixes #398

Co-authored-by: you <you@example.com>
2026-04-04 10:19:18 -07:00
Kpa-clawbot cd470dffbe perf: batch observation fetching to eliminate N+1 API calls on sort change (#586)
## Summary

Fixes the N+1 API call pattern when changing observation sort mode on
the packets page. Previously, switching sort to Path or Time fired
individual `/api/packets/{hash}` requests for **every**
multi-observation group without cached children — potentially 100+
concurrent requests.

## Changes

### Backend: Batch observations endpoint
- **New endpoint:** `POST /api/packets/observations` accepts `{"hashes":
["h1", "h2", ...]}` and returns all observations keyed by hash in a
single response
- Capped at 200 hashes per request to prevent abuse
- 4 test cases covering empty input, invalid JSON, too-many-hashes, and
valid requests

### Frontend: Use batch endpoint
- `packets.js` sort change handler now collects all hashes needing
observation data and sends a single POST request instead of N individual
GETs
- Same behavior, single round-trip

## Performance

- **Before:** Changing sort with 100 visible groups → 100 concurrent API
requests, browser connection queueing (6 per host), several seconds of
lag
- **After:** Single POST request regardless of group count, response
time proportional to store lookup (sub-millisecond per hash in memory)

Fixes #389

---------

Co-authored-by: you <you@example.com>
2026-04-04 10:18:40 -07:00
Kpa-clawbot 7ff89d8607 perf(packets): coalesce WS-triggered renders with requestAnimationFrame (#585)
## Summary

Coalesce WS-triggered `renderTableRows()` calls using
`requestAnimationFrame` instead of `setTimeout` debouncing.

Fixes #396

## Problem

During high WebSocket throughput, multiple WS batches could each trigger
a `renderTableRows()` call via `setTimeout(..., 200)`. With rapid
batches, this caused the 50K-row table to be fully rebuilt every few
hundred milliseconds, causing UI jank.

## Solution

Replace the `setTimeout`-based debounce with a `requestAnimationFrame`
coalescing pattern:

1. **`scheduleWSRender()`** — sets a dirty flag and schedules a single
rAF callback
2. **Dirty flag** — multiple WS batches within the same frame just set
the flag; only one render fires
3. **Cleanup** — `destroy()` cancels any pending rAF and resets the
dirty flag

This ensures at most **one `renderTableRows()` per animation frame**
(~16ms), regardless of how many WS batches arrive.

## Performance justification

- **Before:** Each WS batch → `setTimeout(renderTableRows, 200)` — N
batches in <200ms = N renders
- **After:** N batches in one frame → 1 render on next rAF (~16ms)
- Worst case goes from O(N) renders per second to O(60) renders per
second (frame-capped)

## Changes

- `public/packets.js`: Add `scheduleWSRender()` with rAF + dirty flag;
replace setTimeout in WS handler; clean up in `destroy()`
- `test-frontend-helpers.js`: Update tests to verify rAF coalescing
pattern instead of setTimeout debounce

## Testing

- All existing tests pass (`npm test` — 0 failures)
- Updated 2 test cases to verify new rAF coalescing behavior

Co-authored-by: you <you@example.com>
2026-04-04 10:18:09 -07:00
Kpa-clawbot 493849f2e3 perf(frontend): compress og-image.png from 1.1MB to 235KB (#584)
## Summary

Compress `public/og-image.png` from **1,159,050 bytes (1.1MB)** to
**234,899 bytes (235KB)** — an **80% reduction**.

## What Changed

- Applied lossy PNG quantization via `pngquant` (quality 45-65, speed 1)
- Image dimensions unchanged: 1200×630px (standard OG image size)
- Visual quality remains suitable for social media previews

## Why

A 1.1MB OpenGraph image is excessive. Typical OG images are 50-200KB.
This reduces deployment size and Git repo bloat without affecting
functionality (browsers don't preload OG images).

## Testing

- Unit tests pass (`npm run test:unit`)
- No code changes — image-only commit
- `index.html` reference unchanged (`<meta property="og:image"
content="/og-image.png">`)

Fixes #397

Co-authored-by: you <you@example.com>
2026-04-04 10:17:21 -07:00
Kpa-clawbot 87ac61748c perf(analytics): compute network status client-side, eliminate redundant API call (#583)
## Summary

Reduces the analytics nodes tab from 3 parallel API calls to 2 by
computing network status (active/degraded/silent counts) client-side
instead of fetching from `/nodes/network-status`.

## What Changed

**`public/analytics.js` — `renderNodesTab()`:**
- Removed the `/nodes/network-status` API call from the `Promise.all`
batch
- Added client-side computation of active/degraded/silent counts using
the shared `getHealthThresholds()` function from `roles.js`
- Uses `nodesResp.total` and `nodesResp.counts` (already returned by
`/nodes` endpoint) for total node count and role breakdown

## Why This Works

The `/nodes` response already includes:
- `total` — count of all matching nodes (server-computed across full DB)
- `counts` — role counts across all nodes (from `GetAllRoleCounts()`)
- Per-node `last_seen`/`last_heard` timestamps

The `getHealthThresholds()` function in `roles.js` provides the same
degraded/silent thresholds used server-side, so client-side status
computation produces equivalent results for the loaded node set.

## Performance

- **Before:** 3 parallel API calls (`/nodes`, `/nodes/bulk-health`,
`/nodes/network-status`)
- **After:** 2 parallel API calls (`/nodes`, `/nodes/bulk-health`)
- Network status computation is O(n) over the 200 loaded nodes —
negligible client-side cost
- The `/nodes/network-status` endpoint scanned ALL nodes in the DB on
every call; this eliminates that server-side work entirely

## Testing

- All frontend helper tests pass (445/445)
- All packet filter tests pass (62/62)  
- All aging tests pass (29/29)
- All Go backend tests pass

Fixes #392

---------

Co-authored-by: you <you@example.com>
2026-04-04 10:17:05 -07:00
Kpa-clawbot 26de38f4b6 perf(map): reposition markers on zoom/resize instead of full rebuild (#582)
## Summary

Eliminates visible marker flicker on zoom/resize events in the map page
when displaying 500+ nodes.

## Problem

`renderMarkers()` was called on every `zoomend` and `resize` event,
which did `markerLayer.clearLayers()` followed by a full rebuild of all
markers. With many nodes, this caused a visible flash where all markers
disappeared briefly before being re-added.

## Solution

Instead of rebuilding all markers from scratch on zoom/resize:

1. **Store Leaflet layer references** on marker data objects
(`_leafletMarker`, `_leafletLine`, `_leafletDot`) during the initial
full render
2. **Add `_repositionMarkers()`** — re-runs `deconflictLabels()` at the
new zoom level and updates existing marker positions via
`setLatLng()`/`setLatLngs()` without clearing the layer group
3. **Debounce zoom/resize handlers** (150ms) to coalesce rapid events
during animated zooms
4. **Dynamically manage offset indicators** — adds/removes deconfliction
offset lines and dots as positions change at different zoom levels

Full `renderMarkers()` is still called for filter changes, data updates,
and theme changes — only zoom/resize uses the lightweight repositioning
path.

## Complexity

- `_repositionMarkers()`: O(n) — single pass over stored marker data
- `deconflictLabels()`: O(n × k) where k is max spiral offsets (48) —
unchanged
- No new API calls, no DOM rebuilds

Fixes #393

---------

Co-authored-by: you <you@example.com>
2026-04-04 17:16:48 +00:00
Kpa-clawbot d2d4c504e8 perf(live): parallelize replayRecent() observation fetches (#581)
## Summary

`replayRecent()` in `live.js` fetched observation details for 8 packet
groups **sequentially** — each `await fetch()` waited for the previous
to complete before starting the next.

## Change

Replaced the sequential `for` loop with `Promise.all()` to fetch all 8
detail API calls **concurrently**. The mapping from results to live
packets is unchanged.

**Before:** 8 sequential fetches (total time ≈ sum of all request
durations)
**After:** 8 parallel fetches (total time ≈ max of all request
durations)

## Notes

- `replayRecent()` is currently disabled (commented out at line 856), so
this is dormant code — no runtime risk
- No behavioral change: same data mapping, same rendering, same VCR
buffer population
- All existing tests pass

Fixes #394

---------

Co-authored-by: you <you@example.com>
2026-04-04 10:16:08 -07:00
Kpa-clawbot b37e8e2da2 perf(packets): replace N+1 API calls with single expand=observations query (#580)
## Summary

Eliminates the N+1 API call storm when toggling off "Group by Hash" in
the packets table.

## Problem

When ungrouped mode was active, `loadPackets()` fired individual
`/api/packets/{hash}` requests for every multi-observation packet. With
200+ multi-obs packets, this created 200+ parallel HTTP requests —
overwhelming both browser connection limits and the server.

## Fix

The server already supports `expand=observations` on the `/api/packets`
endpoint, which returns observations inline. Instead of:

1. Always fetching grouped (`groupByHash=true`)
2. Then N+1 fetching each packet's children individually

We now:

1. Fetch grouped when grouped mode is active (`groupByHash=true`)
2. Fetch with `expand=observations` when ungrouped — **single API call**
3. Flatten observations client-side

**Result: 200+ API calls → 1 API call.**

## Changes

- `public/packets.js`: Replaced N+1 observation fetching loop with
single `expand=observations` query parameter, flatten inline
observations client-side.

## Testing

- All frontend tests pass (packet-filter: 62/62, frontend-helpers:
445/445)
- All Go backend tests pass

Fixes #382

Co-authored-by: you <you@example.com>
2026-04-04 10:15:14 -07:00
Kpa-clawbot 45d8116880 perf: query only matching node locations in handleObservers (#579)
## Summary

`handleObservers()` in `routes.go` was calling `GetNodeLocations()`
which fetches ALL nodes from the DB just to match ~10 observer IDs
against node public keys. With 500+ nodes this is wasteful.

## Changes

- **`db.go`**: Added `GetNodeLocationsByKeys(keys []string)` — queries
only the rows matching the given public keys using a parameterized
`WHERE LOWER(public_key) IN (?, ?, ...)` clause.
- **`routes.go`**: `handleObservers` now collects observer IDs and calls
the targeted method instead of the full-table scan.
- **`coverage_test.go`**: Added `TestGetNodeLocationsByKeys` covering
known key, empty keys, and unknown key cases.

## Performance

With ~10 observers and 500+ nodes, the query goes from scanning all 500
rows to fetching only ~10. The original `GetNodeLocations()` is
preserved for any other callers.

Fixes #378

Co-authored-by: you <you@example.com>
2026-04-04 10:14:37 -07:00
Kpa-clawbot f68e98c376 perf(live): skip updateTimeline() when tab is hidden (#578)
## Summary

Skip `updateTimeline()` canvas redraws in `bufferPacket()` when the
browser tab is hidden (`_tabHidden === true`). Instead, batch-update the
timeline once when the tab becomes visible again via the
`visibilitychange` handler.

Fixes #385

## What Changed

**`public/live.js`** — two surgical edits:

1. **`bufferPacket()`**: Removed `updateTimeline()` call from the
`_tabHidden` early-return path. When the tab is backgrounded, packets
are still buffered (for VCR) but no canvas work is done.

2. **`visibilitychange` handler**: Added `updateTimeline()` call when
the tab is restored, so the timeline catches up in a single repaint
instead of N repaints (one per buffered packet).

## Performance Impact

At 5+ packets/sec with a backgrounded tab, this eliminates continuous
canvas redraws (`updateTimeline()` calls `ctx.clearRect` + full canvas
redraw + `updateTimelinePlayhead()`) that are invisible to the user. CPU
usage drops to near-zero for timeline rendering while backgrounded.

## Tests

All existing tests pass:
- `test-packet-filter.js` — 62 passed
- `test-aging.js` — 29 passed  
- `test-frontend-helpers.js` — 445 passed

Co-authored-by: you <you@example.com>
2026-04-04 10:14:13 -07:00
Kpa-clawbot f3d5d1e021 perf: resolve hops from in-memory prefix map instead of N+1 DB queries (#577)
## Summary

Replace N+1 per-hop DB queries in `handleResolveHops` with O(1) lookups
against the in-memory prefix map that already exists in the packet
store.

## Problem

Each hop in the `resolve-hops` API triggered a separate `SELECT ... LIKE
?` query against the nodes table. With 10 hops, that's 10 DB round-trips
— unnecessary when `getCachedNodesAndPM()` already maintains an
in-memory prefix map that can resolve hops instantly.

## Changes

- **routes.go**: Replace the per-hop DB query loop with `pm.m[hopLower]`
lookups from the prefix map. Convert `nodeInfo` → `HopCandidate` inline.
Remove unused `rows`/`sql.Scan` code.
- **store.go**: Add `InvalidateNodeCache()` method to force prefix map
rebuild (needed by tests that insert nodes after store initialization).
- **routes_test.go**: Give `TestResolveHopsAmbiguous` a proper store so
hops resolve via the prefix map.
- **resolve_context_test.go**: Call `InvalidateNodeCache()` after
inserting test nodes. Fix confidence assertion — with GPS candidates and
no affinity context, `resolveWithContext` correctly returns
`gps_preference` (previously masked because the prefix map didn't have
the test nodes).

## Complexity

O(1) per hop lookup via hash map vs O(n) DB scan per hop. No hot-path
impact — this endpoint is called on-demand, not in a render loop.

Fixes #369

---------

Co-authored-by: you <you@example.com>
2026-04-04 09:51:07 -07:00
Kpa-clawbot 02004c5912 perf: incremental distance index update on path changes (#576)
## Summary

Replace full `buildDistanceIndex()` rebuild with incremental
`removeTxFromDistanceIndex`/`addTxToDistanceIndex` for only the
transmissions whose paths actually changed during
`IngestNewObservations`.

## Problem

When any transmission's best path changed during observation ingestion,
the **entire distance index was rebuilt** — iterating all 30K+ packets,
resolving all hops, and computing haversine distances. This
`O(total_packets × avg_hops)` operation ran under a write lock, blocking
all API readers.

A 30-second debounce (`distRebuildInterval`) was added in #557 to
mitigate this, but it only delayed the pain — the full rebuild still
happened, just less frequently.

## Fix

- Added `removeTxFromDistanceIndex(tx)` — filters out all
`distHopRecord` and `distPathRecord` entries for a specific transmission
- Added `addTxToDistanceIndex(tx)` — computes and appends new distance
records for a single transmission
- In `IngestNewObservations`, changed path-change handling to call
remove+add for each affected tx instead of marking dirty and waiting for
a full rebuild
- Removed `distDirty`, `distLast`, and `distRebuildInterval` since
incremental updates are cheap enough to apply immediately

## Complexity

- **Before:** `O(total_packets × avg_hops)` per rebuild (30K+ packets)
- **After:** `O(changed_txs × avg_hops + total_dist_records)` — the
remove is a linear scan of the distance slices, but only for affected
txs; the add is `O(hops)` per changed tx

The remove scan over `distHops`/`distPaths` slices is linear in slice
length, but this is still far cheaper than the full rebuild which also
does JSON parsing, hop resolution, and haversine math for every packet.

## Tests

- Updated `TestDistanceRebuildDebounce` →
`TestDistanceIncrementalUpdate` to verify incremental behavior and check
for duplicate path records
- All existing tests pass (`go test ./...` in both `cmd/server` and
`cmd/ingestor`)

Fixes #365

---------

Co-authored-by: you <you@example.com>
2026-04-04 09:50:55 -07:00
Kpa-clawbot ef30031e2e perf: cache resolveRegionObservers with 30s TTL (#575)
## Summary

Cache `resolveRegionObservers()` results with a 30-second TTL to
eliminate repeated database queries for region→observer ID mappings.

## Problem

`resolveRegionObservers()` queried the database on every call despite
the observers table changing infrequently (~20 rows). It's called from
10+ hot paths including `filterPackets()`, `GetChannels()`, and multiple
analytics compute functions. When analytics caches are cold, parallel
requests each hit the DB independently.

## Solution

- Added a dedicated `regionObsMu` mutex + `regionObsCache` map with 30s
TTL
- Uses a separate mutex (not `s.mu`) to avoid deadlocks — callers
already hold `s.mu.RLock()`
- Cache is lazily populated per-region and fully invalidated after TTL
expires
- Follows the same pattern as `getCachedNodesAndPM()` (30s TTL,
on-demand rebuild)

## Changes

- **`cmd/server/store.go`**: Added `regionObsMu`, `regionObsCache`,
`regionObsCacheTime` fields; rewrote `resolveRegionObservers()` to check
cache first; added `fetchAndCacheRegionObs()` helper
- **`cmd/server/coverage_test.go`**: Added
`TestResolveRegionObserversCaching` — verifies cache population, cache
hits, and nil handling for unknown regions

## Testing

- All existing Go tests pass (`go test ./...`)
- New test verifies caching behavior (population, hits, nil for unknown
regions)

Fixes #362

---------

Co-authored-by: you <you@example.com>
2026-04-04 09:50:27 -07:00
Kpa-clawbot 67511ed6a7 perf: combine GetStoreStats into 2 concurrent queries instead of 5 sequential (#574)
## Summary

`GetStoreStats()` ran 5 sequential DB queries on every call. This
combines them into **2 concurrent queries**:

1. **Node/observer counts** — single query using subqueries: `SELECT
(SELECT COUNT(*) FROM nodes WHERE ...), (SELECT COUNT(*) FROM nodes),
(SELECT COUNT(*) FROM observers)`
2. **Observation counts** — single query using conditional aggregation:
`SUM(CASE WHEN timestamp > ? THEN 1 ELSE 0 END)` scoped to the 24h
window, avoiding a full table scan for the 1h count

Both queries run concurrently via goroutines + `sync.WaitGroup`.

## What changed

- `cmd/server/store.go`: Rewrote `GetStoreStats()` — 5 sequential
`QueryRow` calls → 2 concurrent combined queries
- Error handling now propagates query errors instead of silently
ignoring them

## Performance justification

- **Before:** 5 sequential round-trips to SQLite, with 2 potentially
expensive `COUNT(*)` scans on the `observations` table
- **After:** 2 concurrent round-trips; the observation query scans the
24h window once instead of separately scanning for 1h and 24h
- The 10s cache (`statsTTL`) remains, so this fires at most once per 10s
— but when it does fire, it's ~2.5x fewer round-trips and the
observation scan is halved

## Tests

- `go test ./...` passes for both `cmd/server` and `cmd/ingestor`

Fixes #363

---------

Co-authored-by: you <you@example.com>
2026-04-04 09:48:25 -07:00
Kpa-clawbot b35b473508 perf(nodes): extract shared fetchNodeDetail() to deduplicate API calls (#573)
## Summary

Extracts a shared `fetchNodeDetail(pubkey)` helper in `nodes.js` that
fetches both `/nodes/{pubkey}` and `/nodes/{pubkey}/health` in parallel.
Both `selectNode()` (side panel) and `loadFullNode()` (full-screen view)
now call this single function instead of duplicating the fetch logic.

## What Changed

- **New:** `fetchNodeDetail(pubkey)` — shared async function that
returns node data with `.healthData` attached
- **Modified:** `loadFullNode()` — uses `fetchNodeDetail()` instead of
inline `Promise.all`
- **Modified:** `selectNode()` — uses `fetchNodeDetail()` instead of
inline `Promise.all`

## Why

The duplicate `api()` calls weren't a major perf issue (TTL caching
mitigates most cases), but the duplicated logic was unnecessary tech
debt. On mobile, `selectNode()` redirects to `loadFullNode()` via hash
change, so the two code paths could fire sequentially with expired
cache.

## Testing

- All frontend helper tests pass (445/445)
- All packet filter tests pass (62/62)
- All aging tests pass (29/29)
- No behavioral change — only code structure improvement

Fixes #391

Co-authored-by: you <you@example.com>
2026-04-04 09:47:59 -07:00
Kpa-clawbot d4f2c3ac66 perf: index subpath detail lookups instead of scanning all packets (#571)
## Summary

`GetSubpathDetail()` iterated ALL packets to find those containing a
specific subpath — `O(packets × hops × subpath_length)`. With 30K+
packets this caused user-visible latency on every subpath detail click.

## Changes

### `cmd/server/store.go`
- Added `spTxIndex map[string][]*StoreTx` alongside existing `spIndex` —
tracks which transmissions contain each subpath key
- Extended `addTxToSubpathIndexFull()` and
`removeTxFromSubpathIndexFull()` to maintain both indexes simultaneously
- Original `addTxToSubpathIndex()`/`removeTxFromSubpathIndex()` wrappers
preserved for backward compatibility
- `buildSubpathIndex()` now populates both `spIndex` and `spTxIndex`
during `Load()`
- All incremental update sites (ingest, path change, eviction) use the
`Full` variants
- `GetSubpathDetail()` rewritten: direct `O(1)` map lookup on
`spTxIndex[key]` instead of scanning all packets

### `cmd/server/coverage_test.go`
- Added `TestSubpathTxIndexPopulated`: verifies `spTxIndex` is
populated, counts match `spIndex`, and `GetSubpathDetail` returns
correct results for both existing and non-existent subpaths

## Complexity

- **Before:** `O(total_packets × avg_hops × subpath_length)` per request
- **After:** `O(matched_txs)` per request (direct map lookup)

## Tests

All tests pass: `cmd/server` (4.6s), `cmd/ingestor` (25.6s)

Fixes #358

---------

Co-authored-by: you <you@example.com>
2026-04-04 09:35:00 -07:00
Kpa-clawbot 37300bf5c8 fix: cap prefix map at 8 chars to cut memory ~10x (#570)
## Summary

`buildPrefixMap()` was generating map entries for every prefix length
from 2 to `len(pubkey)` (up to 64 chars), creating ~31 entries per node.
With 500 nodes that's ~15K map entries; with 1K+ nodes it balloons to
31K+.

## Changes

**`cmd/server/store.go`:**
- Added `maxPrefixLen = 8` constant — MeshCore path hops use 2–6 char
prefixes, 8 gives headroom
- Capped the prefix generation loop at `maxPrefixLen` instead of
`len(pk)`
- Added full pubkey as a separate map entry when key is longer than
`maxPrefixLen`, ensuring exact-match lookups (used by
`resolveWithContext`) still work

**`cmd/server/coverage_test.go`:**
- Added `TestPrefixMapCap` with subtests for:
  - Short prefix resolution still works
  - Full pubkey exact-match resolution still works
  - Intermediate prefixes beyond the cap correctly return nil
  - Short keys (≤8 chars) have all prefix entries
  - Map size is bounded

## Impact

- Map entries per node: ~31 → ~8 (one per prefix length 2–8, plus one
full-key entry)
- Total map size for 500 nodes: ~15K entries → ~4K entries (~75%
reduction)
- No behavioral change for path hop resolution (2–6 char prefixes)
- No behavioral change for exact pubkey lookups

## Tests

All existing tests pass:
- `cmd/server`: 
- `cmd/ingestor`: 

Fixes #364

---------

Co-authored-by: you <you@example.com>
2026-04-04 09:28:38 -07:00
Kpa-clawbot cb8a2e15c8 perf: index node path lookups instead of scanning all packets (#572)
## Summary

Index node path lookups in `handleNodePaths()` instead of scanning all
packets on every request.

## Problem

`handleNodePaths()` iterated ALL packets in the store (`O(total_packets
× avg_hops)`) with prefix string matching on every hop. This caused
user-facing latency on every node detail page load with 30K+ packets.

## Fix

Added a `byPathHop` index (`map[string][]*StoreTx`) that maps lowercase
hop prefixes and resolved full pubkeys to their transmissions. The
handler now does direct map lookups instead of a full scan.

### Index lifecycle
- **Built** during `Load()` via `buildPathHopIndex()`
- **Incrementally updated** during `IngestNewFromDB()` (new packets) and
`IngestNewObservations()` (path changes)
- **Cleaned up** during `EvictStale()` (packet removal)

### Query strategy
The handler looks up candidates from the index using:
1. Full pubkey (matches resolved hops from `resolved_path`)
2. 2-char prefix (matches short raw hops)
3. 4-char prefix (matches medium raw hops)
4. Any longer raw hops starting with the 4-char prefix

This reduces complexity from `O(total_packets × avg_hops)` to
`O(matching_txs + unique_hop_keys)`.

## Tests

- `TestNodePathsEndpointUsesIndex` — verifies the endpoint returns
correct results using the index
- `TestPathHopIndexIncrementalUpdate` — verifies add/remove operations
on the index

All existing tests pass.

Fixes #359

Co-authored-by: you <you@example.com>
2026-04-04 09:25:18 -07:00
Kpa-clawbot aac038abb9 fix: filter inconsistent hash sizes by role and add 7-day time window (#567)
## Summary

Fixes #566 — The "Inconsistent Hash Sizes" list on the Analytics page
included all node types and had no time window, causing false positives.

## Changes

### 1. Role filter on inconsistent nodes (`cmd/server/store.go`)
Added role filter to the `inconsistentNodes` loop in
`computeHashCollisions()` so only repeaters and room servers are
included. Companions are excluded since they were never affected by the
firmware bug. This matches the existing role filter on collision
bucketing from #441.

```go
// Before:
if cn.HashSizeInconsistent {

// After:
if cn.HashSizeInconsistent && (cn.Role == "repeater" || cn.Role == "room_server") {
```

### 2. 7-day time window on hash size computation
(`cmd/server/store.go`)
Added a 7-day recency cutoff to `computeNodeHashSizeInfo()`. Adverts
older than 7 days are now skipped, preventing legitimate historical
config changes (e.g., testing different byte sizes) from creating
permanent false positives.

### 3. Frontend description text (`public/analytics.js`)
Updated the description to reflect the filtered scope: now says
"Repeaters and room servers" instead of "Nodes", mentions the 7-day
window, and notes that companions are excluded.

## Tests

- `TestInconsistentNodesExcludesCompanions` — verifies companions are
excluded while repeaters and room servers are included
- `TestHashSizeInfoTimeWindow` — verifies adverts older than 7 days are
excluded from hash size computation
- Updated existing hash size tests to use recent timestamps (compatible
with the new time window)
- All existing tests pass: `cmd/server` , `cmd/ingestor` 

## Perf justification
The time window filter adds a single string comparison per advert in the
scan loop — O(n) with a tiny constant. No impact on hot paths.

---------

Co-authored-by: you <you@example.com>
2026-04-04 09:22:12 -07:00
Kpa-clawbot 588fba226d perf: track max transmission/observation IDs incrementally (#569)
## Summary

Replace O(n) map iteration in `MaxTransmissionID()` and
`MaxObservationID()` with O(1) field lookups.

## What Changed

- Added `maxTxID` and `maxObsID` fields to `PacketStore`
- Updated `Load()`, `IngestNewFromDB()`, and `IngestNewObservations()`
to track max IDs incrementally as entries are added
- `MaxTransmissionID()` and `MaxObservationID()` now return the tracked
field directly instead of iterating the entire map

## Performance

Before: O(n) iteration over 30K+ map entries under a read lock
After: O(1) field return

## Tests

- Added `TestMaxTransmissionIDIncremental` verifying the incremental
field matches brute-force iteration over the maps
- All existing tests pass (`cmd/server` and `cmd/ingestor`)

Fixes #356

Co-authored-by: you <you@example.com>
2026-04-04 09:20:17 -07:00
Kpa-clawbot c670742589 feat: add byte-size filter to map page (#565) (#568)
## Summary

Adds a byte-size filter to the map page, allowing users to filter
repeater markers by their hash prefix size (1-byte, 2-byte, or 3-byte).

## What changed

**`public/map.js`** — single file change:

1. **New filter state**: Added `byteSize` to the `filters` object
(default: `'all'`), persisted in `localStorage`
2. **New UI section**: Added a "Byte Size" fieldset with button group
(`All | 1-byte | 2-byte | 3-byte`) in the map controls panel, between
"Node Types" and "Display"
3. **Filter logic**: In `_renderMarkersInner`, when `byteSize !==
'all'`, repeater nodes are filtered by their `hash_size` field.
Non-repeater nodes (companions, rooms, sensors) are unaffected — they
pass through regardless of the byte-size filter setting
4. **Event binding**: Button click handlers update the filter, persist
to localStorage, and re-render markers

## Design decisions

- **Client-side only** — no backend changes needed. The `hash_size`
field is already included in the `/api/nodes` response
- **Repeaters only** — byte size is a repeater configuration concept;
other node roles don't have configurable path prefix sizes
- **Matches existing pattern** — uses the same button-group UI as the
Status filter (All/Active/Stale)
- **`hash_size` defaults to 1** — consistent with how the rest of the
codebase treats missing `hash_size` (`node.hash_size || 1`)

## Performance

No new API calls. Filter is a simple string comparison inside the
existing `nodes.filter()` loop in `_renderMarkersInner` — O(1) per node,
negligible overhead.

Fixes #565

Co-authored-by: you <you@example.com>
2026-04-04 09:14:49 -07:00
efiten f897ce1b26 fix: use runtime heap stats for memory-based eviction (#564)
## Problem

Closes #563. Addresses the *Packet store estimated memory* item in #559.

`estimatedMemoryMB()` used a hardcoded formula:

```go
return float64(len(s.packets)*5120+s.totalObs*500) / 1048576.0
```

This ignored three data structures that grow continuously with every
ingest cycle:

| Structure | Production size | Heap not counted |
|---|---|---|
| `distHops []distHopRecord` | 1,556,833 records | ~300 MB |
| `distPaths []distPathRecord` | 93,090 records | ~25 MB |
| `spIndex map[string]int` | 4,113,234 entries | ~400 MB |

Result: formula reported ~1.2 GB while actual heap was ~5 GB. With
`maxMemoryMB: 1024`, eviction calculated it only needed to shed ~200 MB,
removed a handful of packets, and stopped. Memory kept growing until the
OOM killer fired.

## Fix

Replace `estimatedMemoryMB()` with `runtime.ReadMemStats` so all data
structures are automatically counted:

```go
func (s *PacketStore) estimatedMemoryMB() float64 {
    if s.memoryEstimator != nil {
        return s.memoryEstimator()
    }
    var ms runtime.MemStats
    runtime.ReadMemStats(&ms)
    return float64(ms.HeapAlloc) / 1048576.0
}
```

Replace the eviction simulation loop (which re-used the same wrong
formula) with a proportional calculation: if heap is N× over budget,
evict enough packets to keep `(1/N) × 0.9` of the current count. The 0.9
factor adds a 10% buffer so the next ingest cycle doesn't immediately
re-trigger. All major data structures (distHops, distPaths, spIndex)
scale with packet count, so removing a fraction of packets frees roughly
the same fraction of total heap.

## Testing

- Updated `TestEvictStale_MemoryBasedEviction` to inject a deterministic
estimator via the new `memoryEstimator` field.
- Added `TestEvictStale_MemoryBasedEviction_UnderestimatedHeap`:
verifies that when actual heap is 5× over limit (the production failure
scenario), eviction correctly removes ~80%+ of packets.

```
=== RUN   TestEvictStale_MemoryBasedEviction
[store] Evicted 538 packets (1076 obs)
--- PASS

=== RUN   TestEvictStale_MemoryBasedEviction_UnderestimatedHeap
[store] Evicted 820 packets (1640 obs)
--- PASS
```

Full suite: `go test ./...` — ok (10.3s)

## Perf note

`runtime.ReadMemStats` runs once per eviction tick (every 60 s) and once
per `/api/perf/store` call. Cost is negligible.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 08:41:54 -07:00
Kpa-clawbot cbfce41d7e perf: optimize neighbor graph build (3 fixes for 30s+ CPU) (#562)
## Summary

Fixes critical performance issue in neighbor graph computation that
consumed 65% of CPU (30+ seconds) on a 325K packet dataset.

## Changes

### Fix 1: Cache strings.ToLower results
- Added cachedToLower() helper that caches lowercased strings in a local
map
- Pubkeys repeat across hundreds of thousands of observations
- Pre-computes fromLower once per transaction instead of once per
observation
- **Impact:** Eliminates ~8.4s (25.3% CPU)

### Fix 2: Cache parsed DecodedJSON via StoreTx.ParsedDecoded()
- Added ParsedDecoded() method on StoreTx using sync.Once for
thread-safe lazy caching
- json.Unmarshal on decoded_json now runs at most once per packet
lifetime
- Result reused by extractFromNode, indexByNode, trackAdvertPubkey
- **Impact:** Eliminates ~8.8s (26.3% CPU)

### Fix 3: Extend neighbor graph TTL from 60s to 5 minutes
- The graph depends on traffic patterns, not individual packets
- Reduces rebuild frequency 5x
- **Impact:** ~80% reduction in sustained CPU from graph rebuilds

## Tests

- 7 new tests added, all 26+ existing neighbor graph tests pass
- BenchmarkBuildFromStore: 727us/op, 237KB/op, 6030 allocs/op

Related: #559

---------

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: you <you@example.com>
2026-04-04 01:25:51 -07:00
you 1e1c4cb91f fix: include resolved_path in groupByHash packet response
QueryGroupedPackets builds its map manually and was missing
resolved_path. The non-grouped path (txToMap) included it.
2026-04-04 08:01:35 +00:00
you 0c340e1eb6 fix: set hasResolvedPath flag after ensuring column exists
detectSchema() runs at DB open time before ensureResolvedPathColumn()
adds the column during Load(). On first run (or any run where the column
was just added), hasResolvedPath stayed false, causing Load() to skip
reading resolved_path from SQLite. This forced a full backfill of all
observations on every restart, burning CPU for minutes on large DBs.

Fix: set hasResolvedPath = true after ensureResolvedPathColumn succeeds.
2026-04-04 07:46:25 +00:00
Kpa-clawbot ae38cdefb4 feat: server-side hop resolution at ingest — resolved_path (#556)
## Summary

Implements server-side hop prefix resolution at ingest time with a
persisted neighbor graph. Hop prefixes in `path_json` are now resolved
to full 64-char pubkeys at ingest and stored as `resolved_path` on each
observation, eliminating the need for client-side resolution via
`HopResolver`.

Fixes #555

## What changed

### New file: `cmd/server/neighbor_persist.go`
SQLite persistence layer for the neighbor graph and resolved paths:
- `neighbor_edges` table creation and management
- Load/build/persist neighbor edges from/to SQLite
- `resolved_path` column migration on observations
- `resolvePathForObs()` — resolves hop prefixes using
`resolveWithContext` with 4-tier priority (affinity → geo → GPS → first
match)
- Cold startup backfill for observations missing `resolved_path`
- Async persistence of edges and resolved paths during ingest
(non-blocking)

### Modified: `cmd/server/store.go`
- `StoreObs` gains `ResolvedPath []*string` field
- `StoreTx` gains `ResolvedPath []*string` (cached from best
observation)
- `Load()` dynamically includes `resolved_path` in SQL query when column
exists
- `IngestNewFromDB()` resolves paths at ingest time and persists
asynchronously
- `pickBestObservation()` propagates `ResolvedPath` to transmission
- `txToMap()` and `enrichObs()` include `resolved_path` in API responses
- All 7 `pm.resolve()` call sites migrated to `pm.resolveWithContext()`
with the persisted graph
- Broadcast maps include `resolved_path` per observation

### Modified: `cmd/server/db.go`
- `DB` struct gains `hasResolvedPath bool` flag
- `detectSchema()` checks for `resolved_path` column existence
- Graceful degradation when column is absent (test DBs, old schemas)

### Modified: `cmd/server/main.go`
- Startup sequence: ensure tables → load/build graph → backfill resolved
paths → re-pick best observations

### Modified: `cmd/server/routes.go`
- `mapSliceToTransmissions()` and `mapSliceToObservations()` propagate
`resolved_path`
- Node paths handler uses `resolveWithContext` with graph

### Modified: `cmd/server/types.go`
- `TransmissionResp` and `ObservationResp` gain `ResolvedPath []*string`
with `omitempty`

### New file: `cmd/server/neighbor_persist_test.go`
16 tests covering:
- Path resolution (unambiguous, empty, unresolvable prefixes)
- Marshal/unmarshal of resolved_path JSON
- SQLite table creation and column migration (idempotent)
- Edge persistence and loading
- Schema detection
- Full Load() with resolved_path
- API response serialization (present when set, omitted when nil)

## Design decisions

1. **Async persistence** — resolved paths and neighbor edges are written
to SQLite in a goroutine to avoid blocking the ingest loop. The
in-memory state is authoritative.

2. **Schema compatibility** — `DB.hasResolvedPath` flag allows the
server to work with databases that don't yet have the `resolved_path`
column. SQL queries dynamically include/exclude the column.

3. **`pm.resolve()` retained** — Not removed as dead code because
existing tests use it directly. All production call sites now use
`resolveWithContext` with the persisted graph.

4. **Edge persistence is conservative** — Only unambiguous edges (single
candidate) are persisted to `neighbor_edges`. Ambiguous prefixes are
handled by the in-memory `NeighborGraph` via Jaccard disambiguation.

5. **`null` = unresolved** — Ambiguous prefixes store `null` in the
resolved_path array. Frontend falls back to prefix display.

## Performance

- `resolveWithContext` per hop: ~1-5μs (map lookups, no DB queries)
- Typical packet has 0-5 hops → <25μs total resolution overhead per
packet
- Edge/path persistence is async → zero impact on ingest latency
- Backfill is one-time on first startup with the new column

## Test results

```
cd cmd/server && go test ./... -count=1  → ok (4.4s)
cd cmd/ingestor && go test ./... -count=1 → ok (25.5s)
```

---------

Co-authored-by: you <you@example.com>
2026-04-04 00:20:59 -07:00
Kpa-clawbot a97fa52f10 feat: frontend consumers prefer resolved_path (M4, #555) (#561)
## Summary

Implements **M4 (frontend consumers)** from the [resolved-path
spec](https://github.com/Kpa-clawbot/CoreScope/blob/resolved-path-spec/docs/specs/resolved-path.md)
for #555.

The server (PR #556, M1-M3) now returns `resolved_path` on all
packet/observation API responses and WebSocket broadcasts. This PR
updates all frontend consumers to **prefer `resolved_path`** over
client-side HopResolver, with full fallback for old packets.

## What changed

### `hop-resolver.js`
- Added `resolveFromServer(hops, resolvedPath)` — takes the short hex
prefixes and aligned array of full pubkeys from `resolved_path`, looks
up node names from the existing nodesList. Returns the same `{ [hop]: {
name, pubkey, ... } }` format as `resolve()`.

### `packet-helpers.js`
- Added `getResolvedPath(p)` — cached JSON parser for the new
`resolved_path` field (mirrors `getParsedPath`).
- Updated `clearParsedCache()` to also clear `_parsedResolvedPath`.

### `packets.js`
- **Bulk load** (`loadPackets`): calls `cacheResolvedPaths(packets)`
before the existing `resolveHops` fallback.
- **WebSocket updates**: pre-populates `hopNameCache` from
`resolved_path` on incoming packets before falling back to HopResolver
for any remaining unknown hops.
- **Group expansion** (`pktToggleGroup`): caches resolved paths from
child observations.
- **Packet detail** (`selectPacket`): prefers `resolveFromServer` when
`resolved_path` is available.
- **Show Route button**: uses `resolved_path` pubkeys directly instead
of client-side disambiguation.
- **Observation spreading**: carries `resolved_path` field when
constructing observation packets.

### `live.js`
- `resolveHopPositions` accepts optional `resolvedPath` parameter;
prefers server-resolved pubkeys, falls back to HopResolver for null
entries.
- Normalized WS packet objects now carry `resolved_path`.

### Files NOT changed (no resolution changes needed)
- **`analytics.js`** — only uses `HopResolver.haversineKm` (a utility
function). Topology, subpath, and hop distance data comes pre-resolved
from the server API (handled by M2/M3).
- **`nodes.js`** — gets pre-resolved path data from
`/nodes/:pubkey/paths` API; no client-side hop resolution.
- **`map.js`** — `drawPacketRoute` already handles full 64-char pubkeys
via exact match. The updated `packets.js` now passes full pubkeys from
`resolved_path` to the map.

## Fallback pattern

```javascript
// In hop-resolver.js
function resolveFromServer(hops, resolvedPath) {
  // Returns resolved entries for non-null pubkeys
  // Skips null entries (unresolved) — caller falls back to HopResolver
}

// In packets.js — bulk load
await cacheResolvedPaths(packets);  // server-side first
await resolveHops([...allHops]);     // client-side fallback for remaining
```

Old packets without `resolved_path` continue to work exactly as before
via the existing HopResolver. `hop-resolver.js` is NOT removed — it
remains the fallback.

## Tests

- 10 new tests for `resolveFromServer()` and `getResolvedPath()`
- All 445 frontend helper tests pass
- All 62 packet filter tests pass
- All 29 aging tests pass

Closes #555 (M4 milestone)

---------

Co-authored-by: you <you@example.com>
2026-04-04 00:18:46 -07:00
Kpa-clawbot 43673e86f2 fix: perf stats MaxMB reads from config instead of hardcoded 1024 (#558)
Perf stats `GetPerfStoreStats` returned a hardcoded `MaxMB: 1024`
regardless of the configured `packetStore.maxMemoryMB`. Now reads from
`s.maxMemoryMB`.

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-03 23:25:54 -07:00
Kpa-clawbot 81ef51cc5c fix: debounce distance index rebuild to prevent CPU hot loop (#557)
## Problem

On busy meshes (325K+ transmissions, 50 observers), the distance index
rebuild runs on **every ingest poll** (~1s interval), computing
haversine distances for 1M+ hop records. Each rebuild takes 2-3 seconds
but new observations arrive faster than it can finish, creating a CPU
hot loop that starves the HTTP server.

Discovered on the Cascadia Mesh instance where `corescope-server` was
consuming 15 minutes of CPU time in 10 minutes of uptime, the API was
completely unresponsive, and health checks were timing out.

### Server logs showing the hot loop:
```
[store] Built distance index: 1797778 hop records, 207072 path records
[store] Built distance index: 1797806 hop records, 207075 path records
[store] Built distance index: 1797811 hop records, 207075 path records
[store] Built distance index: 1797820 hop records, 207075 path records
```
Every 2 seconds, nonstop.

## Root Cause

`IngestNewObservations` calls `buildDistanceIndex()` synchronously
whenever `pickBestObservation` selects a longer path. With 50 observers
sending observations every second, paths change on nearly every poll
cycle, triggering a full rebuild each time.

## Fix

- Mark distance index dirty on path changes instead of rebuilding inline
- Rebuild at most every **30 seconds** (configurable via `distLast`
timer)
- Set `distLast` after initial `Load()` to prevent immediate re-rebuild
on first ingest
- Distance data is at most 30s stale — acceptable for an analytics view

## Testing

- `go build`, `go vet`, `go test` all pass
- No behavioral change for the initial load or the analytics API
response shape
- Distance data freshness goes from real-time to 30s max staleness

---------

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: you <you@example.com>
2026-04-03 23:08:09 -07:00
you ddce26ff2d ci: pin build and deploy jobs to meshcore-vm runner 2026-04-04 04:21:48 +00:00
Kpa-clawbot ee29cc627f perf: parallelize expanded group fetches, use hashIndex Map lookup (#552)
## Summary
Fixes #388 — expanded groups were fetched sequentially with O(n)
`packets.find()` lookups.

## Changes
1. **Parallel fetch**: Replaced sequential `for...of + await` loop in
`loadPackets()` with `Promise.all()` so all expanded group children are
fetched concurrently.
2. **O(1) Map lookup**: Replaced 3 instances of `packets.find(p =>
p.hash === hash)` with `hashIndex.get(hash)`:
   - `loadPackets()` expanded group restore (~line 553)
   - `select-observation` click handler (~line 1015)
   - `pktToggleGroup()` (~line 2012)

## Perf justification
- **Before**: N expanded groups → N sequential API calls + N ×
O(packets.length) array scans
- **After**: N parallel API calls + N × O(1) Map lookups
- Typical N is 1-3 (minor severity as noted in issue), but the fix is
trivial and correct

## Tests
All existing tests pass: `test-packet-filter.js` (62), `test-aging.js`
(29), `test-frontend-helpers.js` (433).

Co-authored-by: you <you@example.com>
2026-04-03 21:09:17 -07:00
Kpa-clawbot f3caf42be4 feat: show transport badge in live packet feed (#551)
## Summary

Show the transport badge ("T") in the live packet feed, matching the
packets table (#337).

## Changes

- Add `transportBadge(pkt.route_type)` to all 4 feed rendering paths in
`live.js`:
  - Grouped feed items (initial history load)
  - `addFeedItemDOM()` (VCR replay)
  - Dedup new feed items (live WebSocket updates)
  - Node detail panel recent packets list
- Uses existing `transportBadge()` from `app.js` and `.badge-transport`
CSS from `style.css`

## Testing

- 2 new source-level assertions in `test-live.js` verifying
`transportBadge()` calls exist
- All existing tests pass (67 passed in test-live.js, no new failures)

Fixes #338

Co-authored-by: you <you@example.com>
2026-04-03 21:09:02 -07:00
Kpa-clawbot c34744247a fix: clean up nodeActivity in pruneStaleNodes to prevent memory leak (#553)
## Summary

`nodeActivity` (an object tracking per-node packet counts for heatmap
intensity) grows without bound — entries are added on every packet flash
but never removed, even when stale nodes are pruned.

## Changes

- **Delete `nodeActivity[key]`** alongside `nodeMarkers[key]` and
`nodeData[key]` when removing stale WS-only nodes in `pruneStaleNodes()`
- **Prune orphaned entries** — after the main prune loop, sweep
`nodeActivity` and delete any key that has no corresponding `nodeData`
entry (catches edge cases where nodes were removed by other code paths)
- Both run every 60s via the existing `pruneStaleNodes` interval timer

## Testing

- Added 2 regression tests in `test-frontend-helpers.js` verifying stale
node cleanup and orphan removal
- All 435 frontend helper tests pass, plus packet-filter (62) and aging
(29)

Fixes #390

---------

Co-authored-by: you <you@example.com>
2026-04-03 16:54:53 -07:00
Kpa-clawbot 10f712f9d7 fix: restructure scroll containers for iOS status bar tap-to-scroll (#330) (#554)
## Summary

Fixes #330 — iOS status bar tap-to-scroll broken because `#app` had
`overflow: hidden`, preventing `<body>` from being the scroll container.

## Approach: Option B from the issue

Instead of a JS polyfill, this restructures scroll containers so
`<body>` is the primary scroll container by default, which iOS Safari
requires for native status-bar tap-to-scroll.

### How it works

**`#app` default (body-scroll mode):** Uses `min-height` instead of
fixed `height`, no `overflow: hidden`. Content pushes beyond the
viewport and body scrolls naturally.

**`#app.app-fixed` (fixed-layout mode):** Restores the original `height:
calc(100dvh - 52px); overflow: hidden` for pages that need constrained
containers. The router in `app.js` toggles this class based on the
current page.

### Fixed-layout pages (`.app-fixed`)
These pages need fixed-height containers and are unchanged in behavior:
- **packets** — virtual scroll requires fixed-height `.panel-left` to
calculate visible rows
- **nodes** — split-panel layout with independently scrollable panels
- **map** — Leaflet requires fixed-dimension container
- **live** — Leaflet map (also has its own `#app:has(.live-page)`
override in live.css)
- **channels** — split-panel chat layout
- **audio-lab** — split-panel layout

### Body-scroll pages (no `.app-fixed`)
These pages now let the body scroll, enabling iOS tap-to-scroll:
- **analytics** — removed `overflow-y: auto; height: 100%`
- **observers** — removed `overflow-y: auto; height: calc(100vh - 56px)`
- **traces** — removed `overflow-y: auto; height: 100%`
- **home** — removed `#app:has(.home-hero)` override (no longer needed)
- **compare** — removed inline `overflow-y:auto; height:calc(100vh -
56px)`
- **perf** — removed inline `height:100%; overflow-y:auto`
- **observer-detail** — removed inline `overflow-y:auto;
height:calc(100vh - 56px)`
- **node-analytics** — removed inline `height:100%; overflow-y:auto`

### Files changed
| File | Change |
|------|--------|
| `public/style.css` | `#app` default → `min-height`; added `.app-fixed`
class |
| `public/app.js` | Router toggles `.app-fixed` based on page |
| `public/home.css` | Removed `#app:has()` workaround |
| `public/compare.js` | Removed inline overflow/height |
| `public/perf.js` | Removed inline overflow/height |
| `public/observer-detail.js` | Removed inline overflow/height |
| `public/node-analytics.js` | Removed inline overflow/height |

### What's preserved
- Sticky nav (`position: sticky; top: 0`) — works with body scroll
- Split-panel resize handles — unchanged, still in fixed containers
- Virtual scroll on packets page — unchanged, `.panel-left` still has
fixed height
- Leaflet maps — unchanged, containers still have fixed dimensions
- Mobile responsive overrides — unchanged

Co-authored-by: you <you@example.com>
2026-04-03 16:54:36 -07:00
Kpa-clawbot 412a8fdb8f feat: live map uses affinity-aware hop resolution (#528) (#550)
## Summary

Augments the shared `HopResolver` with neighbor-graph affinity data so
that when multiple nodes match a hop prefix, the resolver prefers
candidates that are known neighbors of the adjacent hop — instead of
relying solely on geo-distance.

Fixes #528

## Changes

### `public/hop-resolver.js`
- Added `affinityMap` — stores bidirectional neighbor adjacency with
scores
- Added `setAffinity(graph)` — ingests `/api/analytics/neighbor-graph`
edge data into O(1) Map lookups
- Added `getAffinity(pubkeyA, pubkeyB)` — returns affinity score between
two nodes (0 if not neighbors)
- Added `pickByAffinity(candidates, adjacentPubkey, anchor, ...)` —
picks best candidate: affinity-neighbor first (highest score), then
geo-distance fallback
- Modified forward and backward passes in `resolve()` to track the
previously-resolved pubkey and use `pickByAffinity` instead of raw
geo-sort

### `public/live.js`
- Added `fetchAffinityData()` — fetches `/api/analytics/neighbor-graph`
once and calls `HopResolver.setAffinity()`
- Added `startAffinityRefresh()` — refreshes affinity data every 60
seconds
- Both are called from `loadNodes()` after HopResolver is initialized

### `test-hop-resolver-affinity.js` (new)
- Affinity prefers neighbor candidate over geo-closest
- Cold start (no affinity data) falls back to geo-closest
- Null/undefined affinity doesn't crash
- Bidirectional score lookup
- Highest affinity score wins among multiple neighbors
- Unambiguous hops unaffected by affinity

## Performance

- API calls: 1 at load + 1 per 60s (no per-packet calls)
- Per-packet resolve: O(1) Map lookups, <0.5ms
- Memory: ~50KB for 2K-node graph

---------

Co-authored-by: you <you@example.com>
2026-04-03 16:32:53 -07:00
Kpa-clawbot 9a39198d92 fix: only count repeaters in hash collision analysis (#441) (#548)
Fixes #441

## Summary

Hash collision analysis was including ALL node types, inflating
collision counts with irrelevant data. Per MeshCore firmware analysis,
**only repeaters matter for collision analysis** — they're the only role
that forwards packets and appears in routing `path[]` arrays.

## Root Causes Fixed

1. **`hash_size==0` nodes counted in all buckets** — nodes with unknown
hash size were included via `cn.HashSize == bytes || cn.HashSize == 0`,
polluting every bucket
2. **Non-repeater roles included** — companions, rooms, sensors, and
observers were counted even though their hash collisions never cause
routing ambiguity

## Fix

Changed `computeHashCollisions()` filter from:
```go
// Before: include everything except companions
if cn.HashSize == bytes && cn.Role != "companion" {
```
To:
```go
// After: only include repeaters (per firmware analysis)
if cn.HashSize == bytes && cn.Role == "repeater" {
```

## Why only repeaters?

From [MeshCore firmware
analysis](https://github.com/Kpa-clawbot/CoreScope/issues/441#issuecomment-4185218547):
- Only repeaters override `allowPacketForward()` to return `true`
- Only repeaters append their hash to `path[]` during relay
- Companions, rooms, sensors, observers never forward packets
- Cross-role collisions are benign (companion silently drops, real
repeater still forwards)

## Tests
- `TestHashCollisionsOnlyRepeaters` — verifies companions, rooms,
sensors, and hash_size==0 nodes are all excluded

---------

Co-authored-by: you <you@example.com>
2026-04-03 14:23:13 -07:00
Kpa-clawbot 526ea8a1fc perf(live): chunk VCR replay packet processing to avoid UI freezes (#549)
## Summary

VCR replay functions (`vcrReplayFromTs`, `vcrRewind`,
`fetchNextReplayPage`) fetch up to 10K packets and process them all
synchronously on the main thread via `expandToBufferEntries`, causing
multi-second UI freezes — especially on mobile.

## Fix

- Added `expandToBufferEntriesAsync()` — processes packets in chunks of
200, yielding to the event loop via `setTimeout(0)` between chunks
- Updated all three VCR replay callers to use the async variant
- Kept the synchronous `expandToBufferEntries()` for backward
compatibility (tests, small datasets)
- Exposed `_liveExpandToBufferEntriesAsync` on window for test access

## Perf justification

- **Before:** 10K packets × ~2 observations = 20K+ objects created
synchronously, blocking the main thread for 1-3 seconds on mobile
- **After:** Same work split into chunks of 200 packets (~400 entries)
with event loop yields between chunks. Each chunk takes <5ms, keeping
the UI responsive (well under the 16ms frame budget)
- Chunk size of 200 is tunable via `VCR_CHUNK_SIZE`

## Tests

- Added regression test: sync expand correctness at scale (500 packets →
1000 entries)
- Added structural test: verifies `VCR_CHUNK_SIZE` exists and async
function yields via `setTimeout`
- All existing tests pass (`npm test`)

Fixes #395

---------

Co-authored-by: you <you@example.com>
2026-04-03 21:22:05 +00:00
Kpa-clawbot 8e42febc9c fix: virtual scroll height accounts for expanded group rows (#410) (#547)
## Summary

Fixes #410 — virtual scroll height miscalculation for expanded group
rows.

## Root Cause

When WebSocket messages add children to an already-expanded packet
group, `_rowCounts` becomes stale during the 200ms render debounce
window. Scroll events during this window call `renderVisibleRows()` with
stale row counts, causing wrong total height, spacer heights, and
visible range calculations.

## Changes

**public/packets.js:**
- Added `_rowCountsDirty` flag to track when row counts need
recomputation
- Added `_invalidateRowCounts()` — marks row counts as stale and clears
cumulative cache
- Added `_refreshRowCountsIfDirty()` — lazily recomputes `_rowCounts`
from `_displayPackets`
- Called `_invalidateRowCounts()` when WS handler adds children to
expanded groups (line ~402)
- Called `_refreshRowCountsIfDirty()` at top of `renderVisibleRows()`
before using row counts
- Reset `_rowCountsDirty` in all cleanup paths (destroy, empty display)

**test-packets.js:**
- Added 4 regression tests for `_invalidateRowCounts` /
`_refreshRowCountsIfDirty`

## Complexity

O(n) recomputation of `_rowCounts` when dirty (same as existing
`renderTableRows` path). Only triggers when WS modifies expanded group
children, which is infrequent relative to scroll events.

Co-authored-by: you <you@example.com>
2026-04-03 13:55:23 -07:00
Kpa-clawbot 59bff5462c fix: rate-limit cache invalidation to prevent 0% hit rate (#533) (#546)
## Summary

Fixes #533 — server cache hit rate always 0%.

## Root Cause

`invalidateCachesFor()` is called at the end of every
`IngestNewFromDB()` and `IngestNewObservations()` cycle (~2-5s). Since
new data arrives continuously, caches are cleared faster than any
analytics request can hit them, resulting in a permanent 0% cache hit
rate. The cache TTL (15s/60s) is irrelevant because entries are evicted
by invalidation long before they expire.

## Fix

Rate-limit cache invalidation with a 10-second cooldown:

- First call after cooldown goes through immediately
- Subsequent calls during cooldown accumulate dirty flags in
`pendingInv`
- Next call after cooldown merges pending + current flags and applies
them
- Eviction bypasses cooldown (data removal requires immediate clearing)

Analytics data may be at most ~10s stale, which is acceptable for a
dashboard.

## Changes

- **`store.go`**: Added `lastInvalidated`, `pendingInv`, `invCooldown`
fields. Refactored `invalidateCachesFor()` to rate-limit non-eviction
invalidation. Extracted `applyCacheInvalidation()` helper.
- **`cache_invalidation_test.go`**: Added 4 new tests:
- `TestInvalidationRateLimited` — verifies caches survive during
cooldown
  - `TestInvalidationCooldownAccumulatesFlags` — verifies flag merging
- `TestEvictionBypassesCooldown` — verifies eviction always clears
immediately
- `BenchmarkCacheHitDuringIngestion` — confirms 100% hit rate during
rapid ingestion (was 0%)

## Perf Proof

```
BenchmarkCacheHitDuringIngestion-16    3467889    1018 ns/op    100.0 hit%
```

Before: 0% hit rate under continuous ingestion. After: 100% hit rate
during cooldown periods.

Co-authored-by: you <you@example.com>
2026-04-03 13:53:58 -07:00
Kpa-clawbot 8c1cd8a9fe perf: track advert pubkeys incrementally, eliminate per-request JSON parsing (#360) (#544)
## Summary

`GetPerfStoreStats()` and `GetPerfStoreStatsTyped()` iterated **all**
ADVERT packets and called `json.Unmarshal` on each one — under a read
lock — on every `/api/perf` and `/api/health` request. With 5K+ adverts,
each health check triggered thousands of JSON parses.

## Fix

Added a refcounted `advertPubkeys map[string]int` to `PacketStore` that
tracks distinct pubkeys incrementally during `Load()`,
`IngestNewFromDB()`, and eviction. The perf/health handlers now just
read `len(s.advertPubkeys)` — O(1) with zero allocations.

## Benchmark Results (5K adverts, 200 distinct pubkeys)

| Method | ns/op | allocs/op |
|--------|-------|-----------|
| `GetPerfStoreStatsTyped` | **78** | **0** |
| `GetPerfStoreStats` | **2,565** | **9** |

Before this change, both methods performed O(N) JSON unmarshals per
call.

## Tests Added

- `TestAdvertPubkeyTracking` — verifies incremental tracking through
add/evict lifecycle
- `TestAdvertPubkeyPublicKeyField` — covers the `public_key` JSON field
variant
- `TestAdvertPubkeyNonAdvert` — ensures non-ADVERT packets don't affect
count
- `BenchmarkGetPerfStoreStats` — 5K adverts benchmark
- `BenchmarkGetPerfStoreStatsTyped` — 5K adverts benchmark

Fixes #360

---------

Co-authored-by: you <you@example.com>
2026-04-03 13:51:13 -07:00
Kpa-clawbot 29e8e37114 fix: mobile filter dropdown specificity prevents expansion (#534) (#541)
## Summary

Fixes #534 — mobile filter dropdown doesn't expand on packets page.

## Root Cause

CSS specificity battle in the mobile media query. The hide rule uses
`:not()` pseudo-classes which add specificity:

```css
/* Higher specificity due to :not() */
.filter-bar > *:not(.filter-toggle-btn):not(.col-toggle-wrap) { display: none; }

/* Lower specificity — loses even with .filters-expanded */
.filter-bar.filters-expanded > * { display: inline-flex; }
```

The JS toggle correctly adds/removes `.filters-expanded`, but the CSS
expanded rule could never win.

## Fix

Match the `:not()` selectors in the expanded rule so `.filters-expanded`
makes it strictly more specific:

```css
.filter-bar.filters-expanded > *:not(.filter-toggle-btn):not(.col-toggle-wrap) { display: inline-flex; }
```

Added a comment explaining the specificity dependency so future devs
don't repeat this.

## Tests

Added Playwright E2E test: mobile viewport (480×800), navigates to
packets page, clicks filter toggle, verifies filter inputs become
visible.

---------

Co-authored-by: you <you@example.com>
2026-04-03 13:50:10 -07:00
Kpa-clawbot 9b9f396af5 perf: replace O(n²) observation dedup with map-based O(n) (#355) (#543)
## Summary

Fixes #355 — replaces O(n²) observation dedup in `Load()`,
`IngestNewFromDB()`, and `IngestNewObservations()` with an O(1)
map-based lookup.

## Changes

- Added `obsKeys map[string]bool` field to `StoreTx` for O(1) dedup
keyed on `observerID + "|" + pathJSON`
- Replaced all 3 linear-scan dedup sites in `store.go` with map lookups
- Lazy-init `obsKeys` for transmissions created before this change (in
`IngestNewFromDB` and `IngestNewObservations`)
- Added regression test (`TestObsDedupCorrectness`) verifying dedup
correctness
- Added nil-map safety test (`TestObsDedupNilMapSafety`)
- Added benchmark comparing map vs linear scan

## Benchmark Results (ARM64, 16 cores)

| Observations | Map (O(1)) | Linear (O(n)) | Speedup |
|---|---|---|---|
| 10 | 34 ns/op | 41 ns/op | 1.2x |
| 50 | 34 ns/op | 186 ns/op | 5.5x |
| 100 | 34 ns/op | 361 ns/op | 10.6x |
| 500 | 34 ns/op | 4,903 ns/op | **146x** |

Map lookup is constant time regardless of observation count. The linear
scan degrades quadratically — at 500 observations per transmission
(realistic for popular packets seen by many observers), the old code is
146x slower per dedup check.

All existing tests pass.

---------

Co-authored-by: you <you@example.com>
2026-04-03 13:33:26 -07:00
Kpa-clawbot b472c8de30 perf: replace O(n²) selection sort with sort.Slice (#354) (#542)
## Summary

Fixes #354

Replaces the O(n²) selection sort in `sortedCopy()` with Go's built-in
`sort.Float64s()` (O(n log n)).

## Changes

- **`cmd/server/routes.go`**: Replaced manual nested-loop selection sort
with `sort.Float64s(cp)`
- **`cmd/server/helpers_test.go`**: Added regression test with
1000-element random input + benchmark

## Benchmark Results (ARM64)

```
BenchmarkSortedCopy/n=256     ~16μs/op    1 alloc
BenchmarkSortedCopy/n=1000    ~95μs/op    1 alloc
BenchmarkSortedCopy/n=10000   ~1.3ms/op   1 alloc
```

With the old O(n²) sort, n=10000 would take ~50ms+. The new
implementation scales as O(n log n).

## Testing

- All existing `TestSortedCopy` tests pass (unchanged behavior)
- New `TestSortedCopyLarge` validates correctness on 1000 random
elements
- `go test ./...` passes in `cmd/server`

Co-authored-by: you <you@example.com>
2026-04-03 13:11:59 -07:00
Kpa-clawbot 03e384bbc4 fix: null guard on pathHops prevents crash on ADVERT detail (#538) (#540)
## Summary

Fixes #538 — `null is not an object (evaluating 'pathHops.length')`
crash on ADVERT packet detail.

## Root Cause

`getParsedPath` caches its result as `p._parsedPath`. If another code
path (e.g., object spread, API response) sets `_parsedPath = null`, the
cache check (`!== undefined`) passes and returns `null` — causing
`.length` to crash.

Same pattern exists for `getParsedDecoded`.

## Changes

### `public/packet-helpers.js`
- `getParsedPath`: cached return now uses `|| []` to guard against null
cache
- `getParsedDecoded`: cached return now uses `|| {}` to guard against
null cache

### `public/packets.js`
- `renderDetail()` (line ~1440): defensive `|| []` / `|| {}` on
getParsedPath/getParsedDecoded calls
- `buildFlatRowHtml()` (line ~1103): same defensive guards

### `test-frontend-helpers.js`
- Added test: cached `_parsedPath = null` returns `[]`
- Added test: cached `_parsedDecoded = null` returns `{}`

## Testing

All 428 frontend helper tests pass. All 62 packet filter tests pass.

Co-authored-by: you <you@example.com>
2026-04-03 13:03:20 -07:00
Kpa-clawbot bf8c9e72ec fix: observer filter checks all observations in grouped mode (#537) (#539)
Fixes #537

## Problem
Observer filter in grouped mode only checked `p.observer_id` (the
primary observer), ignoring child observations. Grouped packets seen by
multiple observers would be hidden when filtering for a non-primary
observer.

## Fix
Two filter paths updated to also check `p._children`:

1. **Client-side display filter** (line ~1293): removed the
`!groupByHash` guard and added `_children` check so grouped packets are
included when any child observation matches
2. **WS real-time filter** (line ~360): added `_children` fallback check

The grouped row rendering (line ~1042) already correctly uses
`_observerFilterSet` for child filtering — no changes needed there.

## Tests
Added 5 tests in `test-frontend-helpers.js`:
- Grouped packet with matching child observer is shown
- Grouped packet with no matching observers is hidden  
- WS filter passes/rejects grouped packets correctly
- Source code assertions verifying both filter paths check `_children`

Co-authored-by: you <you@example.com>
2026-04-03 13:02:25 -07:00
Kpa-clawbot 48923db3d0 Add deep linking rule to AGENTS.md (#535)
Adds a rule to AGENTS.md requiring all new UI states to be
URL-addressable (deep-linkable). Part of #536.

Co-authored-by: you <you@example.com>
2026-04-03 13:01:31 -07:00
efiten 709e5a4776 fix: observer filter drops groups in grouped packets view (#464) (#531)
## Summary

- When `groupByHash=true`, each group only carries its representative
(best-path) `observer_id`. The client-side filter was checking only that
field, silently dropping groups that were seen by the selected observer
but had a different representative.
- `loadPackets` now passes the `observer` param to the server so
`filterPackets`/`buildGroupedWhere` do the correct "any observation
matches" check.
- Client-side observer filter in `renderTableRows` is skipped for
grouped mode (server already filtered correctly).
- Both `db.go` and `store.go` observer filtering extended to support
comma-separated IDs (multi-select UI).

## Test plan

- [ ] Set an observer filter on the Packets screen with grouping enabled
— all groups that have **any** observation from the selected observer(s)
should appear, not just groups where that observer is the representative
- [ ] Multi-select two observers — groups seen by either should appear
- [ ] Toggle to flat (ungrouped) mode — per-observation filter still
works correctly
- [ ] Existing grouped packets tests pass: `cd cmd/server && go test
./...`

Fixes #464

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: you <you@example.com>
2026-04-03 09:22:37 -07:00
you 9099154514 docs: add v3.4 release notes 2026-04-03 08:26:05 +00:00
Kpa-clawbot 924caaa680 fix: render both steps AND FAQ on home page (#525) (#529)
Fixes #525

The `checklist()` function in `home.js` treated steps and FAQ/checklist
as mutually exclusive — if `homeCfg.checklist` existed, steps were
skipped entirely. Adding a single FAQ via the customizer made all intro
steps disappear.

Now renders steps first, then FAQ below with a ' FAQ' header. Falls
back to Bay Area hardcoded defaults only when neither exists.

---------

Co-authored-by: you <you@example.com>
2026-04-03 01:19:42 -07:00
Kpa-clawbot ca95fc46aa fix: neighbor UI — show neighbors crash, dark mode contrast (#523) (#527)
## Summary

Part of #523 — fixes bugs 5 and 7 (bug 6 was a duplicate of bug 7).

### Bug 5: Show Neighbors button throws `window._mapSelectRefNode is not
a function`

**Root cause:** Map popup HTML used inline `onclick` calling
`window._mapSelectRefNode`, which was deleted on SPA page destroy. If a
popup persisted after navigation, clicks would throw.

**Fix:** Replaced inline `onclick` with event delegation. A
document-level click handler catches all `[data-show-neighbors]` clicks
and calls `selectReferenceNode` directly. The global
`window._mapSelectRefNode` is still exposed for existing Playwright
tests but is no longer relied upon by the UI.

### Bug 7: Blue text on dark blue background (dark mode contrast)

**Root cause:** Neighbor table cells inside `.node-detail-section` /
`.node-full-card` inherited accent/link color instead of using
`var(--text)`, making text unreadable in dark mode.

**Fix:** Added explicit `color: var(--text)` on `.node-detail-section
.data-table td` and `.node-full-card .data-table td`. Only `<a>` tags
within those cells retain `color: var(--accent)`.

### Files changed
- `public/map.js` — event delegation for Show Neighbors
- `public/style.css` — contrast fix for neighbor table cells

---------

Co-authored-by: you <you@example.com>
2026-04-03 00:49:17 -07:00
Kpa-clawbot 54fab0551e fix: add home defaults to server theme config (#525) (#526)
## Summary

Fixes #525 — Customizer v2 home section shows empty fields and adding
FAQ kills steps.

## Root Cause

Server returned `home: null` from `/api/config/theme` when no home
config existed in config.json or theme.json. The customizer had no
built-in defaults, so all home fields appeared empty. When a user added
a single override (e.g. FAQ), `computeEffective` started from `home:
null`, created `home: {}`, and only applied the user's override — wiping
steps and everything else.

## Fix

### Server-side (primary)
In `handleConfigTheme()`, replaced the conditional `home` assignment
with `mergeMap` using built-in defaults matching what `home.js`
hardcodes:
- `heroTitle`: "CoreScope"
- `heroSubtitle`: "Real-time MeshCore LoRa mesh network analyzer"
- `steps`: 4 default getting-started steps
- `footerLinks`: Packets + Network Map links

Config/theme overrides merge on top, so customization still works.

### Client-side (defense-in-depth)
Added `DEFAULT_HOME` constant in `customize-v2.js`. `computeEffective()`
now falls back to these defaults when server returns `home: null`,
ensuring the customizer works even without server defaults.

## Tests
- **Go**: `TestConfigThemeHomeDefaults` — verifies `/api/config/theme`
returns non-null home with heroTitle, steps, footerLinks when no config
is set
- **JS**: Two new tests in `test-frontend-helpers.js` — verifies
`computeEffective` provides defaults when home is null, and that user
overrides merge correctly with defaults

Co-authored-by: you <you@example.com>
2026-04-03 00:31:03 -07:00
Kpa-clawbot 0e1beac52f fix: neighbor affinity graph empty results + performance + accessibility (#523) (#524)
## Summary

Fixes the neighbor affinity graph returning empty results despite
abundant ADVERT data in the store.

**Root cause:** `extractFromNode()` in `neighbor_graph.go` only checked
for `"from_node"` and `"from"` fields in the decoded JSON, but real
ADVERT packets store the originator public key as `"pubKey"`. This meant
`fromNode` was always empty, so:
- Zero-hop edges (originator↔observer) were never created
- Originator↔path[0] edges were never created
- Only observer↔path[last] edges could be created (and only for
non-empty paths)

**Fix:** Check `"pubKey"` first in `extractFromNode()`, then fall
through to `"from_node"` and `"from"` for other packet types.

## Bugs Fixed

| Bug | Issue | Fix |
|-----|-------|-----|
| Empty graph results | #522 | `extractFromNode()` now reads `pubKey`
field from ADVERTs |
| 3-4s response time | #523 comment | Graph was rebuilding correctly
with 60s TTL cache — the slow response was due to iterating all packets
finding zero matches. With edges now being found, the cache works as
designed. |
| Incomplete visualization | #523 comment | Downstream of bug 1+2 —
fixed by fixing the builder |
| Accessibility | #523 comment | Added text-based neighbor list, dynamic
aria-label, keyboard focus CSS, dashed lines for ambiguous edges,
confidence symbols |

## Changes

- **`cmd/server/neighbor_graph.go`** — Fixed `extractFromNode()` to
check `pubKey` field (real ADVERT format)
- **`cmd/server/neighbor_graph_test.go`** — Added 2 new tests:
`TestBuildNeighborGraph_AdvertPubKeyField` (real ADVERT format) and
`TestBuildNeighborGraph_OneByteHashPrefixes` (1-byte prefix collision
scenario)
- **`public/analytics.js`** — Added accessible text-based neighbor list,
dynamic aria-label, dashed line pattern for ambiguous edges
- **`public/style.css`** — Added `:focus-visible` keyboard focus
indicator for canvas

## Testing

All Go tests pass (`go test ./... -count=1`). New tests verify the fix
prevents regression.

Fixes #523, Fixes #522

---------

Co-authored-by: you <you@example.com>
2026-04-03 00:30:39 -07:00
Kpa-clawbot 34489e0446 fix: customizer v2 — phantom overrides, missing defaults, stale dark mode (#518) (#520)
Fixes #518, Fixes #514, Fixes #515, Fixes #516

## Summary

Fixes all customizer v2 bugs from the consolidated tracker (#518). Both
server and client changes.

## Server Changes (`routes.go`)

- **typeColors defaults** — added all 10 type color defaults matching
`roles.js` `TYPE_COLORS`. Previously returned `{}`, causing all type
colors to render as black.
- **themeDark defaults** — added 22 dark mode color defaults matching
the Default preset. Previously returned `{}`, causing dark mode to have
no server-side defaults.

## Client Changes (`customize-v2.js`)

- [x] **P0: Phantom override cleanup on init** — new
`_cleanPhantomOverrides()` runs on startup, scanning
`cs-theme-overrides` and removing any values that match server defaults
(arrays via `JSON.stringify`, scalars via `===`).
- [x] **P1: `setOverride` auto-prunes matching defaults** — after
debounced write, iterates the delta and removes any key whose value
matches the server default. Prevents phantom overrides from
accumulating.
- [x] **P1: `_countOverrides` counts only real diffs** — now iterates
keys and calls `_isOverridden()` instead of blindly counting
`Object.keys().length`. Badge count reflects actual overrides only.
- [x] **P1: `_isOverridden` handles arrays/objects** — uses
`JSON.stringify` comparison for non-scalar values (home.steps,
home.checklist, etc.).
- [x] **P1: Type color fallback** — `_renderNodes()` falls back to
`window.TYPE_COLORS` when effective typeColors are empty, preventing
black color swatches.
- [x] **P1: Dark/light toggle re-renders panel** — MutationObserver on
`data-theme` now calls `_refreshPanel()` when panel is open, so
switching modes updates the Theme tab immediately.

## Tests

6 new unit tests added to `test-customizer-v2.js`:
- Phantom scalar overrides cleaned on init
- Phantom array overrides cleaned on init
- Real overrides preserved after cleanup
- `isOverridden` handles matching arrays (returns false)
- `isOverridden` handles differing arrays (returns true)
- `setOverride` prunes value matching server default

All 48 tests pass. Go tests pass.

---------

Co-authored-by: you <you@example.com>
2026-04-03 00:04:33 -07:00
Kpa-clawbot 58f791266d feat: affinity debugging tools (#482) — milestone 6 (#521)
## Summary

Milestone 6 of #482: Observability & Debugging tools for the neighbor
affinity system.

These tools exist because someone will need them at 3 AM when "Show
Neighbors is showing the wrong node for C0DE" and they have 5 minutes to
diagnose it.

## Changes

### 1. Debug API — `GET /api/debug/affinity`
- Full graph state dump: all edges with weights, observation counts,
last-seen timestamps
- Per-prefix resolution log with disambiguation reasoning (Jaccard
scores, ratios, thresholds)
- Query params: `?prefix=C0DE` filter to specific prefix,
`?node=<pubkey>` for specific node's edges
- Protected by API key (same auth as `/api/admin/prune`)
- Response includes: edge count, node count, cache age, last rebuild
time

### 2. Debug Overlay on Map
- Toggle-able checkbox "🔍 Affinity Debug" in map controls
- Draws lines between nodes showing affinity edges with color coding:
  - Green = high confidence (score ≥ 0.6)
  - Yellow = medium (0.3–0.6)
  - Red = ambiguous (< 0.3)
- Line thickness proportional to weight, dashed for ambiguous
- Unresolved prefixes shown as  markers
- Click edge → popup with observation count, last seen, score, observers
- Hidden behind `debugAffinity` config flag or
`localStorage.setItem('meshcore-affinity-debug', 'true')`

### 3. Per-Node Debug Panel
- Expandable "🔍 Affinity Debug" section in node detail page (collapsed
by default)
- Shows: neighbor edges table with scores, prefix resolutions with
reasoning trace
- Candidates table with Jaccard scores, highlighting the chosen
candidate
- Graph-level stats summary

### 4. Server-Side Structured Logging
- Integrated into `disambiguate()` — logs every resolution decision
during graph build
- Format: `[affinity] resolve C0DE: c0dedad4 score=47 Jaccard=0.82 vs
c0dedad9 score=3 Jaccard=0.11 → neighbor_affinity (ratio 15.7×)`
- Logs ambiguous decisions: `scores too close (12 vs 9, ratio 1.3×) →
ambiguous`
- Gated by `debugAffinity` config flag

### 5. Dashboard Stats Widget
- Added to analytics overview tab when debug mode is enabled
- Metrics: total edges/nodes, resolved/ambiguous counts (%), avg
confidence, cold-start coverage, cache age, last rebuild

## Files Changed
- `cmd/server/neighbor_debug.go` — new: debug API handler, resolution
builder, cold-start coverage
- `cmd/server/neighbor_debug_test.go` — new: 7 tests for debug API
- `cmd/server/neighbor_graph.go` — added structured logging to
disambiguate(), `logFn` field, `BuildFromStoreWithLog`
- `cmd/server/neighbor_api.go` — pass debug flag through
`BuildFromStoreWithLog`
- `cmd/server/config.go` — added `DebugAffinity` config field
- `cmd/server/routes.go` — registered `/api/debug/affinity` route,
exposed `debugAffinity` in client config
- `cmd/server/types.go` — added `DebugAffinity` to
`ClientConfigResponse`
- `public/map.js` — affinity debug overlay layer with edge visualization
- `public/nodes.js` — per-node affinity debug panel
- `public/analytics.js` — dashboard stats widget
- `test-e2e-playwright.js` — 3 Playwright tests for debug UI

## Tests
-  7 Go unit tests (API shape, prefix/node filters, auth, structured
logging, cold-start coverage)
-  3 Playwright E2E tests (overlay checkbox, toggle without crash,
panel expansion)
-  All existing tests pass (`go test ./cmd/server/... -count=1`)

Part of #482

---------

Co-authored-by: you <you@example.com>
2026-04-02 23:45:03 -07:00
Kpa-clawbot 9b1b82f29b fix: remove merge conflict marker from test-e2e-playwright.js (#519)
Removes a stale `<<<<<<< HEAD` conflict marker that was accidentally
left in during the PR #510 rebase. This breaks Playwright E2E tests in
CI.

One-line fix — line 1311 deletion.

Co-authored-by: you <you@example.com>
2026-04-02 22:41:30 -07:00
Kpa-clawbot 943eb69937 feat: neighbors section in node detail page (#482) — milestone 5 (#510)
## Summary

Add a "Neighbors" section to the node detail page, showing first-hop
neighbor relationships derived from the neighbor affinity graph (M2
API).

Part of #482 — Milestone 5 per
[spec](https://github.com/Kpa-clawbot/CoreScope/blob/spec/482-neighbor-affinity/docs/specs/neighbor-affinity-graph.md).

## What's Added

### Full-screen detail view (`#/nodes/{pubkey}`)
- New `node-full-card` section between "Heard By" and "Paths Through
This Node"
- Table with columns: **Neighbor** (linked), **Role** (badge),
**Score**, **Obs**, **Last Seen**, **Conf** (confidence indicator)
- Confidence indicators per spec:
  - 🟢 HIGH: auto-resolved, ≥3 observations, score ≥ 0.5
  - 🟡 MEDIUM: 2+ observations
  - 🔴 LOW: single observation
  - ⚠️ AMBIGUOUS: multiple candidates
- Click neighbor name → navigate to their detail page
- 📍 Map button per resolved neighbor row

### Condensed panel view (right panel)
- Shows top 5 neighbors only
- "View all N neighbors →" link navigates to full detail page with
`?section=node-neighbors`

### Deep linking
- `?section=node-neighbors` auto-scrolls to the neighbors section (uses
existing scroll mechanism)

### Data fetching
- `GET /api/nodes/{pubkey}/neighbors` via existing `api()` helper
- Cached per-node for 5 minutes (panel lifetime)
- Loading spinner, empty state, error state

### States
- **Loading**: spinner with "Loading neighbors…"
- **Empty**: "No neighbor data available yet. Neighbor relationships are
built from observed packet paths over time."
- **Error**: "Could not load neighbor data"

## Tests
- 2 new Playwright E2E tests:
  1. Section exists with correct table columns (or empty state)
  2. Loading spinner visible during fetch

## Files Changed
- `public/nodes.js` — neighbor section rendering + data fetching helpers
- `test-e2e-playwright.js` — 2 new E2E tests

---------

Co-authored-by: you <you@example.com>
2026-04-03 05:36:47 +00:00
Kpa-clawbot 15634362c9 feat: neighbor graph visualization in analytics (#482) — milestone 7 (#513)
## Summary

Adds a **Neighbor Graph** tab to the Analytics page — an interactive
force-directed graph visualization of the mesh network's neighbor
affinity data.

Part of #482 (Milestone 7 — Analytics Graph Visualization)

## What's New

### Neighbor Graph Tab
- New "Neighbor Graph" tab in the analytics tab bar
- Force-directed graph layout using HTML5 Canvas (vanilla JS, no
external libs)
- Nodes rendered as circles, colored by role using existing
`ROLE_COLORS`
- Edges as lines with thickness proportional to affinity score
- Ambiguous edges highlighted in yellow

### Interactions
- **Click node** → navigates to node detail page (`#/nodes/{pubkey}`)
- **Hover node** → tooltip showing name, role, neighbor count
- **Drag nodes** → rearrange layout interactively
- **Mouse wheel** → zoom in/out (towards cursor position)
- **Drag background** → pan the view

### Filters
- **Role checkboxes** — toggle repeater, companion, room, sensor
visibility
- **Minimum score slider** — filter out weak edges (0.00–1.00)
- **Confidence filter** — show all / high confidence only / hide
ambiguous

### Stats Summary
Displays above the graph: total nodes, total edges, average score,
resolved %, ambiguous count

### Data Source
Uses `GET /api/analytics/neighbor-graph` endpoint from M2, with region
filtering via the shared RegionFilter component.

## Performance
- Canvas-based rendering (not SVG) for performance with large graphs
- Force simulation uses `requestAnimationFrame` with cooling/dampening —
stops iterating when layout stabilizes
- O(n²) repulsion is acceptable for typical mesh sizes (~500 nodes); for
larger meshes, a Barnes-Hut approximation could be added later
- Animation frame is properly cleaned up on page destroy

## Tests
- Updated tab count assertion (≥10 tabs)
- New Playwright test: tab loads, canvas renders, stats shown (≥3 stat
cards)
- New Playwright test: filter changes update stats

## Files Changed
- `public/analytics.js` — new tab + full graph visualization
implementation
- `test-e2e-playwright.js` — 2 new tests + updated assertion

---------

Co-authored-by: you <you@example.com>
2026-04-02 22:35:28 -07:00
Kpa-clawbot 5151030697 feat: affinity-aware hop resolution (#482) — milestone 4 (#511)
## Summary

Milestone 4 of #482: adds affinity-aware hop resolution to improve
disambiguation accuracy across all hop resolution in the app.

### What changed

**Backend — `prefixMap.resolveWithContext()` (store.go)**

New method that applies a 4-tier disambiguation priority when multiple
nodes match a hop prefix:

| Priority | Strategy | When it wins |
|----------|----------|-------------|
| 1 | **Affinity graph score** | Neighbor graph has data, score ratio ≥
3× runner-up |
| 2 | **Geographic proximity** | Context nodes have GPS, pick closest
candidate |
| 3 | **GPS preference** | At least one candidate has coordinates |
| 4 | **First match** | No signal — current naive fallback |

The existing `resolve()` method is unchanged for backward compatibility.
New callers that have context (originator, observer, adjacent hops) can
use `resolveWithContext()` for better results.

**API — `handleResolveHops` (routes.go)**

Enhanced `/api/resolve-hops` endpoint:
- New query params: `from_node`, `observer` — provide context for
affinity scoring
- New response fields on `HopCandidate`: `affinityScore` (float,
0.0–1.0)
- New response fields on `HopResolution`: `bestCandidate` (pubkey when
confident), `confidence` (one of `unique_prefix`, `neighbor_affinity`,
`ambiguous`)
- Backward compatible: without context params, behavior is identical to
before (just adds `confidence` field)

**Types (types.go)**
- `HopCandidate.AffinityScore *float64`
- `HopResolution.BestCandidate *string`
- `HopResolution.Confidence string`

### Tests

- 7 unit tests for `resolveWithContext` covering all 4 priority tiers +
edge cases
- 2 unit tests for `geoDistApprox`
- 4 API tests for enhanced `/api/resolve-hops` response shape
- All existing tests pass (no regressions)

### Impact

This improves ALL hop resolution across the app — analytics, route
display, subpath analysis, and any future feature that resolves hop
prefixes. The affinity graph (from M1/M2) now feeds directly into
disambiguation decisions.

Part of #482

---------

Co-authored-by: you <you@example.com>
2026-04-02 22:28:07 -07:00
52 changed files with 8851 additions and 791 deletions
+2 -2
View File
@@ -236,7 +236,7 @@ jobs:
build:
name: "🏗️ Build Docker Image"
needs: [e2e-test]
runs-on: [self-hosted, Linux]
runs-on: [self-hosted, meshcore-vm]
steps:
- name: Checkout code
uses: actions/checkout@v5
@@ -271,7 +271,7 @@ jobs:
name: "🚀 Deploy Staging"
if: github.event_name == 'push'
needs: [build]
runs-on: [self-hosted, Linux]
runs-on: [self-hosted, meshcore-vm]
steps:
- name: Checkout code
uses: actions/checkout@v5
+6
View File
@@ -362,6 +362,12 @@ One logical change per commit. Each commit is deployable. Each commit has its te
- Tests: `test-{feature}.js` in repo root
- No build step, no transpilation — write ES2020 for server, ES5/6 for frontend (broad browser support)
### Deep Linking
All new UI states that a user might want to share or bookmark MUST be reflected in the URL hash.
This includes: tabs, filters, selected items, view modes. Use query parameters on the hash
(e.g., `#/packets?observer=ABC&timeRange=24h`) for filter state.
Existing patterns: `#/nodes/{pubkey}?section=node-neighbors`, `#/analytics?tab=collisions`, `#/packets/{hash}`.
## What NOT to Do
- **Don't check in private information** — no names, API keys, tokens, passwords, IP addresses, personal data, or any identifying information. This is a PUBLIC repo.
- Don't add npm dependencies without asking
+5
View File
@@ -0,0 +1,5 @@
module github.com/corescope/channel-discover
go 1.22
require github.com/mattn/go-sqlite3 v1.14.24
+2
View File
@@ -0,0 +1,2 @@
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+519
View File
@@ -0,0 +1,519 @@
package main
import (
"crypto/aes"
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/binary"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"strings"
"time"
"unicode/utf8"
_ "github.com/mattn/go-sqlite3"
)
// grpTxtPayload is the decoded_json shape for GRP_TXT packets.
type grpTxtPayload struct {
Type string `json:"type"`
ChannelHash int `json:"channelHash"`
ChannelHashHex string `json:"channelHashHex"`
DecryptionStatus string `json:"decryptionStatus"`
MAC string `json:"mac"`
EncryptedData string `json:"encryptedData"`
}
// undecryptedPacket holds a GRP_TXT packet that failed decryption.
type undecryptedPacket struct {
ID int
Hash string
ChannelHash byte
MAC string
EncryptedData string
}
// discoveredChannel is a confirmed channel discovery result.
type discoveredChannel struct {
Name string `json:"name"`
Key string `json:"key"`
ChannelHash string `json:"channelHash"`
PacketsMatched int `json:"packetsMatched"`
SampleMessages []sampleMessage `json:"sampleMessages"`
}
type sampleMessage struct {
Sender string `json:"sender,omitempty"`
Text string `json:"text"`
Timestamp string `json:"timestamp"`
}
// deriveChannelKey derives an AES-128 key from a hashtag channel name.
// key = SHA256(name)[:16]
func deriveChannelKey(name string) []byte {
h := sha256.Sum256([]byte(name))
return h[:16]
}
// channelHashFromKey computes the 1-byte channel hash from a 16-byte key.
// channelHash = SHA256(key)[0]
func channelHashFromKey(key []byte) byte {
h := sha256.Sum256(key)
return h[0]
}
// tryDecrypt attempts to decrypt ciphertext with given key and MAC.
// Returns (sender, message, timestamp, ok).
func tryDecrypt(ciphertextHex, macHex string, key []byte) (string, string, uint32, bool) {
macBytes, err := hex.DecodeString(macHex)
if err != nil || len(macBytes) != 2 {
return "", "", 0, false
}
ciphertext, err := hex.DecodeString(ciphertextHex)
if err != nil || len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 {
return "", "", 0, false
}
// HMAC-SHA256 verification: secret = key + 16 zero bytes
secret := make([]byte, 32)
copy(secret, key)
h := hmac.New(sha256.New, secret)
h.Write(ciphertext)
mac := h.Sum(nil)
if mac[0] != macBytes[0] || mac[1] != macBytes[1] {
return "", "", 0, false
}
// AES-128-ECB decrypt
block, err := aes.NewCipher(key)
if err != nil {
return "", "", 0, false
}
plaintext := make([]byte, len(ciphertext))
for i := 0; i < len(ciphertext); i += aes.BlockSize {
block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
}
if len(plaintext) < 5 {
return "", "", 0, false
}
timestamp := binary.LittleEndian.Uint32(plaintext[0:4])
// flags := plaintext[4]
msg := string(plaintext[5:])
if idx := strings.IndexByte(msg, 0); idx >= 0 {
msg = msg[:idx]
}
// Validate: must be printable UTF-8
if !utf8.ValidString(msg) {
return "", "", 0, false
}
nonPrintable := 0
for _, r := range msg {
if r < 0x20 && r != '\n' && r != '\t' {
nonPrintable++
} else if r == utf8.RuneError {
nonPrintable++
}
}
if nonPrintable > 2 {
return "", "", 0, false
}
// Parse "sender: message"
sender := ""
text := msg
if idx := strings.Index(msg, ": "); idx > 0 && idx < 50 {
potential := msg[:idx]
if !strings.ContainsAny(potential, ":[]") {
sender = potential
text = msg[idx+2:]
}
}
return sender, text, timestamp, true
}
// loadPackets extracts undecrypted GRP_TXT packets from the DB.
func loadPackets(db *sql.DB) ([]undecryptedPacket, error) {
rows, err := db.Query(`
SELECT id, hash, decoded_json
FROM transmissions
WHERE payload_type = 5 AND decoded_json IS NOT NULL
`)
if err != nil {
return nil, err
}
defer rows.Close()
var packets []undecryptedPacket
for rows.Next() {
var id int
var hash, djson string
if err := rows.Scan(&id, &hash, &djson); err != nil {
continue
}
var p grpTxtPayload
if err := json.Unmarshal([]byte(djson), &p); err != nil {
continue
}
// Include both decryption_failed and no_key packets
if p.DecryptionStatus != "decrypted" && p.EncryptedData != "" && p.MAC != "" {
packets = append(packets, undecryptedPacket{
ID: id,
Hash: hash,
ChannelHash: byte(p.ChannelHash),
MAC: p.MAC,
EncryptedData: p.EncryptedData,
})
}
}
return packets, rows.Err()
}
// loadWordlist reads a file with one word per line.
func loadWordlist(path string) ([]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var words []string
for _, line := range strings.Split(string(data), "\n") {
w := strings.TrimSpace(line)
if w != "" && !strings.HasPrefix(w, "#") {
words = append(words, w)
}
}
return words, nil
}
// defaultWordlist returns a built-in list of common channel name candidates.
func defaultWordlist() []string {
return []string{
// Common mesh/radio terms
"test", "testing", "general", "chat", "local", "help", "emergency",
"net", "repeater", "mesh", "meshcore", "lora", "radio", "ham",
"hf", "vhf", "uhf", "simplex", "duplex", "packet", "digital",
"analog", "beacon", "relay", "node", "base", "mobile", "portable",
"antenna", "tower", "signal", "frequency", "channel", "band",
"monitor", "scanner", "wx", "weather", "alert", "warning",
"ares", "races", "emcomm", "skywarn", "cert", "fema",
"sos", "mayday", "rescue", "search", "fire", "medical",
"police", "sheriff", "ems", "dispatch",
// Common words
"hello", "world", "admin", "default", "public", "private",
"open", "closed", "secure", "secret", "password", "key",
"group", "team", "family", "friends", "club", "community",
"network", "system", "server", "client", "device",
"home", "office", "work", "school", "park", "trail",
"mountain", "valley", "river", "lake", "ocean", "beach",
"forest", "desert", "island", "bridge", "road", "highway",
"north", "south", "east", "west", "central", "downtown",
"urban", "rural", "suburban", "metro",
// Tech/hacker terms
"hack", "hacker", "cyber", "crypto", "bitcoin", "blockchain",
"linux", "unix", "windows", "mac", "android", "ios",
"wifi", "bluetooth", "zigbee", "zwave", "mqtt", "iot",
"sensor", "gps", "tracker", "ping", "pong", "echo",
"debug", "dev", "prod", "staging", "beta", "alpha",
"demo", "sample", "example", "foo", "bar", "baz",
// US cities
"seattle", "portland", "sanfrancisco", "losangeles", "sandiego",
"denver", "phoenix", "dallas", "houston", "austin", "chicago",
"newyork", "boston", "miami", "atlanta", "nashville",
"detroit", "minneapolis", "stlouis", "kansascity", "omaha",
"saltlakecity", "lasvegas", "albuquerque", "tucson", "reno",
"boise", "spokane", "tacoma", "eugene", "bend", "olympia",
"sacramento", "oakland", "sanjose", "fresno", "bakersfield",
"anchorage", "honolulu", "fairbanks", "juneau",
// PNW / Cascadia specific
"cascadia", "pnw", "pacific", "northwest", "puget", "sound",
"rainier", "hood", "helens", "baker", "olympic", "cascade",
"columbia", "willamette", "snake", "fraser", "skagit",
"bellingham", "everett", "redmond", "bellevue", "kirkland",
"issaquah", "sammamish", "mercer", "whidbey", "orcas",
"sanjuan", "lopez", "vashon", "bainbridge", "camano",
"corvallis", "salem", "medford", "astoria", "cannon",
"victoria", "vancouver", "whistler", "nanaimo", "kelowna",
// US states
"alabama", "alaska", "arizona", "arkansas", "california",
"colorado", "connecticut", "delaware", "florida", "georgia",
"hawaii", "idaho", "illinois", "indiana", "iowa",
"kansas", "kentucky", "louisiana", "maine", "maryland",
"massachusetts", "michigan", "minnesota", "mississippi", "missouri",
"montana", "nebraska", "nevada", "newhampshire", "newjersey",
"newmexico", "newyork", "northcarolina", "northdakota", "ohio",
"oklahoma", "oregon", "pennsylvania", "rhodeisland", "southcarolina",
"southdakota", "tennessee", "texas", "utah", "vermont",
"virginia", "washington", "westvirginia", "wisconsin", "wyoming",
// Numbers and simple patterns
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10",
"42", "69", "100", "123", "420", "666", "911", "1234",
"chan1", "chan2", "chan3", "ch1", "ch2", "ch3",
"group1", "group2", "group3", "grp1", "grp2", "grp3",
"net1", "net2", "net3", "mesh1", "mesh2", "mesh3",
// Call sign prefixes
"w", "k", "n", "wa", "wb", "wc", "wd", "ka", "kb", "kc", "kd",
"ke", "kf", "kg", "ki", "kj", "kk", "kl", "km", "kn", "ko",
"kp", "kq", "kr", "ks", "kt", "ku", "kv", "kw", "kx", "ky", "kz",
// Outdoor/prepper
"prepper", "survival", "offgrid", "bugout", "shtf", "shtshtf",
"camping", "hiking", "hunting", "fishing", "climbing",
"backpacking", "overlanding", "jeep", "offroad", "4x4",
"bushcraft", "homestead", "farm", "ranch", "garden",
// Events/organizations
"defcon", "hamfest", "fieldday", "arrl", "amsat", "aprs",
"winlink", "vara", "js8", "ft8", "psk31", "sstv",
"dmr", "dstar", "fusion", "p25", "nxdn", "tetra",
"meshtastic", "gotenna", "baofeng", "yaesu", "icom", "kenwood",
"elecraft", "flexradio",
// Misc common
"love", "peace", "freedom", "liberty", "justice", "truth",
"power", "energy", "solar", "wind", "water", "earth",
"space", "moon", "mars", "stars", "galaxy", "universe",
"cats", "dogs", "birds", "fish", "wolves", "bears", "eagles",
"coffee", "beer", "wine", "pizza", "taco", "burrito",
"music", "rock", "jazz", "blues", "country", "metal",
"game", "play", "fun", "cool", "awesome", "epic",
"nostr", "fedi", "mastodon", "matrix", "signal", "telegram",
// Short common words that might be channels
"go", "run", "fly", "sky", "sun", "fog", "ice", "hot", "cold",
"new", "old", "big", "top", "low", "all", "one", "two", "ten",
"red", "blue", "green", "black", "white", "gold", "grey", "gray",
"oak", "elm", "pine", "fir", "ash", "bay", "cove", "cape",
"port", "dock", "pier", "reef", "wave", "surf", "tide", "sand",
}
}
func main() {
dbPath := flag.String("db", "", "Path to CoreScope SQLite database")
wordlistPath := flag.String("wordlist", "", "Path to custom wordlist file (one word per line)")
singleName := flag.String("name", "", "Test a single channel name (e.g. '#test')")
verbose := flag.Bool("verbose", false, "Show progress and timing details")
jsonOutput := flag.Bool("json", false, "Output results as JSON")
maxSamples := flag.Int("samples", 3, "Max sample messages per discovered channel")
flag.Parse()
if *dbPath == "" {
fmt.Fprintln(os.Stderr, "Usage: channel-discover -db <path-to-db> [options]")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Options:")
flag.PrintDefaults()
os.Exit(1)
}
db, err := sql.Open("sqlite3", *dbPath+"?mode=ro")
if err != nil {
log.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Load undecrypted packets
packets, err := loadPackets(db)
if err != nil {
log.Fatalf("Failed to load packets: %v", err)
}
if len(packets) == 0 {
fmt.Println("No undecrypted GRP_TXT packets found in database.")
return
}
// Group packets by channelHash
byHash := make(map[byte][]undecryptedPacket)
for _, p := range packets {
byHash[p.ChannelHash] = append(byHash[p.ChannelHash], p)
}
if *verbose {
fmt.Printf("Found %d undecrypted GRP_TXT packets across %d unique channel hashes\n",
len(packets), len(byHash))
for h, pkts := range byHash {
fmt.Printf(" channelHash 0x%02X: %d packets\n", h, len(pkts))
}
fmt.Println()
}
// Build candidate list
var candidates []string
if *singleName != "" {
name := *singleName
if !strings.HasPrefix(name, "#") {
name = "#" + name
}
candidates = []string{name}
} else {
// Start with default wordlist
words := defaultWordlist()
// Add custom wordlist if provided
if *wordlistPath != "" {
custom, err := loadWordlist(*wordlistPath)
if err != nil {
log.Fatalf("Failed to load wordlist: %v", err)
}
words = append(words, custom...)
if *verbose {
fmt.Printf("Loaded %d words from custom wordlist\n", len(custom))
}
}
// Generate candidates: each word as "#word"
seen := make(map[string]bool)
for _, w := range words {
w = strings.ToLower(strings.TrimSpace(w))
if w == "" {
continue
}
// Try with # prefix (standard hashtag channel)
name := "#" + w
if !seen[name] {
candidates = append(candidates, name)
seen[name] = true
}
}
if *verbose {
fmt.Printf("Generated %d candidate channel names\n\n", len(candidates))
}
}
// Precompute candidate keys and hashes, filter by matching channelHash
type candidate struct {
Name string
Key []byte
ChannelHash byte
}
var matched []candidate
start := time.Now()
for _, name := range candidates {
key := deriveChannelKey(name)
ch := channelHashFromKey(key)
if _, ok := byHash[ch]; ok {
matched = append(matched, candidate{Name: name, Key: key, ChannelHash: ch})
}
}
if *verbose {
fmt.Printf("Hash precompute: %d candidates → %d hash matches (%.1f ms)\n",
len(candidates), len(matched), float64(time.Since(start).Microseconds())/1000)
}
// Attempt decryption for each matched candidate
var discovered []discoveredChannel
decryptAttempts := 0
for _, c := range matched {
pkts := byHash[c.ChannelHash]
var samples []sampleMessage
decrypted := 0
for _, pkt := range pkts {
if len(pkt.EncryptedData) < 10 {
continue
}
decryptAttempts++
sender, text, ts, ok := tryDecrypt(pkt.EncryptedData, pkt.MAC, c.Key)
if ok {
decrypted++
if len(samples) < *maxSamples {
t := time.Unix(int64(ts), 0).UTC().Format(time.RFC3339)
samples = append(samples, sampleMessage{
Sender: sender,
Text: text,
Timestamp: t,
})
}
}
}
if decrypted > 0 {
discovered = append(discovered, discoveredChannel{
Name: c.Name,
Key: hex.EncodeToString(c.Key),
ChannelHash: fmt.Sprintf("0x%02X", c.ChannelHash),
PacketsMatched: decrypted,
SampleMessages: samples,
})
}
}
elapsed := time.Since(start)
// Output results
if *jsonOutput {
out := struct {
Candidates int `json:"candidatesTested"`
HashMatches int `json:"hashMatches"`
DecryptAttempts int `json:"decryptAttempts"`
Discovered []discoveredChannel `json:"discovered"`
ElapsedMs float64 `json:"elapsedMs"`
}{
Candidates: len(candidates),
HashMatches: len(matched),
DecryptAttempts: decryptAttempts,
Discovered: discovered,
ElapsedMs: float64(elapsed.Microseconds()) / 1000,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.Encode(out)
return
}
// Human-readable output
fmt.Printf("Channel Discovery Results\n")
fmt.Printf("========================\n\n")
fmt.Printf("Database: %s\n", *dbPath)
fmt.Printf("Undecrypted packets: %d (%d unique channel hashes)\n", len(packets), len(byHash))
fmt.Printf("Candidates tested: %d\n", len(candidates))
fmt.Printf("Hash matches: %d (filtered by 1-byte channelHash)\n", len(matched))
fmt.Printf("Decryption attempts: %d\n", decryptAttempts)
fmt.Printf("Time: %.1f ms (%.0f candidates/sec)\n\n", float64(elapsed.Microseconds())/1000,
float64(len(candidates))/elapsed.Seconds())
if len(discovered) == 0 {
fmt.Println("No channels discovered.")
fmt.Println("\nTips:")
fmt.Println(" - Try a custom wordlist with domain-specific terms: -wordlist words.txt")
fmt.Println(" - Test a specific guess: -name \"#yourchannel\"")
fmt.Println(" - Channel names are case-sensitive and include the '#' prefix")
return
}
fmt.Printf("✓ Discovered %d channel(s):\n\n", len(discovered))
for _, ch := range discovered {
fmt.Printf(" Channel: %s\n", ch.Name)
fmt.Printf(" Key: %s\n", ch.Key)
fmt.Printf(" Hash: %s\n", ch.ChannelHash)
fmt.Printf(" Packets: %d decrypted\n", ch.PacketsMatched)
if len(ch.SampleMessages) > 0 {
fmt.Printf(" Sample messages:\n")
for _, m := range ch.SampleMessages {
if m.Sender != "" {
fmt.Printf(" [%s] %s: %s\n", m.Timestamp, m.Sender, m.Text)
} else {
fmt.Printf(" [%s] %s\n", m.Timestamp, m.Text)
}
}
}
fmt.Println()
}
}
+79
View File
@@ -0,0 +1,79 @@
package main
import (
"encoding/hex"
"testing"
)
func TestDeriveChannelKey(t *testing.T) {
// Known: SHA256("#test") → first 16 bytes as hex
key := deriveChannelKey("#test")
keyHex := hex.EncodeToString(key)
if len(key) != 16 {
t.Fatalf("expected 16-byte key, got %d", len(key))
}
// Verify it's deterministic
key2 := deriveChannelKey("#test")
if hex.EncodeToString(key2) != keyHex {
t.Fatal("key derivation not deterministic")
}
// Different name → different key
key3 := deriveChannelKey("#other")
if hex.EncodeToString(key3) == keyHex {
t.Fatal("different names produced same key")
}
}
func TestChannelHashFromKey(t *testing.T) {
key := deriveChannelKey("#test")
ch := channelHashFromKey(key)
// Must be deterministic
ch2 := channelHashFromKey(key)
if ch != ch2 {
t.Fatal("channelHash not deterministic")
}
}
func TestTryDecryptInvalidInputs(t *testing.T) {
key := deriveChannelKey("#test")
// Empty ciphertext
_, _, _, ok := tryDecrypt("", "0000", key)
if ok {
t.Fatal("expected failure on empty ciphertext")
}
// Invalid hex
_, _, _, ok = tryDecrypt("zzzz", "0000", key)
if ok {
t.Fatal("expected failure on invalid hex")
}
// Wrong MAC should fail
_, _, _, ok = tryDecrypt("00000000000000000000000000000000", "ffff", key)
if ok {
t.Fatal("expected failure on wrong MAC")
}
}
func TestRoundTripEncryptDecrypt(t *testing.T) {
// We can't easily encrypt without reimplementing, but we can verify
// that the hash derivation chain works end-to-end:
// name → key → channelHash, and channelHash is 1 byte
names := []string{"#test", "#general", "#cascadia", "#meshcore"}
for _, name := range names {
key := deriveChannelKey(name)
ch := channelHashFromKey(key)
_ = ch // just verify no panic
if len(key) != 16 {
t.Fatalf("key for %s has wrong length: %d", name, len(key))
}
}
}
func TestDefaultWordlistNotEmpty(t *testing.T) {
words := defaultWordlist()
if len(words) < 400 {
t.Fatalf("expected 400+ words in default wordlist, got %d", len(words))
}
}
+181
View File
@@ -0,0 +1,181 @@
package main
import (
"encoding/json"
"fmt"
"testing"
)
// TestAdvertPubkeyTracking verifies that advertPubkeys is maintained
// incrementally during ingest and eviction, and that GetPerfStoreStats
// returns the correct count without per-request JSON parsing.
func TestAdvertPubkeyTracking(t *testing.T) {
ps := NewPacketStore(nil, nil)
ps.mu.Lock()
// Helper to create an ADVERT StoreTx with a given pubkey.
pt4 := 4
mkAdvert := func(id int, pubkey string) *StoreTx {
d := map[string]interface{}{"pubKey": pubkey}
j, _ := json.Marshal(d)
return &StoreTx{
ID: id,
Hash: fmt.Sprintf("hash%d", id),
PayloadType: &pt4,
DecodedJSON: string(j),
}
}
// Add 3 adverts: 2 distinct pubkeys
tx1 := mkAdvert(1, "pk_alpha")
tx2 := mkAdvert(2, "pk_beta")
tx3 := mkAdvert(3, "pk_alpha") // duplicate pubkey
for _, tx := range []*StoreTx{tx1, tx2, tx3} {
ps.packets = append(ps.packets, tx)
ps.byHash[tx.Hash] = tx
ps.byTxID[tx.ID] = tx
ps.byPayloadType[4] = append(ps.byPayloadType[4], tx)
ps.trackAdvertPubkey(tx)
}
ps.mu.Unlock()
// GetPerfStoreStats should report 2 distinct pubkeys
stats := ps.GetPerfStoreStats()
indexes := stats["indexes"].(map[string]interface{})
got := indexes["advertByObserver"].(int)
if got != 2 {
t.Errorf("advertByObserver = %d, want 2", got)
}
// GetPerfStoreStatsTyped should agree
typed := ps.GetPerfStoreStatsTyped()
if typed.Indexes.AdvertByObserver != 2 {
t.Errorf("typed AdvertByObserver = %d, want 2", typed.Indexes.AdvertByObserver)
}
// Evict tx3 (pk_alpha duplicate) — count should stay 2
ps.mu.Lock()
ps.untrackAdvertPubkey(tx3)
ps.mu.Unlock()
stats2 := ps.GetPerfStoreStats()
idx2 := stats2["indexes"].(map[string]interface{})
if idx2["advertByObserver"].(int) != 2 {
t.Errorf("after evicting duplicate: advertByObserver = %d, want 2", idx2["advertByObserver"].(int))
}
// Evict tx1 (last pk_alpha) — count should drop to 1
ps.mu.Lock()
ps.untrackAdvertPubkey(tx1)
ps.mu.Unlock()
stats3 := ps.GetPerfStoreStats()
idx3 := stats3["indexes"].(map[string]interface{})
if idx3["advertByObserver"].(int) != 1 {
t.Errorf("after evicting last pk_alpha: advertByObserver = %d, want 1", idx3["advertByObserver"].(int))
}
// Evict tx2 (last remaining) — count should be 0
ps.mu.Lock()
ps.untrackAdvertPubkey(tx2)
ps.mu.Unlock()
stats4 := ps.GetPerfStoreStats()
idx4 := stats4["indexes"].(map[string]interface{})
if idx4["advertByObserver"].(int) != 0 {
t.Errorf("after evicting all: advertByObserver = %d, want 0", idx4["advertByObserver"].(int))
}
}
// TestAdvertPubkeyPublicKeyField tests the "public_key" JSON field variant.
func TestAdvertPubkeyPublicKeyField(t *testing.T) {
ps := NewPacketStore(nil, nil)
ps.mu.Lock()
pt4 := 4
d, _ := json.Marshal(map[string]interface{}{"public_key": "pk_legacy"})
tx := &StoreTx{ID: 1, Hash: "h1", PayloadType: &pt4, DecodedJSON: string(d)}
ps.trackAdvertPubkey(tx)
ps.mu.Unlock()
stats := ps.GetPerfStoreStats()
idx := stats["indexes"].(map[string]interface{})
if idx["advertByObserver"].(int) != 1 {
t.Errorf("public_key field: advertByObserver = %d, want 1", idx["advertByObserver"].(int))
}
}
// TestAdvertPubkeyNonAdvert ensures non-ADVERT packets don't affect the count.
func TestAdvertPubkeyNonAdvert(t *testing.T) {
ps := NewPacketStore(nil, nil)
ps.mu.Lock()
pt2 := 2
d, _ := json.Marshal(map[string]interface{}{"pubKey": "pk_text"})
tx := &StoreTx{ID: 1, Hash: "h1", PayloadType: &pt2, DecodedJSON: string(d)}
ps.trackAdvertPubkey(tx)
ps.mu.Unlock()
stats := ps.GetPerfStoreStats()
idx := stats["indexes"].(map[string]interface{})
if idx["advertByObserver"].(int) != 0 {
t.Errorf("non-ADVERT should not be tracked: advertByObserver = %d, want 0", idx["advertByObserver"].(int))
}
}
// BenchmarkGetPerfStoreStats benchmarks the perf stats endpoint with many adverts.
// Before the fix, this did O(N) JSON unmarshals per call.
// After the fix, it's O(1) — just len(map).
func BenchmarkGetPerfStoreStats(b *testing.B) {
ps := NewPacketStore(nil, nil)
ps.mu.Lock()
pt4 := 4
for i := 0; i < 5000; i++ {
pk := fmt.Sprintf("pk_%04d", i%200) // 200 distinct pubkeys
d, _ := json.Marshal(map[string]interface{}{"pubKey": pk})
tx := &StoreTx{
ID: i + 1,
Hash: fmt.Sprintf("hash%d", i+1),
PayloadType: &pt4,
DecodedJSON: string(d),
}
ps.packets = append(ps.packets, tx)
ps.byHash[tx.Hash] = tx
ps.byTxID[tx.ID] = tx
ps.byPayloadType[4] = append(ps.byPayloadType[4], tx)
ps.trackAdvertPubkey(tx)
}
ps.mu.Unlock()
b.ResetTimer()
for i := 0; i < b.N; i++ {
ps.GetPerfStoreStats()
}
}
// BenchmarkGetPerfStoreStatsTyped benchmarks the typed variant.
func BenchmarkGetPerfStoreStatsTyped(b *testing.B) {
ps := NewPacketStore(nil, nil)
ps.mu.Lock()
pt4 := 4
for i := 0; i < 5000; i++ {
pk := fmt.Sprintf("pk_%04d", i%200)
d, _ := json.Marshal(map[string]interface{}{"pubKey": pk})
tx := &StoreTx{
ID: i + 1,
Hash: fmt.Sprintf("hash%d", i+1),
PayloadType: &pt4,
DecodedJSON: string(d),
}
ps.packets = append(ps.packets, tx)
ps.byHash[tx.Hash] = tx
ps.byTxID[tx.ID] = tx
ps.byPayloadType[4] = append(ps.byPayloadType[4], tx)
ps.trackAdvertPubkey(tx)
}
ps.mu.Unlock()
b.ResetTimer()
for i := 0; i < b.N; i++ {
ps.GetPerfStoreStatsTyped()
}
}
+162
View File
@@ -16,6 +16,7 @@ func newTestStore(t *testing.T) *PacketStore {
distCache: make(map[string]*cachedResult),
subpathCache: make(map[string]*cachedResult),
rfCacheTTL: 15 * time.Second,
invCooldown: 10 * time.Second,
}
}
@@ -169,3 +170,164 @@ func TestInvalidateCachesFor_NoFlags(t *testing.T) {
}
}
}
// TestInvalidationRateLimited verifies that rapid ingest cycles don't clear
// caches immediately — they accumulate dirty flags during the cooldown period
// and apply them on the next call after cooldown expires (fixes #533).
func TestInvalidationRateLimited(t *testing.T) {
s := newTestStore(t)
s.invCooldown = 100 * time.Millisecond // short cooldown for testing
// First invalidation should go through immediately
populateAllCaches(s)
s.invalidateCachesFor(cacheInvalidation{hasNewObservations: true})
state := cachePopulated(s)
if state["rf"] {
t.Error("rf cache should be cleared on first invalidation")
}
if !state["topo"] {
t.Error("topo cache should survive (no path changes)")
}
// Repopulate and call again within cooldown — should NOT clear
populateAllCaches(s)
s.invalidateCachesFor(cacheInvalidation{hasNewObservations: true})
state = cachePopulated(s)
if !state["rf"] {
t.Error("rf cache should survive during cooldown period")
}
// Wait for cooldown to expire
time.Sleep(150 * time.Millisecond)
// Next call should apply accumulated + current flags
populateAllCaches(s)
s.invalidateCachesFor(cacheInvalidation{hasNewPaths: true})
state = cachePopulated(s)
if state["rf"] {
t.Error("rf cache should be cleared (pending from cooldown)")
}
if state["topo"] {
t.Error("topo cache should be cleared (current call has hasNewPaths)")
}
if !state["hash"] {
t.Error("hash cache should survive (no transmission changes)")
}
}
// TestInvalidationCooldownAccumulatesFlags verifies that multiple calls during
// cooldown merge their flags correctly.
func TestInvalidationCooldownAccumulatesFlags(t *testing.T) {
s := newTestStore(t)
s.invCooldown = 200 * time.Millisecond
// Initial invalidation (goes through, starts cooldown)
s.invalidateCachesFor(cacheInvalidation{hasNewObservations: true})
// Several calls during cooldown with different flags
s.invalidateCachesFor(cacheInvalidation{hasNewPaths: true})
s.invalidateCachesFor(cacheInvalidation{hasNewTransmissions: true})
s.invalidateCachesFor(cacheInvalidation{hasChannelData: true})
// Verify pending has all flags
s.cacheMu.Lock()
if s.pendingInv == nil {
t.Fatal("pendingInv should not be nil during cooldown")
}
if !s.pendingInv.hasNewPaths || !s.pendingInv.hasNewTransmissions || !s.pendingInv.hasChannelData {
t.Error("all flags should be accumulated in pendingInv")
}
// hasNewObservations was applied immediately, not accumulated
if s.pendingInv.hasNewObservations {
t.Error("hasNewObservations was already applied, should not be in pending")
}
s.cacheMu.Unlock()
// Wait for cooldown, then trigger — all accumulated flags should apply
time.Sleep(250 * time.Millisecond)
populateAllCaches(s)
s.invalidateCachesFor(cacheInvalidation{}) // empty trigger
state := cachePopulated(s)
// Pending had paths, transmissions, channels — all those caches should clear
if state["topo"] {
t.Error("topo should be cleared (pending hasNewPaths)")
}
if state["hash"] {
t.Error("hash should be cleared (pending hasNewTransmissions)")
}
if state["chan"] {
t.Error("chan should be cleared (pending hasChannelData)")
}
}
// TestEvictionBypassesCooldown verifies eviction always clears immediately.
func TestEvictionBypassesCooldown(t *testing.T) {
s := newTestStore(t)
s.invCooldown = 10 * time.Second // long cooldown
// Start cooldown
s.invalidateCachesFor(cacheInvalidation{hasNewObservations: true})
// Eviction during cooldown should still clear everything
populateAllCaches(s)
s.invalidateCachesFor(cacheInvalidation{eviction: true})
state := cachePopulated(s)
for name, has := range state {
if has {
t.Errorf("%s cache should be cleared on eviction even during cooldown", name)
}
}
// pendingInv should be cleared
s.cacheMu.Lock()
if s.pendingInv != nil {
t.Error("pendingInv should be nil after eviction")
}
s.cacheMu.Unlock()
}
// BenchmarkCacheHitDuringIngestion simulates rapid ingestion and verifies
// that cache hits now occur thanks to rate-limited invalidation.
func BenchmarkCacheHitDuringIngestion(b *testing.B) {
s := &PacketStore{
rfCache: make(map[string]*cachedResult),
topoCache: make(map[string]*cachedResult),
hashCache: make(map[string]*cachedResult),
chanCache: make(map[string]*cachedResult),
distCache: make(map[string]*cachedResult),
subpathCache: make(map[string]*cachedResult),
rfCacheTTL: 15 * time.Second,
invCooldown: 50 * time.Millisecond,
}
// Trigger first invalidation to start cooldown timer
s.invalidateCachesFor(cacheInvalidation{hasNewObservations: true})
var hits, misses int64
for i := 0; i < b.N; i++ {
// Populate cache (simulates an analytics query filling the cache)
s.cacheMu.Lock()
s.rfCache["global"] = &cachedResult{
data: map[string]interface{}{"test": true},
expiresAt: time.Now().Add(time.Hour),
}
s.cacheMu.Unlock()
// Simulate rapid ingest invalidation (should be rate-limited)
s.invalidateCachesFor(cacheInvalidation{hasNewObservations: true})
// Check if cache survived the invalidation
s.cacheMu.Lock()
if len(s.rfCache) > 0 {
hits++
} else {
misses++
}
s.cacheMu.Unlock()
}
if hits == 0 {
b.Errorf("expected cache hits > 0 with rate-limited invalidation, got 0 hits / %d misses", misses)
}
b.ReportMetric(float64(hits)/float64(hits+misses)*100, "hit%")
}
+2
View File
@@ -55,6 +55,8 @@ type Config struct {
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
Timestamps *TimestampConfig `json:"timestamps,omitempty"`
DebugAffinity bool `json:"debugAffinity,omitempty"`
}
// PacketStoreConfig controls in-memory packet store limits.
+528
View File
@@ -1,6 +1,7 @@
package main
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
@@ -428,6 +429,49 @@ func TestMaxTransmissionID(t *testing.T) {
})
}
// --- MaxTransmissionID incremental tracking ---
func TestMaxTransmissionIDIncremental(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
store := NewPacketStore(db, nil)
store.Load()
maxTx := store.MaxTransmissionID()
maxObs := store.MaxObservationID()
if maxTx <= 0 {
t.Fatalf("expected maxTx > 0 after Load, got %d", maxTx)
}
if maxObs <= 0 {
t.Fatalf("expected maxObs > 0 after Load, got %d", maxObs)
}
// Verify incremental field matches brute-force iteration
store.mu.RLock()
bruteMaxTx := 0
for id := range store.byTxID {
if id > bruteMaxTx {
bruteMaxTx = id
}
}
bruteMaxObs := 0
for id := range store.byObsID {
if id > bruteMaxObs {
bruteMaxObs = id
}
}
store.mu.RUnlock()
if maxTx != bruteMaxTx {
t.Errorf("maxTxID mismatch: incremental=%d brute=%d", maxTx, bruteMaxTx)
}
if maxObs != bruteMaxObs {
t.Errorf("maxObsID mismatch: incremental=%d brute=%d", maxObs, bruteMaxObs)
}
}
// --- Route handler DB fallback (no store) ---
func TestHandleBulkHealthNoStore(t *testing.T) {
@@ -770,6 +814,56 @@ func TestPrefixMapResolve(t *testing.T) {
})
}
func TestPrefixMapCap(t *testing.T) {
// 16-char pubkey — longer than maxPrefixLen
nodes := []nodeInfo{
{PublicKey: "aabbccdd11223344", Name: "LongKey"},
{PublicKey: "eeff0011", Name: "ShortKey"}, // exactly 8 chars
}
pm := buildPrefixMap(nodes)
t.Run("short prefixes still work", func(t *testing.T) {
n := pm.resolve("aabb")
if n == nil || n.Name != "LongKey" {
t.Errorf("expected LongKey for short prefix, got %v", n)
}
})
t.Run("full pubkey exact match works", func(t *testing.T) {
n := pm.resolve("aabbccdd11223344")
if n == nil || n.Name != "LongKey" {
t.Errorf("expected LongKey for full key, got %v", n)
}
})
t.Run("intermediate prefix beyond cap returns nil", func(t *testing.T) {
// 10-char prefix — beyond maxPrefixLen but not full key
n := pm.resolve("aabbccdd11")
if n != nil {
t.Errorf("expected nil for intermediate prefix beyond cap, got %v", n.Name)
}
})
t.Run("short key within cap has all prefixes", func(t *testing.T) {
for l := 2; l <= 8; l++ {
pfx := "eeff0011"[:l]
n := pm.resolve(pfx)
if n == nil || n.Name != "ShortKey" {
t.Errorf("prefix %q: expected ShortKey, got %v", pfx, n)
}
}
})
t.Run("map size is capped", func(t *testing.T) {
// LongKey: 7 prefix entries (2..8) + 1 full key = 8
// ShortKey: 7 prefix entries (2..8), no full key entry (len == maxPrefixLen) = 7
// No overlapping prefixes between the two nodes → 8 + 7 = 15 unique map keys
if len(pm.m) != 15 {
t.Errorf("expected 15 map entries (8 for LongKey + 7 for ShortKey), got %d", len(pm.m))
}
})
}
// --- pathLen ---
func TestPathLen(t *testing.T) {
@@ -1333,6 +1427,40 @@ func TestGetNodeLocations(t *testing.T) {
}
}
// --- GetNodeLocationsByKeys ---
func TestGetNodeLocationsByKeys(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
// Query with a known key
pk := "aabbccdd11223344"
locs := db.GetNodeLocationsByKeys([]string{pk})
if len(locs) != 1 {
t.Errorf("expected 1 location, got %d", len(locs))
}
if entry, ok := locs[strings.ToLower(pk)]; ok {
if entry["lat"] == nil {
t.Error("expected non-nil lat")
}
} else {
t.Error("expected node location for test repeater")
}
// Query with no keys returns empty map
empty := db.GetNodeLocationsByKeys([]string{})
if len(empty) != 0 {
t.Errorf("expected 0 locations for empty keys, got %d", len(empty))
}
// Query with unknown key returns empty map
unknown := db.GetNodeLocationsByKeys([]string{"nonexistent"})
if len(unknown) != 0 {
t.Errorf("expected 0 locations for unknown key, got %d", len(unknown))
}
}
// --- Store edge cases ---
func TestStoreQueryPacketsEdgeCases(t *testing.T) {
@@ -1906,6 +2034,48 @@ func TestTxToMap(t *testing.T) {
}
}
func TestTxToMapLazyObservations(t *testing.T) {
snr := 10.5
rssi := -90.0
tx := &StoreTx{
ID: 1,
Hash: "abc",
Observations: []*StoreObs{
{ID: 10, ObserverID: "obs1", ObserverName: "O1", SNR: &snr, RSSI: &rssi, Timestamp: "2025-01-01"},
{ID: 11, ObserverID: "obs2", ObserverName: "O2", SNR: &snr, RSSI: &rssi, Timestamp: "2025-01-02"},
},
}
// Without flag: no observations key
m := txToMap(tx)
if _, ok := m["observations"]; ok {
t.Error("txToMap without includeObservations should not include observations key")
}
// With false: no observations key
m = txToMap(tx, false)
if _, ok := m["observations"]; ok {
t.Error("txToMap(tx, false) should not include observations key")
}
// With true: observations included
m = txToMap(tx, true)
obs, ok := m["observations"]
if !ok {
t.Fatal("txToMap(tx, true) should include observations key")
}
obsList, ok := obs.([]map[string]interface{})
if !ok {
t.Fatal("observations should be []map[string]interface{}")
}
if len(obsList) != 2 {
t.Errorf("expected 2 observations, got %d", len(obsList))
}
if obsList[0]["observer_id"] != "obs1" {
t.Errorf("expected observer_id obs1, got %v", obsList[0]["observer_id"])
}
}
// --- filterTxSlice ---
func TestFilterTxSlice(t *testing.T) {
@@ -2099,6 +2269,84 @@ func TestSubpathPrecomputedIndex(t *testing.T) {
}
}
func TestSubpathTxIndexPopulated(t *testing.T) {
db := setupRichTestDB(t)
defer db.Close()
store := NewPacketStore(db, nil)
store.Load()
// spTxIndex must be populated alongside spIndex
if len(store.spTxIndex) == 0 {
t.Fatal("expected spTxIndex to be populated after Load()")
}
// Every key in spIndex must also exist in spTxIndex with matching count
for key, count := range store.spIndex {
txs, ok := store.spTxIndex[key]
if !ok {
t.Errorf("spTxIndex missing key %q that exists in spIndex", key)
continue
}
if len(txs) != count {
t.Errorf("spTxIndex[%q] has %d txs, spIndex count is %d", key, len(txs), count)
}
}
// GetSubpathDetail should return correct match count via indexed lookup
detail := store.GetSubpathDetail([]string{"eeff", "0011"})
if detail == nil {
t.Fatal("expected non-nil detail for existing subpath")
}
matches, _ := detail["totalMatches"].(int)
if matches != 1 {
t.Errorf("totalMatches = %d, want 1", matches)
}
// Non-existent subpath should return 0 matches
detail2 := store.GetSubpathDetail([]string{"zzzz", "yyyy"})
if detail2 == nil {
t.Fatal("expected non-nil result even for non-existent subpath")
}
matches2, _ := detail2["totalMatches"].(int)
if matches2 != 0 {
t.Errorf("totalMatches for non-existent subpath = %d, want 0", matches2)
}
}
func TestSubpathDetailMixedCaseHops(t *testing.T) {
db := setupRichTestDB(t)
defer db.Close()
store := NewPacketStore(db, nil)
store.Load()
// Query with lowercase hops to establish baseline
lower := store.GetSubpathDetail([]string{"eeff", "0011"})
if lower == nil {
t.Fatal("expected non-nil detail for lowercase subpath")
}
lowerMatches, _ := lower["totalMatches"].(int)
if lowerMatches == 0 {
t.Fatal("expected >0 matches for lowercase subpath")
}
// Query with mixed-case hops — must return the same results (case-insensitive)
mixed := store.GetSubpathDetail([]string{"EEFF", "0011"})
if mixed == nil {
t.Fatal("expected non-nil detail for mixed-case subpath")
}
mixedMatches, _ := mixed["totalMatches"].(int)
if mixedMatches != lowerMatches {
t.Errorf("mixed-case totalMatches = %d, want %d (same as lowercase)", mixedMatches, lowerMatches)
}
// All-uppercase should also match
upper := store.GetSubpathDetail([]string{"EEFF", "0011"})
upperMatches, _ := upper["totalMatches"].(int)
if upperMatches != lowerMatches {
t.Errorf("uppercase totalMatches = %d, want %d", upperMatches, lowerMatches)
}
}
func TestStoreGetAnalyticsRFCacheHit(t *testing.T) {
db := setupRichTestDB(t)
defer db.Close()
@@ -3716,6 +3964,71 @@ func TestGetChannelMessagesAfterIngest(t *testing.T) {
}
}
// --- resolveRegionObservers caching ---
func TestResolveRegionObserversCaching(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
store := &PacketStore{db: db}
// First call should populate cache.
obs1 := store.resolveRegionObservers("SJC")
if obs1 == nil || len(obs1) == 0 {
t.Fatal("expected observer IDs for SJC on first call")
}
// Second call should return cached result (same pointer).
obs2 := store.resolveRegionObservers("SJC")
if len(obs2) != len(obs1) {
t.Errorf("cached result differs: got %d, want %d", len(obs2), len(obs1))
}
// Non-existent region should return nil even from cache.
obs3 := store.resolveRegionObservers("NONEXIST")
if obs3 != nil {
t.Errorf("expected nil for NONEXIST, got %v", obs3)
}
// Verify cache fields are set.
if store.regionObsCache == nil {
t.Error("regionObsCache should be non-nil after calls")
}
if store.regionObsCacheTime.IsZero() {
t.Error("regionObsCacheTime should be set")
}
}
func TestResolveRegionObserversCacheMissNewRegion(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
store := &PacketStore{db: db}
// Populate cache with SJC.
obs1 := store.resolveRegionObservers("SJC")
if obs1 == nil || len(obs1) == 0 {
t.Fatal("expected observer IDs for SJC on first call")
}
// Cache is now valid. Request a different region that exists in DB.
// Before the fix, this would return nil from the map lookup instead of
// fetching from DB, silently returning "no observers" for up to 30s.
obs2 := store.resolveRegionObservers("LAX")
// LAX may or may not have data in the test DB, but the key point is:
// a non-existent region should be fetched (not just nil-returned).
// Verify the region key was cached (even if empty).
store.regionObsMu.Lock()
_, cached := store.regionObsCache["LAX"]
store.regionObsMu.Unlock()
if !cached {
t.Error("LAX should be cached after resolveRegionObservers call, even if empty")
}
_ = obs2
}
func TestIndexByNodePreCheck(t *testing.T) {
store := &PacketStore{
byNode: make(map[string][]*StoreTx),
@@ -3811,3 +4124,218 @@ func BenchmarkIndexByNode(b *testing.B) {
}
})
}
// --- Multi-observer comma-separated filter tests ---
func TestTransmissionsForObserverMultiCSV(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
store := NewPacketStore(db, nil)
store.Load()
t.Run("comma-separated returns union via index", func(t *testing.T) {
result := store.transmissionsForObserver("obs1,obs2", nil)
if len(result) == 0 {
t.Fatal("expected results for obs1,obs2")
}
// obs1 has transmissions 1,2,3; obs2 has transmission 1
// Union should include all unique transmissions
obs1Only := store.transmissionsForObserver("obs1", nil)
obs2Only := store.transmissionsForObserver("obs2", nil)
if len(result) < len(obs1Only) || len(result) < len(obs2Only) {
t.Errorf("union (%d) should be >= each individual set (obs1=%d, obs2=%d)",
len(result), len(obs1Only), len(obs2Only))
}
})
t.Run("comma-separated with spaces via index", func(t *testing.T) {
result := store.transmissionsForObserver("obs1, obs2", nil)
if len(result) == 0 {
t.Fatal("expected results for 'obs1, obs2' (with space)")
}
noSpace := store.transmissionsForObserver("obs1,obs2", nil)
if len(result) != len(noSpace) {
t.Errorf("with-space (%d) should equal no-space (%d)", len(result), len(noSpace))
}
})
t.Run("comma-separated returns union via filter path", func(t *testing.T) {
allTx := store.packets
result := store.transmissionsForObserver("obs1,obs2", allTx)
if len(result) == 0 {
t.Fatal("expected results for obs1,obs2 via filter path")
}
})
t.Run("comma-separated with spaces via filter path", func(t *testing.T) {
allTx := store.packets
withSpace := store.transmissionsForObserver("obs1, obs2", allTx)
noSpace := store.transmissionsForObserver("obs1,obs2", allTx)
if len(withSpace) != len(noSpace) {
t.Errorf("filter path: with-space (%d) should equal no-space (%d)", len(withSpace), len(noSpace))
}
})
}
func TestBuildTransmissionWhereMultiObserver(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
t.Run("comma-separated produces IN clause", func(t *testing.T) {
q := PacketQuery{Observer: "obs1,obs2"}
where, args := db.buildTransmissionWhere(q)
if len(where) != 1 {
t.Fatalf("expected 1 WHERE clause, got %d", len(where))
}
clause := where[0]
if !strings.Contains(clause, "IN (?,?)") {
t.Errorf("expected IN (?,?) in clause, got: %s", clause)
}
if len(args) != 2 {
t.Fatalf("expected 2 args, got %d", len(args))
}
if args[0] != "obs1" || args[1] != "obs2" {
t.Errorf("expected [obs1, obs2], got %v", args)
}
})
t.Run("comma-separated with spaces trims IDs", func(t *testing.T) {
q := PacketQuery{Observer: "obs1, obs2"}
_, args := db.buildTransmissionWhere(q)
if len(args) != 2 {
t.Fatalf("expected 2 args, got %d", len(args))
}
if args[0] != "obs1" || args[1] != "obs2" {
t.Errorf("expected trimmed [obs1, obs2], got %v", args)
}
})
t.Run("single observer still works", func(t *testing.T) {
q := PacketQuery{Observer: "obs1"}
where, args := db.buildTransmissionWhere(q)
if len(where) != 1 {
t.Fatalf("expected 1 WHERE clause, got %d", len(where))
}
if !strings.Contains(where[0], "IN (?)") {
t.Errorf("expected IN (?) for single observer, got: %s", where[0])
}
if len(args) != 1 || args[0] != "obs1" {
t.Errorf("expected [obs1], got %v", args)
}
})
}
// --- Distance index incremental update (#365, replaces debounce #557) ---
func TestDistanceIncrementalUpdate(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
store := NewPacketStore(db, nil)
store.Load()
// Record initial distance index size.
initialHops := len(store.distHops)
initialPaths := len(store.distPaths)
// Insert a new observation with a different path to trigger an incremental update.
maxObsID := db.GetMaxObservationID()
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 2, 5.0, -100, '["xx","yy","zz"]', ?)`, time.Now().Unix())
store.IngestNewObservations(maxObsID, 500)
// Distance index should have been updated incrementally (sizes may differ
// if the new path resolves differently, but should not panic or corrupt).
_ = len(store.distHops)
_ = len(store.distPaths)
// Insert another observation with yet another path.
maxObsID = db.GetMaxObservationID()
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 2, 7.0, -95, '["aa","bb","cc","dd"]', ?)`, time.Now().Unix())
store.IngestNewObservations(maxObsID, 500)
// Verify the index is still coherent (no duplicates for the same tx).
txSeen := make(map[int]int)
for _, r := range store.distPaths {
if r.tx != nil {
txSeen[r.tx.ID]++
}
}
for txID, count := range txSeen {
if count > 1 {
t.Errorf("distPaths has %d entries for tx %d (expected at most 1)", count, txID)
}
}
t.Logf("Distance index: %d→%d hops, %d→%d paths (incremental)",
initialHops, len(store.distHops), initialPaths, len(store.distPaths))
}
func TestHandleBatchObservations(t *testing.T) {
_, router := setupNoStoreServer(t)
t.Run("empty hashes returns empty results", func(t *testing.T) {
body := strings.NewReader(`{"hashes":[]}`)
req := httptest.NewRequest("POST", "/api/packets/observations", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
results, ok := resp["results"].(map[string]interface{})
if !ok || len(results) != 0 {
t.Fatalf("expected empty results map, got %v", resp)
}
})
t.Run("invalid JSON returns 400", func(t *testing.T) {
body := strings.NewReader(`not json`)
req := httptest.NewRequest("POST", "/api/packets/observations", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 400 {
t.Fatalf("expected 400, got %d", w.Code)
}
})
t.Run("too many hashes returns 400", func(t *testing.T) {
hashes := make([]string, 201)
for i := range hashes {
hashes[i] = fmt.Sprintf("hash%d", i)
}
data, _ := json.Marshal(map[string][]string{"hashes": hashes})
req := httptest.NewRequest("POST", "/api/packets/observations", bytes.NewReader(data))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 400 {
t.Fatalf("expected 400, got %d", w.Code)
}
})
t.Run("valid hashes with no store returns empty results", func(t *testing.T) {
body := strings.NewReader(`{"hashes":["abc123","def456"]}`)
req := httptest.NewRequest("POST", "/api/packets/observations", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
_, ok := resp["results"].(map[string]interface{})
if !ok {
t.Fatalf("expected results map, got %v", resp)
}
})
}
+54 -10
View File
@@ -15,9 +15,10 @@ import (
// DB wraps a read-only connection to the MeshCore SQLite database.
type DB struct {
conn *sql.DB
path string // filesystem path to the database file
isV3 bool // v3 schema: observer_idx in observations (vs observer_id in v2)
conn *sql.DB
path string // filesystem path to the database file
isV3 bool // v3 schema: observer_idx in observations (vs observer_id in v2)
hasResolvedPath bool // observations table has resolved_path column
}
// OpenDB opens a read-only SQLite connection with WAL mode.
@@ -61,9 +62,13 @@ func (db *DB) detectSchema() {
var colType sql.NullString
var notNull, pk int
var dflt sql.NullString
if rows.Scan(&cid, &colName, &colType, &notNull, &dflt, &pk) == nil && colName == "observer_idx" {
db.isV3 = true
return
if rows.Scan(&cid, &colName, &colType, &notNull, &dflt, &pk) == nil {
if colName == "observer_idx" {
db.isV3 = true
}
if colName == "resolved_path" {
db.hasResolvedPath = true
}
}
}
}
@@ -372,7 +377,8 @@ type PacketQuery struct {
Until string
Region string
Node string
Order string // ASC or DESC
Order string // ASC or DESC
ExpandObservations bool // when true, include observation sub-maps in txToMap output
}
// PacketResult wraps paginated packet list.
@@ -608,12 +614,17 @@ func (db *DB) buildTransmissionWhere(q PacketQuery) ([]string, []interface{}) {
args = append(args, "%"+pk+"%")
}
if q.Observer != "" {
ids := strings.Split(q.Observer, ",")
placeholders := strings.Repeat("?,", len(ids))
placeholders = placeholders[:len(placeholders)-1]
if db.isV3 {
where = append(where, "EXISTS (SELECT 1 FROM observations oi JOIN observers obi ON obi.rowid = oi.observer_idx WHERE oi.transmission_id = t.id AND obi.id = ?)")
where = append(where, "EXISTS (SELECT 1 FROM observations oi JOIN observers obi ON obi.rowid = oi.observer_idx WHERE oi.transmission_id = t.id AND obi.id IN ("+placeholders+"))")
} else {
where = append(where, "EXISTS (SELECT 1 FROM observations oi WHERE oi.transmission_id = t.id AND oi.observer_id = ?)")
where = append(where, "EXISTS (SELECT 1 FROM observations oi WHERE oi.transmission_id = t.id AND oi.observer_id IN ("+placeholders+"))")
}
for _, id := range ids {
args = append(args, strings.TrimSpace(id))
}
args = append(args, q.Observer)
}
if q.Region != "" {
if db.isV3 {
@@ -1487,6 +1498,39 @@ func (db *DB) GetNodeLocations() map[string]map[string]interface{} {
return result
}
// GetNodeLocationsByKeys returns location data only for the given public keys.
// This avoids fetching ALL nodes when only a few keys need to be matched.
func (db *DB) GetNodeLocationsByKeys(keys []string) map[string]map[string]interface{} {
result := make(map[string]map[string]interface{})
if len(keys) == 0 {
return result
}
placeholders := make([]string, len(keys))
args := make([]interface{}, len(keys))
for i, k := range keys {
placeholders[i] = "?"
args[i] = strings.ToLower(k)
}
query := "SELECT public_key, lat, lon, role FROM nodes WHERE LOWER(public_key) IN (" + strings.Join(placeholders, ",") + ")"
rows, err := db.conn.Query(query, args...)
if err != nil {
return result
}
defer rows.Close()
for rows.Next() {
var pk string
var role sql.NullString
var lat, lon sql.NullFloat64
rows.Scan(&pk, &lat, &lon, &role)
result[strings.ToLower(pk)] = map[string]interface{}{
"lat": nullFloat(lat),
"lon": nullFloat(lon),
"role": nullStr(role),
}
}
return result
}
// QueryMultiNodePackets returns transmissions referencing any of the given pubkeys.
func (db *DB) QueryMultiNodePackets(pubkeys []string, limit, offset int, order, since, until string) (*PacketResult, error) {
if len(pubkeys) == 0 {
+32 -6
View File
@@ -162,24 +162,50 @@ func TestEvictStale_NoEvictionWhenDisabled(t *testing.T) {
func TestEvictStale_MemoryBasedEviction(t *testing.T) {
now := time.Now().UTC()
// Create enough packets to exceed a small memory limit
// 1000 packets * 5KB + 2000 obs * 500B ≈ 6MB
store := makeTestStore(1000, now.Add(-1*time.Hour), 0)
// All packets are recent (1h old) so time-based won't trigger
// All packets are recent (1h old) so time-based won't trigger.
store.retentionHours = 24
store.maxMemoryMB = 3 // ~3MB limit, should evict roughly half
store.maxMemoryMB = 3
// Inject deterministic estimator: simulates 6MB (over 3MB limit).
// Uses packet count so it scales correctly after eviction.
store.memoryEstimator = func() float64 {
return float64(len(store.packets)*5120+store.totalObs*500) / 1048576.0
}
evicted := store.EvictStale()
if evicted == 0 {
t.Fatal("expected some evictions for memory cap")
}
// After eviction, estimated memory should be <= 3MB
estMB := store.estimatedMemoryMB()
if estMB > 3.5 { // small tolerance
if estMB > 3.5 {
t.Fatalf("expected <=3.5MB after eviction, got %.1fMB", estMB)
}
}
// TestEvictStale_MemoryBasedEviction_UnderestimatedHeap verifies that eviction
// fires correctly when actual heap is much larger than a formula-based estimate
// would report — the scenario that caused OOM kills in production.
func TestEvictStale_MemoryBasedEviction_UnderestimatedHeap(t *testing.T) {
now := time.Now().UTC()
store := makeTestStore(1000, now.Add(-1*time.Hour), 0)
store.retentionHours = 24
store.maxMemoryMB = 500
// Simulate actual heap 5x over budget (like production: ~5GB actual vs ~1GB limit).
store.memoryEstimator = func() float64 {
return 2500.0 // 2500MB actual vs 500MB limit
}
evicted := store.EvictStale()
if evicted == 0 {
t.Fatal("expected evictions when heap is 5x over limit")
}
// Should keep roughly 500/2500 * 0.9 = 18% of packets → ~180 of 1000.
remaining := len(store.packets)
if remaining > 250 {
t.Fatalf("expected most packets evicted (heap 5x over), but %d of 1000 remain", remaining)
}
}
func TestEvictStale_CleansNodeIndexes(t *testing.T) {
now := time.Now().UTC()
store := makeTestStore(10, now.Add(-48*time.Hour), 0)
+40
View File
@@ -2,6 +2,8 @@ package main
import (
"encoding/json"
"fmt"
"math/rand"
"net/http"
"net/http/httptest"
"os"
@@ -220,6 +222,44 @@ func TestSortedCopy(t *testing.T) {
}
}
func TestSortedCopyLarge(t *testing.T) {
// Regression: verify correct sort on larger input
rng := rand.New(rand.NewSource(42))
n := 1000
input := make([]float64, n)
for i := range input {
input[i] = rng.Float64() * 1000
}
result := sortedCopy(input)
if len(result) != n {
t.Fatalf("expected %d elements, got %d", n, len(result))
}
for i := 1; i < len(result); i++ {
if result[i] < result[i-1] {
t.Fatalf("not sorted at index %d: %v > %v", i, result[i-1], result[i])
}
}
// Original unchanged
if input[0] == result[0] && input[1] == result[1] && input[2] == result[2] {
// Could be coincidence but very unlikely with random data
}
}
func BenchmarkSortedCopy(b *testing.B) {
rng := rand.New(rand.NewSource(42))
for _, size := range []int{256, 1000, 10000} {
data := make([]float64, size)
for i := range data {
data[i] = rng.Float64() * 1000
}
b.Run(fmt.Sprintf("n=%d", size), func(b *testing.B) {
for i := 0; i < b.N; i++ {
sortedCopy(data)
}
})
}
}
func TestLastN(t *testing.T) {
arr := []map[string]interface{}{
{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}, {"id": 5},
+66 -5
View File
@@ -144,6 +144,50 @@ func main() {
log.Fatalf("[store] failed to load: %v", err)
}
// Initialize persisted neighbor graph
dbPath = database.path
if err := ensureNeighborEdgesTable(dbPath); err != nil {
log.Printf("[neighbor] warning: could not create neighbor_edges table: %v", err)
}
// Add resolved_path column if missing.
// NOTE on startup ordering (review item #10): ensureResolvedPathColumn runs AFTER
// OpenDB/detectSchema, so db.hasResolvedPath will be false on first run with a
// pre-existing DB. This means Load() won't SELECT resolved_path from SQLite.
// That's OK: backfillResolvedPaths (below) computes and persists them in-memory
// AND to SQLite. On next restart, detectSchema finds the column and Load() reads it.
if err := ensureResolvedPathColumn(dbPath); err != nil {
log.Printf("[store] warning: could not add resolved_path column: %v", err)
} else {
database.hasResolvedPath = true // detectSchema ran before column was added; fix the flag
}
// Load or build neighbor graph
if neighborEdgesTableExists(database.conn) {
store.graph = loadNeighborEdgesFromDB(database.conn)
log.Printf("[neighbor] loaded persisted neighbor graph")
} else {
log.Printf("[neighbor] no persisted edges found, building from store...")
rw, rwErr := openRW(dbPath)
if rwErr == nil {
edgeCount := buildAndPersistEdges(store, rw)
rw.Close()
log.Printf("[neighbor] persisted %d edges", edgeCount)
}
store.graph = BuildFromStore(store)
}
// Backfill resolved_path for observations that don't have it yet
if backfilled := backfillResolvedPaths(store, dbPath); backfilled > 0 {
log.Printf("[store] backfilled resolved_path for %d observations", backfilled)
}
// Re-pick best observation now that resolved paths are populated
store.mu.Lock()
for _, tx := range store.packets {
pickBestObservation(tx)
}
store.mu.Unlock()
// WebSocket hub
hub := NewHub()
@@ -180,8 +224,15 @@ func main() {
defer stopEviction()
// Auto-prune old packets if retention.packetDays is configured
var stopPrune func()
if cfg.Retention != nil && cfg.Retention.PacketDays > 0 {
days := cfg.Retention.PacketDays
pruneTicker := time.NewTicker(24 * time.Hour)
pruneDone := make(chan struct{})
stopPrune = func() {
pruneTicker.Stop()
close(pruneDone)
}
go func() {
time.Sleep(1 * time.Minute)
if n, err := database.PruneOldPackets(days); err != nil {
@@ -189,11 +240,16 @@ func main() {
} else {
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
}
for range time.Tick(24 * time.Hour) {
if n, err := database.PruneOldPackets(days); err != nil {
log.Printf("[prune] error: %v", err)
} else {
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
for {
select {
case <-pruneTicker.C:
if n, err := database.PruneOldPackets(days); err != nil {
log.Printf("[prune] error: %v", err)
} else {
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
}
case <-pruneDone:
return
}
}
}()
@@ -218,6 +274,11 @@ func main() {
// 1. Stop accepting new WebSocket/poll data
poller.Stop()
// 1b. Stop auto-prune ticker
if stopPrune != nil {
stopPrune()
}
// 2. Gracefully drain HTTP connections (up to 15s)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
+2 -1
View File
@@ -80,7 +80,8 @@ func (s *Server) getNeighborGraph() *NeighborGraph {
if s.neighborGraph == nil || s.neighborGraph.IsStale() {
if s.store != nil {
s.neighborGraph = BuildFromStore(s.store)
debugLog := s.cfg != nil && s.cfg.DebugAffinity
s.neighborGraph = BuildFromStoreWithLog(s.store, debugLog)
} else {
s.neighborGraph = NewNeighborGraph()
}
+399
View File
@@ -0,0 +1,399 @@
package main
import (
"encoding/json"
"fmt"
"math"
"net/http"
"sort"
"strings"
"time"
)
// ─── Debug API response types ──────────────────────────────────────────────────
type DebugAffinityResponse struct {
Edges []DebugEdge `json:"edges"`
Resolutions []DebugResolution `json:"resolutions"`
Stats DebugStats `json:"stats"`
}
type DebugEdge struct {
NodeA string `json:"nodeA"`
NodeAName string `json:"nodeAName,omitempty"`
NodeB string `json:"nodeB"`
NodeBName string `json:"nodeBName,omitempty"`
Prefix string `json:"prefix"`
Weight int `json:"weight"`
ObservationCount int `json:"observationCount"`
LastSeen string `json:"lastSeen"`
FirstSeen string `json:"firstSeen"`
Score float64 `json:"score"`
Jaccard float64 `json:"jaccard,omitempty"`
AvgSNR *float64 `json:"avgSnr,omitempty"`
Observers []string `json:"observers"`
Ambiguous bool `json:"ambiguous"`
Unresolved bool `json:"unresolved,omitempty"`
Resolved bool `json:"resolved,omitempty"`
}
type DebugResolution struct {
Prefix string `json:"prefix"`
Chosen string `json:"chosen,omitempty"`
ChosenName string `json:"chosenName,omitempty"`
ChosenScore int `json:"chosenScore"`
ChosenJaccard float64 `json:"chosenJaccard"`
Confidence string `json:"confidence"`
Candidates []DebugCandidate `json:"candidates"`
Ratio float64 `json:"ratio"`
ThresholdApplied float64 `json:"thresholdApplied"`
Method string `json:"method"`
Tier string `json:"tier"`
KnownNode string `json:"knownNode"`
KnownNodeName string `json:"knownNodeName,omitempty"`
}
type DebugCandidate struct {
Pubkey string `json:"pubkey"`
Name string `json:"name,omitempty"`
Score int `json:"score"`
Jaccard float64 `json:"jaccard"`
}
type DebugStats struct {
TotalEdges int `json:"totalEdges"`
TotalNodes int `json:"totalNodes"`
ResolvedCount int `json:"resolvedCount"`
AmbiguousCount int `json:"ambiguousCount"`
UnresolvedCount int `json:"unresolvedCount"`
AvgConfidence float64 `json:"avgConfidence"`
ColdStartCoverage float64 `json:"coldStartCoverage"`
CacheAge string `json:"cacheAge"`
LastRebuild string `json:"lastRebuild"`
}
// ─── Debug API Handler ─────────────────────────────────────────────────────────
func (s *Server) handleDebugAffinity(w http.ResponseWriter, r *http.Request) {
prefixFilter := strings.ToLower(r.URL.Query().Get("prefix"))
nodeFilter := strings.ToLower(r.URL.Query().Get("node"))
graph := s.getNeighborGraph()
now := time.Now()
nodeMap := s.buildNodeInfoMap()
allEdges := graph.AllEdges()
// Build edges response
var debugEdges []DebugEdge
nodeSet := make(map[string]bool)
resolvedCount := 0
ambiguousCount := 0
unresolvedCount := 0
var scoreSum float64
var scoreCount int
for _, e := range allEdges {
// Apply filters
if prefixFilter != "" && !strings.EqualFold(e.Prefix, prefixFilter) {
continue
}
if nodeFilter != "" {
if !strings.EqualFold(e.NodeA, nodeFilter) && !strings.EqualFold(e.NodeB, nodeFilter) {
// Also check if any candidate matches
found := false
for _, c := range e.Candidates {
if strings.EqualFold(c, nodeFilter) {
found = true
break
}
}
if !found {
continue
}
}
}
score := e.Score(now)
de := DebugEdge{
NodeA: e.NodeA,
NodeB: e.NodeB,
Prefix: e.Prefix,
Weight: e.Count,
ObservationCount: e.Count,
LastSeen: e.LastSeen.UTC().Format(time.RFC3339),
FirstSeen: e.FirstSeen.UTC().Format(time.RFC3339),
Score: math.Round(score*1000) / 1000,
Observers: observerList(e.Observers),
Ambiguous: e.Ambiguous,
Resolved: e.Resolved,
}
if e.SNRCount > 0 {
avg := e.AvgSNR()
de.AvgSNR = &avg
}
// Add names
if nodeMap != nil {
if info, ok := nodeMap[strings.ToLower(e.NodeA)]; ok {
de.NodeAName = info.Name
}
if info, ok := nodeMap[strings.ToLower(e.NodeB)]; ok {
de.NodeBName = info.Name
}
}
if e.Ambiguous {
if len(e.Candidates) == 0 {
de.Unresolved = true
unresolvedCount++
} else {
ambiguousCount++
}
} else {
resolvedCount++
scoreSum += score
scoreCount++
}
debugEdges = append(debugEdges, de)
if e.NodeA != "" && !strings.HasPrefix(e.NodeA, "prefix:") {
nodeSet[e.NodeA] = true
}
if e.NodeB != "" && !strings.HasPrefix(e.NodeB, "prefix:") {
nodeSet[e.NodeB] = true
}
}
// Build resolutions from the graph's disambiguation history
resolutions := s.buildResolutions(graph, nodeMap, prefixFilter, nodeFilter)
// Cold-start coverage: % of 1-byte prefixes with ≥3 observations
coldStart := s.computeColdStartCoverage(allEdges)
avgConf := 0.0
if scoreCount > 0 {
avgConf = math.Round(scoreSum/float64(scoreCount)*1000) / 1000
}
if debugEdges == nil {
debugEdges = []DebugEdge{}
}
if resolutions == nil {
resolutions = []DebugResolution{}
}
// Sort edges by weight descending
sort.Slice(debugEdges, func(i, j int) bool {
return debugEdges[i].Weight > debugEdges[j].Weight
})
graph.mu.RLock()
builtAt := graph.builtAt
graph.mu.RUnlock()
cacheAge := ""
lastRebuild := ""
if !builtAt.IsZero() {
cacheAge = fmt.Sprintf("%.1fs", time.Since(builtAt).Seconds())
lastRebuild = builtAt.UTC().Format(time.RFC3339)
}
resp := DebugAffinityResponse{
Edges: debugEdges,
Resolutions: resolutions,
Stats: DebugStats{
TotalEdges: len(debugEdges),
TotalNodes: len(nodeSet),
ResolvedCount: resolvedCount,
AmbiguousCount: ambiguousCount,
UnresolvedCount: unresolvedCount,
AvgConfidence: avgConf,
ColdStartCoverage: coldStart,
CacheAge: cacheAge,
LastRebuild: lastRebuild,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// buildResolutions generates per-prefix resolution decision logs.
// It uses resolveWithContext (M4) to show the actual 4-tier fallback path
// (affinity → geo → GPS → first_match) for each prefix resolution.
func (s *Server) buildResolutions(graph *NeighborGraph, nodeMap map[string]nodeInfo, prefixFilter, nodeFilter string) []DebugResolution {
graph.mu.RLock()
defer graph.mu.RUnlock()
// Get the prefix map for resolveWithContext tier computation.
var pm *prefixMap
if s.store != nil {
_, pm = s.store.getCachedNodesAndPM()
}
// Build resolved neighbor sets for Jaccard computation
resolvedNeighbors := make(map[string]map[string]bool)
for _, e := range graph.edges {
if e.Ambiguous || e.NodeB == "" {
continue
}
if resolvedNeighbors[e.NodeA] == nil {
resolvedNeighbors[e.NodeA] = make(map[string]bool)
}
if resolvedNeighbors[e.NodeB] == nil {
resolvedNeighbors[e.NodeB] = make(map[string]bool)
}
resolvedNeighbors[e.NodeA][e.NodeB] = true
resolvedNeighbors[e.NodeB][e.NodeA] = true
}
var resolutions []DebugResolution
for _, e := range graph.edges {
// Show resolution info for both resolved (auto-resolved) and ambiguous edges
if !e.Resolved && !e.Ambiguous {
continue
}
if len(e.Candidates) < 2 && !e.Resolved {
continue
}
if prefixFilter != "" && !strings.EqualFold(e.Prefix, prefixFilter) {
continue
}
knownNode := e.NodeA
if strings.HasPrefix(e.NodeA, "prefix:") {
knownNode = e.NodeB
}
if nodeFilter != "" && !strings.EqualFold(knownNode, nodeFilter) {
// Check if the resolved node matches
if e.Resolved && !strings.EqualFold(e.NodeB, nodeFilter) && !strings.EqualFold(e.NodeA, nodeFilter) {
continue
}
}
knownNeighbors := resolvedNeighbors[knownNode]
var candidates []DebugCandidate
candList := e.Candidates
// For resolved edges, add the resolved node as a candidate too
if e.Resolved {
resolvedPK := e.NodeB
if strings.EqualFold(e.NodeB, knownNode) {
resolvedPK = e.NodeA
}
// Include resolved + original candidates
found := false
for _, c := range candList {
if strings.EqualFold(c, resolvedPK) {
found = true
break
}
}
if !found {
candList = append([]string{resolvedPK}, candList...)
}
}
for _, cpk := range candList {
candNeighbors := resolvedNeighbors[cpk]
j := jaccardSimilarity(knownNeighbors, candNeighbors)
dc := DebugCandidate{
Pubkey: cpk,
Score: e.Count,
Jaccard: math.Round(j*1000) / 1000,
}
if nodeMap != nil {
if info, ok := nodeMap[strings.ToLower(cpk)]; ok {
dc.Name = info.Name
}
}
candidates = append(candidates, dc)
}
// Sort candidates by Jaccard descending
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].Jaccard > candidates[j].Jaccard
})
dr := DebugResolution{
Prefix: e.Prefix,
ThresholdApplied: affinityConfidenceRatio,
KnownNode: knownNode,
}
if nodeMap != nil {
if info, ok := nodeMap[strings.ToLower(knownNode)]; ok {
dr.KnownNodeName = info.Name
}
}
// Use resolveWithContext to determine the actual 4-tier fallback path.
tier := ""
if pm != nil {
contextPubkeys := []string{knownNode}
_, tierUsed, _ := pm.resolveWithContext(e.Prefix, contextPubkeys, graph)
tier = tierUsed
}
if e.Resolved && len(candidates) > 0 {
dr.Chosen = candidates[0].Pubkey
dr.ChosenName = candidates[0].Name
dr.ChosenScore = candidates[0].Score
dr.ChosenJaccard = candidates[0].Jaccard
dr.Confidence = "HIGH"
dr.Method = "auto-resolved"
dr.Tier = tier
if len(candidates) > 1 && candidates[1].Jaccard > 0 {
dr.Ratio = math.Round(candidates[0].Jaccard/candidates[1].Jaccard*10) / 10
} else if candidates[0].Jaccard > 0 {
dr.Ratio = 999.0 // effectively infinite — JSON doesn't support Infinity
}
} else {
dr.Confidence = "AMBIGUOUS"
dr.Method = "ambiguous"
dr.Tier = tier
if len(candidates) >= 2 {
dr.ChosenScore = candidates[0].Score
dr.ChosenJaccard = candidates[0].Jaccard
if candidates[1].Jaccard > 0 {
dr.Ratio = math.Round(candidates[0].Jaccard/candidates[1].Jaccard*10) / 10
}
}
}
dr.Candidates = candidates
resolutions = append(resolutions, dr)
}
return resolutions
}
// computeColdStartCoverage returns the % of active 1-byte hex prefixes with ≥3 observations.
func (s *Server) computeColdStartCoverage(edges []*NeighborEdge) float64 {
// Track which 1-byte prefixes have sufficient observations
prefixObs := make(map[string]int) // 1-byte prefix → total observations
for _, e := range edges {
if len(e.Prefix) == 2 { // 1-byte = 2 hex chars
prefixObs[strings.ToLower(e.Prefix)] += e.Count
}
}
if len(prefixObs) == 0 {
return 0
}
covered := 0
for _, count := range prefixObs {
if count >= affinityMinObservations {
covered++
}
}
return math.Round(float64(covered)/float64(len(prefixObs))*1000) / 10
}
+223
View File
@@ -0,0 +1,223 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestDebugAffinityEndpoint(t *testing.T) {
now := time.Now()
edge1 := newEdge("aaaa1111", "bbbb2222", "bb", 50, now)
edge2 := newEdge("aaaa1111", "", "cc", 10, now)
edge2.Ambiguous = true
edge2.Candidates = []string{"cccc3333", "cccc4444"}
graph := makeTestGraph(edge1, edge2)
srv := makeTestServer(graph)
srv.cfg = &Config{APIKey: "test-key", DebugAffinity: true}
r, _ := http.NewRequest("GET", "/api/debug/affinity", nil)
r.Header.Set("X-API-Key", "test-key")
w := httptest.NewRecorder()
srv.handleDebugAffinity(w, r)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp DebugAffinityResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode error: %v", err)
}
if len(resp.Edges) != 2 {
t.Errorf("expected 2 edges, got %d", len(resp.Edges))
}
// Check stats shape
if resp.Stats.TotalEdges != 2 {
t.Errorf("expected 2 total edges in stats, got %d", resp.Stats.TotalEdges)
}
if resp.Stats.LastRebuild == "" {
t.Error("expected lastRebuild to be set")
}
if resp.Stats.CacheAge == "" {
t.Error("expected cacheAge to be set")
}
}
func TestDebugAffinityPrefixFilter(t *testing.T) {
now := time.Now()
edge1 := newEdge("aaaa1111", "bbbb2222", "bb", 50, now)
edge2 := newEdge("aaaa1111", "dddd3333", "dd", 30, now)
graph := makeTestGraph(edge1, edge2)
srv := makeTestServer(graph)
srv.cfg = &Config{APIKey: "test-key"}
r, _ := http.NewRequest("GET", "/api/debug/affinity?prefix=bb", nil)
r.Header.Set("X-API-Key", "test-key")
w := httptest.NewRecorder()
srv.handleDebugAffinity(w, r)
var resp DebugAffinityResponse
json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Edges) != 1 {
t.Errorf("expected 1 edge with prefix filter, got %d", len(resp.Edges))
}
}
func TestDebugAffinityNodeFilter(t *testing.T) {
now := time.Now()
edge1 := newEdge("aaaa1111", "bbbb2222", "bb", 50, now)
edge2 := newEdge("cccc3333", "dddd4444", "dd", 30, now)
graph := makeTestGraph(edge1, edge2)
srv := makeTestServer(graph)
srv.cfg = &Config{APIKey: "test-key"}
r, _ := http.NewRequest("GET", "/api/debug/affinity?node=aaaa1111", nil)
r.Header.Set("X-API-Key", "test-key")
w := httptest.NewRecorder()
srv.handleDebugAffinity(w, r)
var resp DebugAffinityResponse
json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Edges) != 1 {
t.Errorf("expected 1 edge with node filter, got %d", len(resp.Edges))
}
}
func TestDebugAffinityRequiresAuth(t *testing.T) {
graph := makeTestGraph()
srv := makeTestServer(graph)
srv.cfg = &Config{APIKey: "secret"}
r, _ := http.NewRequest("GET", "/api/debug/affinity", nil)
r.Header.Set("X-API-Key", "wrong-key")
w := httptest.NewRecorder()
// Use the requireAPIKey middleware
handler := srv.requireAPIKey(http.HandlerFunc(srv.handleDebugAffinity))
handler.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestStructuredLogging(t *testing.T) {
// Test that the logging function in the graph actually works
var logMessages []string
g := NewNeighborGraph()
g.logFn = func(prefix, msg string) {
logMessages = append(logMessages, "[affinity] resolve "+prefix+": "+msg)
}
// Add some edges that would trigger disambiguation
now := time.Now()
// Add resolved edges for neighbor sets
g.mu.Lock()
// Node aaaa has neighbors: xxxx, yyyy
e1 := &NeighborEdge{NodeA: "aaaa", NodeB: "xxxx", Prefix: "xx", Count: 10, Observers: map[string]bool{}, FirstSeen: now, LastSeen: now}
g.edges[makeEdgeKey("aaaa", "xxxx")] = e1
g.byNode["aaaa"] = append(g.byNode["aaaa"], e1)
g.byNode["xxxx"] = append(g.byNode["xxxx"], e1)
e2 := &NeighborEdge{NodeA: "aaaa", NodeB: "yyyy", Prefix: "yy", Count: 10, Observers: map[string]bool{}, FirstSeen: now, LastSeen: now}
g.edges[makeEdgeKey("aaaa", "yyyy")] = e2
g.byNode["aaaa"] = append(g.byNode["aaaa"], e2)
g.byNode["yyyy"] = append(g.byNode["yyyy"], e2)
// Candidate cccc1 also has neighbor xxxx, yyyy (high Jaccard with aaaa)
e3 := &NeighborEdge{NodeA: "cccc1", NodeB: "xxxx", Prefix: "xx", Count: 10, Observers: map[string]bool{}, FirstSeen: now, LastSeen: now}
g.edges[makeEdgeKey("cccc1", "xxxx")] = e3
g.byNode["cccc1"] = append(g.byNode["cccc1"], e3)
e4 := &NeighborEdge{NodeA: "cccc1", NodeB: "yyyy", Prefix: "yy", Count: 10, Observers: map[string]bool{}, FirstSeen: now, LastSeen: now}
g.edges[makeEdgeKey("cccc1", "yyyy")] = e4
g.byNode["cccc1"] = append(g.byNode["cccc1"], e4)
// Candidate cccc2 has no neighbors (low Jaccard)
// Add ambiguous edge: aaaa ↔ prefix:cc with candidates [cccc1, cccc2]
ambigEdge := &NeighborEdge{
NodeA: "aaaa", NodeB: "", Prefix: "cc", Count: 5,
Ambiguous: true, Candidates: []string{"cccc1", "cccc2"},
Observers: map[string]bool{}, FirstSeen: now, LastSeen: now,
}
ambigKey := makeEdgeKey("aaaa", "prefix:cc")
g.edges[ambigKey] = ambigEdge
g.byNode["aaaa"] = append(g.byNode["aaaa"], ambigEdge)
g.mu.Unlock()
// Now run disambiguate — this should trigger logging
g.disambiguate()
if len(logMessages) == 0 {
t.Error("expected at least one log message from disambiguation")
}
found := false
for _, msg := range logMessages {
if strings.Contains(msg, "[affinity] resolve cc:") {
found = true
}
}
if !found {
t.Errorf("expected log message about prefix 'cc', got: %v", logMessages)
}
}
func TestColdStartCoverage(t *testing.T) {
edges := []*NeighborEdge{
{Prefix: "aa", Count: 5},
{Prefix: "bb", Count: 3},
{Prefix: "cc", Count: 1}, // below threshold
}
srv := &Server{cfg: &Config{}}
coverage := srv.computeColdStartCoverage(edges)
// 2 out of 3 prefixes have >=3 observations = 66.7%
if coverage < 66.0 || coverage > 67.0 {
t.Errorf("expected ~66.7%% coverage, got %.1f%%", coverage)
}
}
func TestDebugResponseShape(t *testing.T) {
edge := newEdge("aaaa1111", "bbbb2222", "bb", 50, time.Now())
edge.Resolved = true
graph := makeTestGraph(edge)
srv := makeTestServer(graph)
srv.cfg = &Config{APIKey: "test-key"}
r, _ := http.NewRequest("GET", "/api/debug/affinity", nil)
r.Header.Set("X-API-Key", "test-key")
w := httptest.NewRecorder()
srv.handleDebugAffinity(w, r)
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
// Verify top-level keys
for _, key := range []string{"edges", "resolutions", "stats"} {
if _, ok := resp[key]; !ok {
t.Errorf("missing top-level key: %s", key)
}
}
stats := resp["stats"].(map[string]interface{})
for _, key := range []string{"totalEdges", "totalNodes", "resolvedCount", "ambiguousCount", "unresolvedCount", "avgConfidence", "coldStartCoverage", "cacheAge", "lastRebuild"} {
if _, ok := stats[key]; !ok {
t.Errorf("missing stats key: %s", key)
}
}
}
+86 -42
View File
@@ -2,6 +2,8 @@ package main
import (
"encoding/json"
"fmt"
"log"
"math"
"strings"
"sync"
@@ -16,7 +18,7 @@ const (
// Time-decay half-life: 7 days.
affinityHalfLifeHours = 168.0
// Cache TTL for the built graph.
neighborGraphTTL = 60 * time.Second
neighborGraphTTL = 5 * time.Minute
// Auto-resolve confidence: best must be >= this factor × second-best.
affinityConfidenceRatio = 3.0
// Minimum observation count to auto-resolve.
@@ -84,6 +86,7 @@ type NeighborGraph struct {
edges map[edgeKey]*NeighborEdge
byNode map[string][]*NeighborEdge // pubkey → edges involving this node
builtAt time.Time
logFn func(prefix, msg string) // optional structured logging callback
}
// NewNeighborGraph creates an empty graph.
@@ -124,7 +127,28 @@ func (g *NeighborGraph) IsStale() bool {
// BuildFromStore constructs the neighbor graph from all packets in the store.
// The store's read-lock must NOT be held by the caller.
func BuildFromStore(store *PacketStore) *NeighborGraph {
return BuildFromStoreWithLog(store, false)
}
// cachedToLower returns strings.ToLower(s), caching results to avoid
// repeated allocations for the same pubkey string.
func cachedToLower(cache map[string]string, s string) string {
if v, ok := cache[s]; ok {
return v
}
v := strings.ToLower(s)
cache[s] = v
return v
}
// BuildFromStoreWithLog constructs the neighbor graph, optionally logging disambiguation decisions.
func BuildFromStoreWithLog(store *PacketStore, enableLog bool) *NeighborGraph {
g := NewNeighborGraph()
if enableLog {
g.logFn = func(prefix, msg string) {
log.Printf("[affinity] resolve %s: %s", prefix, msg)
}
}
store.mu.RLock()
// Snapshot what we need under lock.
@@ -136,30 +160,27 @@ func BuildFromStore(store *PacketStore) *NeighborGraph {
// Use cached nodes+PM (avoids DB call if cache is fresh).
_, pm := store.getCachedNodesAndPM()
// Local cache for strings.ToLower — pubkeys are immutable and repeat
// across hundreds of thousands of observations.
lowerCache := make(map[string]string, 256)
// Phase 1: Extract edges from every transmission + observation.
for _, tx := range packets {
isAdvert := tx.PayloadType != nil && *tx.PayloadType == 4
fromNode := "" // originator pubkey (from byNode index key)
// Find the originator pubkey — it's the key in store.byNode.
// StoreTx doesn't store from_node directly; we find it via decoded JSON
// or the byNode index. However, iterating byNode is expensive.
// The originator pubkey is in the decoded JSON "from_node" field,
// but parsing JSON per tx is expensive too.
// Actually, let's look at how byNode is keyed.
// Looking at store.go, byNode maps pubkey → transmissions where that
// pubkey is the "from" node. We need the reverse: tx → from_node.
// The from_node is embedded in DecodedJSON.
// For efficiency, let's extract it once.
fromNode = extractFromNode(tx)
fromNode := extractFromNode(tx)
// Pre-compute lowered originator once per tx (not per observation).
fromLower := ""
if fromNode != "" {
fromLower = cachedToLower(lowerCache, fromNode)
}
for _, obs := range tx.Observations {
path := parsePathJSON(obs.PathJSON)
observerPK := strings.ToLower(obs.ObserverID)
observerPK := cachedToLower(lowerCache, obs.ObserverID)
if len(path) == 0 {
// Zero-hop
if isAdvert && fromNode != "" {
fromLower := strings.ToLower(fromNode)
if isAdvert && fromLower != "" {
if fromLower != observerPK { // self-edge guard
g.upsertEdge(fromLower, observerPK, "", observerPK, obs.SNR, parseTimestamp(obs.Timestamp))
}
@@ -168,20 +189,19 @@ func BuildFromStore(store *PacketStore) *NeighborGraph {
}
// Edge 1: originator ↔ path[0] — ADVERTs only
if isAdvert && fromNode != "" {
firstHop := strings.ToLower(path[0])
fromLower := strings.ToLower(fromNode)
if isAdvert && fromLower != "" {
firstHop := cachedToLower(lowerCache, path[0])
if fromLower != firstHop { // self-edge guard (shouldn't happen but spec says check)
candidates := pm.m[firstHop]
g.upsertEdgeWithCandidates(fromLower, firstHop, candidates, observerPK, obs.SNR, parseTimestamp(obs.Timestamp))
g.upsertEdgeWithCandidates(fromLower, firstHop, candidates, observerPK, obs.SNR, parseTimestamp(obs.Timestamp), lowerCache)
}
}
// Edge 2: observer ↔ path[last] — ALL packet types
lastHop := strings.ToLower(path[len(path)-1])
lastHop := cachedToLower(lowerCache, path[len(path)-1])
if observerPK != lastHop { // self-edge guard
candidates := pm.m[lastHop]
g.upsertEdgeWithCandidates(observerPK, lastHop, candidates, observerPK, obs.SNR, parseTimestamp(obs.Timestamp))
g.upsertEdgeWithCandidates(observerPK, lastHop, candidates, observerPK, obs.SNR, parseTimestamp(obs.Timestamp), lowerCache)
}
}
}
@@ -196,25 +216,21 @@ func BuildFromStore(store *PacketStore) *NeighborGraph {
return g
}
// extractFromNode pulls the from_node pubkey from a StoreTx.
// It looks in DecodedJSON for "from_node" or "from".
// extractFromNode pulls the originator pubkey from a StoreTx's DecodedJSON.
// ADVERTs use "pubKey", other packets may use "from_node" or "from".
// Uses the cached ParsedDecoded() accessor to avoid repeated json.Unmarshal.
func extractFromNode(tx *StoreTx) string {
if tx.DecodedJSON == "" {
decoded := tx.ParsedDecoded()
if decoded == nil {
return ""
}
// Fast path: look for "from_node" key.
var decoded map[string]interface{}
if err := jsonUnmarshalFast(tx.DecodedJSON, &decoded); err != nil {
return ""
}
if v, ok := decoded["from_node"]; ok {
if s, ok := v.(string); ok {
return s
}
}
if v, ok := decoded["from"]; ok {
if s, ok := v.(string); ok {
return s
// ADVERTs store the originator pubkey as "pubKey"; other packets may use
// "from_node" or "from". Check all three so we never miss the originator.
for _, field := range []string{"pubKey", "from_node", "from"} {
if v, ok := decoded[field]; ok {
if s, ok := v.(string); ok && s != "" {
return s
}
}
}
return ""
@@ -264,9 +280,9 @@ func (g *NeighborGraph) upsertEdge(pubkeyA, pubkeyB, prefix, observer string, sn
}
// upsertEdgeWithCandidates handles prefix-based edges that may be ambiguous.
func (g *NeighborGraph) upsertEdgeWithCandidates(knownPK, prefix string, candidates []nodeInfo, observer string, snr *float64, ts time.Time) {
func (g *NeighborGraph) upsertEdgeWithCandidates(knownPK, prefix string, candidates []nodeInfo, observer string, snr *float64, ts time.Time, lc map[string]string) {
if len(candidates) == 1 {
resolved := strings.ToLower(candidates[0].PublicKey)
resolved := cachedToLower(lc, candidates[0].PublicKey)
if resolved == knownPK {
return // self-edge guard
}
@@ -277,7 +293,7 @@ func (g *NeighborGraph) upsertEdgeWithCandidates(knownPK, prefix string, candida
// Filter out self from candidates
filtered := make([]string, 0, len(candidates))
for _, c := range candidates {
pk := strings.ToLower(c.PublicKey)
pk := cachedToLower(lc, c.PublicKey)
if pk != knownPK {
filtered = append(filtered, pk)
}
@@ -407,12 +423,32 @@ func (g *NeighborGraph) disambiguate() {
if secondBest.jaccard == 0 {
// If second-best is 0 and best > 0, ratio is infinite → resolve.
if best.jaccard > 0 {
if g.logFn != nil {
g.logFn(e.Prefix, fmt.Sprintf("%s score=%d Jaccard=%.2f vs %s score=%d Jaccard=%.2f → neighbor_affinity (ratio ∞)",
best.pubkey[:minLen(best.pubkey, 8)], e.Count, best.jaccard,
secondBest.pubkey[:minLen(secondBest.pubkey, 8)], e.Count, secondBest.jaccard))
}
g.resolveEdge(key, e, knownNode, best.pubkey)
}
} else if best.jaccard/secondBest.jaccard >= affinityConfidenceRatio {
ratio := best.jaccard / secondBest.jaccard
if g.logFn != nil {
g.logFn(e.Prefix, fmt.Sprintf("%s score=%d Jaccard=%.2f vs %s score=%d Jaccard=%.2f → neighbor_affinity (ratio %.1f×)",
best.pubkey[:minLen(best.pubkey, 8)], e.Count, best.jaccard,
secondBest.pubkey[:minLen(secondBest.pubkey, 8)], e.Count, secondBest.jaccard, ratio))
}
g.resolveEdge(key, e, knownNode, best.pubkey)
} else {
// Ambiguous
if g.logFn != nil {
ratio := 0.0
if secondBest.jaccard > 0 {
ratio = best.jaccard / secondBest.jaccard
}
g.logFn(e.Prefix, fmt.Sprintf("scores too close (Jaccard %.2f vs %.2f, ratio %.1f×) → ambiguous, returning %d candidates",
best.jaccard, secondBest.jaccard, ratio, len(e.Candidates)))
}
}
// Otherwise remain ambiguous.
}
}
@@ -498,3 +534,11 @@ func parseTimestamp(s string) time.Time {
return time.Time{}
}
// minLen returns the smaller of n and len(s).
func minLen(s string, n int) int {
if len(s) < n {
return len(s)
}
return n
}
+194
View File
@@ -622,6 +622,83 @@ func TestBuildNeighborGraph_ADVERTOnlyConstraint(t *testing.T) {
}
}
// ngPubKeyJSON creates decoded JSON using the real ADVERT format ("pubKey" field).
func ngPubKeyJSON(pubkey string) string {
b, _ := json.Marshal(map[string]string{"pubKey": pubkey})
return string(b)
}
func TestBuildNeighborGraph_AdvertPubKeyField(t *testing.T) {
// Real ADVERTs use "pubKey", not "from_node". Verify the builder handles it.
nodes := []nodeInfo{
{PublicKey: "99bf37abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234", Name: "Originator"},
{PublicKey: "r1aabbccdd001122334455667788990011223344556677889900112233445566", Name: "R1"},
{PublicKey: "obs0000100112233445566778899001122334455667788990011223344556677", Name: "Observer"},
}
tx := ngMakeTx(1, 4, ngPubKeyJSON("99bf37abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234"), []*StoreObs{
ngMakeObs("obs0000100112233445566778899001122334455667788990011223344556677", `["r1"]`, nowStr, ngFloatPtr(-8.5)),
})
store := ngTestStore(nodes, []*StoreTx{tx})
g := BuildFromStore(store)
edges := g.AllEdges()
if len(edges) < 1 {
t.Fatalf("expected >=1 edges from ADVERT with pubKey field, got %d", len(edges))
}
// Check originator↔R1 edge exists
found := false
for _, e := range edges {
a := e.NodeA
b := e.NodeB
orig := "99bf37abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234"
r1 := "r1aabbccdd001122334455667788990011223344556677889900112233445566"
if (a == orig && b == r1) || (a == r1 && b == orig) {
found = true
}
}
if !found {
t.Error("missing originator↔R1 edge when using pubKey field (real ADVERT format)")
}
}
func TestBuildNeighborGraph_OneByteHashPrefixes(t *testing.T) {
// Real-world scenario: 1-byte hash prefixes with multiple candidates.
// Should create edges (possibly ambiguous) rather than empty graph.
nodes := []nodeInfo{
{PublicKey: "c0dedad400000000000000000000000000000000000000000000000000000001", Name: "NodeC0-1"},
{PublicKey: "c0dedad900000000000000000000000000000000000000000000000000000002", Name: "NodeC0-2"},
{PublicKey: "a3bbccdd00000000000000000000000000000000000000000000000000000003", Name: "Originator"},
{PublicKey: "obs1234500000000000000000000000000000000000000000000000000000004", Name: "Observer"},
}
// ADVERT from Originator with 1-byte path hop "c0"
tx := ngMakeTx(1, 4, ngPubKeyJSON("a3bbccdd00000000000000000000000000000000000000000000000000000003"), []*StoreObs{
ngMakeObs("obs1234500000000000000000000000000000000000000000000000000000004", `["c0"]`, nowStr, ngFloatPtr(-12)),
})
store := ngTestStore(nodes, []*StoreTx{tx})
g := BuildFromStore(store)
edges := g.AllEdges()
if len(edges) == 0 {
t.Fatal("expected non-empty edges for 1-byte hash prefix network, got 0")
}
// The originator↔c0 edge should be ambiguous (2 candidates match "c0")
var hasAmbig bool
for _, e := range edges {
if e.Ambiguous && e.Prefix == "c0" {
hasAmbig = true
if len(e.Candidates) != 2 {
t.Errorf("expected 2 candidates for prefix c0, got %d", len(e.Candidates))
}
}
}
if !hasAmbig {
// Could be resolved if one candidate was filtered — check we got some edge
t.Log("no ambiguous edge found, but edges exist — acceptable if resolved")
}
}
func TestNeighborGraph_CacheTTL(t *testing.T) {
g := NewNeighborGraph()
if !g.IsStale() {
@@ -640,3 +717,120 @@ func TestNeighborGraph_CacheTTL(t *testing.T) {
t.Error("old graph should be stale")
}
}
func TestNeighborGraph_TTLIsReasonable(t *testing.T) {
// TTL must be long enough to avoid rebuild storms on busy meshes,
// but short enough to reflect topology changes within minutes.
if neighborGraphTTL < 1*time.Minute {
t.Errorf("neighborGraphTTL too short (%v), will cause rebuild storms", neighborGraphTTL)
}
if neighborGraphTTL > 10*time.Minute {
t.Errorf("neighborGraphTTL too long (%v), topology changes will be stale", neighborGraphTTL)
}
}
func TestCachedToLower(t *testing.T) {
cache := make(map[string]string)
// Basic lowercasing
if got := cachedToLower(cache, "AABB"); got != "aabb" {
t.Errorf("expected 'aabb', got %q", got)
}
// Verify it was cached
if _, ok := cache["AABB"]; !ok {
t.Error("expected 'AABB' to be in cache")
}
// Same input returns cached result
if got := cachedToLower(cache, "AABB"); got != "aabb" {
t.Errorf("expected cached 'aabb', got %q", got)
}
// Already lowercase stays the same
if got := cachedToLower(cache, "aabb"); got != "aabb" {
t.Errorf("expected 'aabb', got %q", got)
}
// Empty string
if got := cachedToLower(cache, ""); got != "" {
t.Errorf("expected empty, got %q", got)
}
}
func TestParsedDecoded_Caching(t *testing.T) {
tx := &StoreTx{DecodedJSON: `{"pubKey":"abc123","name":"test"}`}
// First call parses
d1 := tx.ParsedDecoded()
if d1 == nil {
t.Fatal("expected non-nil parsed result")
}
if d1["pubKey"] != "abc123" {
t.Errorf("expected pubKey=abc123, got %v", d1["pubKey"])
}
// Second call must return the exact same map (pointer equality proves caching)
d2 := tx.ParsedDecoded()
if &d1 == nil || &d2 == nil {
t.Fatal("unexpected nil")
}
// Mutate d1 and verify d2 sees the mutation — proves same underlying map
d1["_sentinel"] = true
if d2["_sentinel"] != true {
t.Error("expected same map instance from second call (caching broken)")
}
delete(d1, "_sentinel") // clean up
}
func TestParsedDecoded_EmptyJSON(t *testing.T) {
tx := &StoreTx{DecodedJSON: ""}
d := tx.ParsedDecoded()
if d != nil {
t.Errorf("expected nil for empty DecodedJSON, got %v", d)
}
}
func TestParsedDecoded_InvalidJSON(t *testing.T) {
tx := &StoreTx{DecodedJSON: "not json"}
d := tx.ParsedDecoded()
if d != nil {
t.Errorf("expected nil for invalid JSON, got %v", d)
}
}
func TestExtractFromNode_UsesCachedParse(t *testing.T) {
tx := &StoreTx{DecodedJSON: `{"pubKey":"aabb1122"}`}
// First call to extractFromNode should use ParsedDecoded
from := extractFromNode(tx)
if from != "aabb1122" {
t.Errorf("expected aabb1122, got %q", from)
}
// ParsedDecoded should now be cached
d := tx.ParsedDecoded()
if d == nil || d["pubKey"] != "aabb1122" {
t.Error("expected ParsedDecoded to return cached result")
}
}
func BenchmarkBuildFromStore(b *testing.B) {
// Simulate a dataset with many packets and repeated pubkeys
nodes := []nodeInfo{
{PublicKey: "aaaa1111", Name: "NodeA"},
{PublicKey: "bbbb2222", Name: "NodeB"},
{PublicKey: "cccc3333", Name: "NodeC"},
{PublicKey: "dddd4444", Name: "NodeD"},
}
const numPackets = 1000
packets := make([]*StoreTx, 0, numPackets)
for i := 0; i < numPackets; i++ {
pt := 4 // ADVERT
packets = append(packets, &StoreTx{
ID: i,
PayloadType: &pt,
DecodedJSON: `{"pubKey":"aaaa1111"}`,
Observations: []*StoreObs{
{ObserverID: "bbbb2222", PathJSON: `["cccc"]`, Timestamp: nowStr, SNR: ngFloatPtr(-5.0)},
},
})
}
store := ngTestStore(nodes, packets)
b.ResetTimer()
for i := 0; i < b.N; i++ {
BuildFromStore(store)
}
}
+531
View File
@@ -0,0 +1,531 @@
package main
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"strings"
"time"
)
// persistSem limits concurrent async persistence goroutines to 1.
// Without this, each ingest cycle spawns a goroutine that opens a new
// SQLite RW connection; under sustained load goroutines pile up with
// no backpressure, causing contention and busy-timeout cascades.
var persistSem = make(chan struct{}, 1)
// ─── neighbor_edges table ──────────────────────────────────────────────────────
// ensureNeighborEdgesTable creates the neighbor_edges table if it doesn't exist.
// Uses a separate read-write connection since the main DB is read-only.
func ensureNeighborEdgesTable(dbPath string) error {
rw, err := openRW(dbPath)
if err != nil {
return fmt.Errorf("open rw for neighbor_edges: %w", err)
}
defer rw.Close()
_, err = rw.Exec(`CREATE TABLE IF NOT EXISTS neighbor_edges (
node_a TEXT NOT NULL,
node_b TEXT NOT NULL,
count INTEGER DEFAULT 1,
last_seen TEXT,
PRIMARY KEY (node_a, node_b)
)`)
return err
}
// loadNeighborEdgesFromDB loads all edges from the neighbor_edges table
// and builds an in-memory NeighborGraph.
func loadNeighborEdgesFromDB(conn *sql.DB) *NeighborGraph {
g := NewNeighborGraph()
rows, err := conn.Query("SELECT node_a, node_b, count, last_seen FROM neighbor_edges")
if err != nil {
log.Printf("[neighbor] failed to load neighbor_edges: %v", err)
return g
}
defer rows.Close()
count := 0
for rows.Next() {
var a, b string
var cnt int
var lastSeen sql.NullString
if err := rows.Scan(&a, &b, &cnt, &lastSeen); err != nil {
continue
}
ts := time.Time{}
if lastSeen.Valid {
ts = parseTimestamp(lastSeen.String)
}
// Build edge directly (both nodes are full pubkeys from persisted data)
key := makeEdgeKey(a, b)
g.mu.Lock()
e, exists := g.edges[key]
if !exists {
e = &NeighborEdge{
NodeA: key.A,
NodeB: key.B,
Observers: make(map[string]bool),
FirstSeen: ts,
LastSeen: ts,
Count: cnt,
}
g.edges[key] = e
g.byNode[key.A] = append(g.byNode[key.A], e)
g.byNode[key.B] = append(g.byNode[key.B], e)
} else {
e.Count += cnt
if ts.After(e.LastSeen) {
e.LastSeen = ts
}
}
g.mu.Unlock()
count++
}
if count > 0 {
g.mu.Lock()
g.builtAt = time.Now()
g.mu.Unlock()
log.Printf("[neighbor] loaded %d edges from neighbor_edges table", count)
}
return g
}
// ─── shared async persistence helper ───────────────────────────────────────────
// persistObsUpdate holds data for a resolved_path SQLite update.
type persistObsUpdate struct {
obsID int
resolvedPath string
}
// persistEdgeUpdate holds data for a neighbor_edges SQLite upsert.
type persistEdgeUpdate struct {
a, b, ts string
}
// asyncPersistResolvedPathsAndEdges writes resolved_path updates and neighbor
// edge upserts to SQLite in a background goroutine. Shared between
// IngestNewFromDB and IngestNewObservations to avoid DRY violation.
func asyncPersistResolvedPathsAndEdges(dbPath string, obsUpdates []persistObsUpdate, edgeUpdates []persistEdgeUpdate, logPrefix string) {
if len(obsUpdates) == 0 && len(edgeUpdates) == 0 {
return
}
// Try-acquire semaphore BEFORE spawning goroutine. If another
// persistence operation is already running, drop this batch —
// data lives in memory and will be backfilled on restart.
select {
case persistSem <- struct{}{}:
// Acquired — spawn goroutine to do the work.
default:
log.Printf("[store] %s skipped: persistence already in progress", logPrefix)
return
}
go func() {
defer func() { <-persistSem }()
rw, err := openRW(dbPath)
if err != nil {
log.Printf("[store] %s rw open error: %v", logPrefix, err)
return
}
defer rw.Close()
if len(obsUpdates) > 0 {
sqlTx, err := rw.Begin()
if err == nil {
stmt, err := sqlTx.Prepare("UPDATE observations SET resolved_path = ? WHERE id = ?")
if err == nil {
var firstErr error
for _, u := range obsUpdates {
if _, err := stmt.Exec(u.resolvedPath, u.obsID); err != nil && firstErr == nil {
firstErr = err
}
}
stmt.Close()
if firstErr != nil {
log.Printf("[store] %s resolved_path error (first): %v", logPrefix, firstErr)
}
} else {
log.Printf("[store] %s resolved_path prepare error: %v", logPrefix, err)
}
sqlTx.Commit()
}
}
if len(edgeUpdates) > 0 {
sqlTx, err := rw.Begin()
if err == nil {
stmt, err := sqlTx.Prepare(`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen)
VALUES (?, ?, 1, ?)
ON CONFLICT(node_a, node_b) DO UPDATE SET
count = count + 1, last_seen = MAX(last_seen, excluded.last_seen)`)
if err == nil {
var firstErr error
for _, e := range edgeUpdates {
if _, err := stmt.Exec(e.a, e.b, e.ts); err != nil && firstErr == nil {
firstErr = err
}
}
stmt.Close()
if firstErr != nil {
log.Printf("[store] %s edge error (first): %v", logPrefix, firstErr)
}
} else {
log.Printf("[store] %s edge prepare error: %v", logPrefix, err)
}
sqlTx.Commit()
}
}
}()
}
// neighborEdgesTableExists checks if the neighbor_edges table has any data.
func neighborEdgesTableExists(conn *sql.DB) bool {
var cnt int
err := conn.QueryRow("SELECT COUNT(*) FROM neighbor_edges").Scan(&cnt)
if err != nil {
return false // table doesn't exist
}
return cnt > 0
}
// buildAndPersistEdges scans all packets in the store, extracts edges per
// ADVERT/non-ADVERT rules, and persists them to SQLite.
func buildAndPersistEdges(store *PacketStore, rw *sql.DB) int {
store.mu.RLock()
packets := make([]*StoreTx, len(store.packets))
copy(packets, store.packets)
store.mu.RUnlock()
_, pm := store.getCachedNodesAndPM()
tx, err := rw.Begin()
if err != nil {
log.Printf("[neighbor] begin tx error: %v", err)
return 0
}
defer tx.Rollback()
stmt, err := tx.Prepare(`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen)
VALUES (?, ?, 1, ?)
ON CONFLICT(node_a, node_b) DO UPDATE SET
count = count + 1, last_seen = MAX(last_seen, excluded.last_seen)`)
if err != nil {
log.Printf("[neighbor] prepare stmt error: %v", err)
return 0
}
defer stmt.Close()
edgeCount := 0
var firstErr error
for _, pkt := range packets {
for _, obs := range pkt.Observations {
for _, ec := range extractEdgesFromObs(obs, pkt, pm) {
if _, err := stmt.Exec(ec.A, ec.B, ec.Timestamp); err != nil && firstErr == nil {
firstErr = err
}
edgeCount++
}
}
}
if firstErr != nil {
log.Printf("[neighbor] edge exec error (first): %v", firstErr)
}
if err := tx.Commit(); err != nil {
log.Printf("[neighbor] commit error: %v", err)
return 0
}
return edgeCount
}
// ─── resolved_path column ──────────────────────────────────────────────────────
// ensureResolvedPathColumn adds the resolved_path column to observations if missing.
func ensureResolvedPathColumn(dbPath string) error {
rw, err := openRW(dbPath)
if err != nil {
return err
}
defer rw.Close()
// Check if column already exists
rows, err := rw.Query("PRAGMA table_info(observations)")
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var cid int
var colName string
var colType sql.NullString
var notNull, pk int
var dflt sql.NullString
if rows.Scan(&cid, &colName, &colType, &notNull, &dflt, &pk) == nil && colName == "resolved_path" {
return nil // already exists
}
}
_, err = rw.Exec("ALTER TABLE observations ADD COLUMN resolved_path TEXT")
if err != nil {
return fmt.Errorf("add resolved_path column: %w", err)
}
log.Println("[store] Added resolved_path column to observations")
return nil
}
// resolvePathForObs resolves hop prefixes to full pubkeys for an observation.
// Returns nil if path is empty.
func resolvePathForObs(pathJSON, observerID string, tx *StoreTx, pm *prefixMap, graph *NeighborGraph) []*string {
hops := parsePathJSON(pathJSON)
if len(hops) == 0 {
return nil
}
// Build context pubkeys: observer + originator (if known)
contextPKs := make([]string, 0, 3)
if observerID != "" {
contextPKs = append(contextPKs, strings.ToLower(observerID))
}
fromNode := extractFromNode(tx)
if fromNode != "" {
contextPKs = append(contextPKs, strings.ToLower(fromNode))
}
resolved := make([]*string, len(hops))
for i, hop := range hops {
// Add adjacent hops as context for disambiguation
ctx := make([]string, len(contextPKs), len(contextPKs)+2)
copy(ctx, contextPKs)
// Add previously resolved hops as context
if i > 0 && resolved[i-1] != nil {
ctx = append(ctx, *resolved[i-1])
}
node, _, _ := pm.resolveWithContext(hop, ctx, graph)
if node != nil {
pk := strings.ToLower(node.PublicKey)
resolved[i] = &pk
}
}
return resolved
}
// marshalResolvedPath converts []*string to JSON for storage.
func marshalResolvedPath(rp []*string) string {
if len(rp) == 0 {
return ""
}
b, err := json.Marshal(rp)
if err != nil {
return ""
}
return string(b)
}
// unmarshalResolvedPath parses a resolved_path JSON string.
func unmarshalResolvedPath(s string) []*string {
if s == "" {
return nil
}
var result []*string
if json.Unmarshal([]byte(s), &result) != nil {
return nil
}
return result
}
// backfillResolvedPaths resolves paths for all observations that have NULL resolved_path.
func backfillResolvedPaths(store *PacketStore, dbPath string) int {
// Collect pending observations and snapshot immutable fields under read lock.
// graph is set in main.go before backfill is called; nil-safe throughout (review item #6).
type obsRef struct {
obsID int
pathJSON string
observerID string
txJSON string // snapshot of DecodedJSON for extractFromNode
payloadType *int
}
store.mu.RLock()
pm := store.nodePM
graph := store.graph
var pending []obsRef
for _, tx := range store.packets {
for _, obs := range tx.Observations {
if obs.ResolvedPath == nil && obs.PathJSON != "" && obs.PathJSON != "[]" {
pending = append(pending, obsRef{
obsID: obs.ID,
pathJSON: obs.PathJSON,
observerID: obs.ObserverID,
txJSON: tx.DecodedJSON,
payloadType: tx.PayloadType,
})
}
}
}
store.mu.RUnlock()
if len(pending) == 0 || pm == nil {
return 0
}
// Resolve paths outside the lock — resolvePathForObs only reads pm and graph.
type resolved struct {
obsID int
rp []*string
rpJSON string
}
var results []resolved
for _, ref := range pending {
// Build a minimal StoreTx for extractFromNode (only needs DecodedJSON + PayloadType).
fakeTx := &StoreTx{DecodedJSON: ref.txJSON, PayloadType: ref.payloadType}
rp := resolvePathForObs(ref.pathJSON, ref.observerID, fakeTx, pm, graph)
if len(rp) > 0 {
rpJSON := marshalResolvedPath(rp)
if rpJSON != "" {
results = append(results, resolved{ref.obsID, rp, rpJSON})
}
}
}
if len(results) == 0 {
return 0
}
// Persist to SQLite (no lock needed — separate RW connection).
rw, err := openRW(dbPath)
if err != nil {
log.Printf("[store] backfill: open rw error: %v", err)
return 0
}
defer rw.Close()
sqlTx, err := rw.Begin()
if err != nil {
log.Printf("[store] backfill: begin tx error: %v", err)
return 0
}
defer sqlTx.Rollback()
stmt, err := sqlTx.Prepare("UPDATE observations SET resolved_path = ? WHERE id = ?")
if err != nil {
log.Printf("[store] backfill: prepare error: %v", err)
return 0
}
defer stmt.Close()
var firstErr error
for _, r := range results {
if _, err := stmt.Exec(r.rpJSON, r.obsID); err != nil && firstErr == nil {
firstErr = err
}
}
if firstErr != nil {
log.Printf("[store] backfill resolved_path exec error (first): %v", firstErr)
}
if err := sqlTx.Commit(); err != nil {
log.Printf("[store] backfill: commit error: %v", err)
return 0
}
// Update in-memory state under write lock.
store.mu.Lock()
count := 0
for _, r := range results {
if obs, ok := store.byObsID[r.obsID]; ok {
obs.ResolvedPath = r.rp
count++
}
}
store.mu.Unlock()
return count
}
// ─── Shared helpers ────────────────────────────────────────────────────────────
// edgeCandidate represents an extracted edge to be persisted.
type edgeCandidate struct {
A, B, Timestamp string
}
// extractEdgesFromObs extracts neighbor edge candidates from a single observation.
// For ADVERTs: originator↔path[0] (if unambiguous). For ALL types: observer↔path[last] (if unambiguous).
// Also handles zero-hop ADVERTs (originator↔observer direct link).
func extractEdgesFromObs(obs *StoreObs, tx *StoreTx, pm *prefixMap) []edgeCandidate {
isAdvert := tx.PayloadType != nil && *tx.PayloadType == 4
fromNode := extractFromNode(tx)
path := parsePathJSON(obs.PathJSON)
observerPK := strings.ToLower(obs.ObserverID)
ts := obs.Timestamp
var edges []edgeCandidate
if len(path) == 0 {
if isAdvert && fromNode != "" {
fromLower := strings.ToLower(fromNode)
if fromLower != observerPK {
a, b := fromLower, observerPK
if a > b {
a, b = b, a
}
edges = append(edges, edgeCandidate{a, b, ts})
}
}
return edges
}
// Edge 1: originator ↔ path[0] — ADVERTs only (resolve prefix to full pubkey)
if isAdvert && fromNode != "" && pm != nil {
firstHop := strings.ToLower(path[0])
fromLower := strings.ToLower(fromNode)
candidates := pm.m[firstHop]
if len(candidates) == 1 {
resolved := strings.ToLower(candidates[0].PublicKey)
if resolved != fromLower {
a, b := fromLower, resolved
if a > b {
a, b = b, a
}
edges = append(edges, edgeCandidate{a, b, ts})
}
}
}
// Edge 2: observer ↔ path[last] — ALL packet types
if pm != nil {
lastHop := strings.ToLower(path[len(path)-1])
candidates := pm.m[lastHop]
if len(candidates) == 1 {
resolved := strings.ToLower(candidates[0].PublicKey)
if resolved != observerPK {
a, b := observerPK, resolved
if a > b {
a, b = b, a
}
edges = append(edges, edgeCandidate{a, b, ts})
}
}
}
return edges
}
// openRW opens a read-write SQLite connection (same pattern as PruneOldPackets).
func openRW(dbPath string) (*sql.DB, error) {
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_busy_timeout=10000", dbPath)
rw, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
rw.SetMaxOpenConns(1)
return rw, nil
}
+534
View File
@@ -0,0 +1,534 @@
package main
import (
"database/sql"
"encoding/json"
"path/filepath"
"strings"
"testing"
"time"
_ "modernc.org/sqlite"
)
// createTestDBWithSchema creates a temp SQLite DB with the standard schema + resolved_path column.
func createTestDBWithSchema(t *testing.T) (*DB, string) {
t.Helper()
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
conn, err := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
if err != nil {
t.Fatal(err)
}
// Create tables
conn.Exec(`CREATE TABLE transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_hex TEXT, hash TEXT UNIQUE, first_seen TEXT,
route_type INTEGER, payload_type INTEGER, payload_version INTEGER,
decoded_json TEXT
)`)
conn.Exec(`CREATE TABLE observers (
id TEXT PRIMARY KEY, name TEXT, iata TEXT
)`)
conn.Exec(`CREATE TABLE observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
observer_id TEXT, observer_name TEXT, direction TEXT,
snr REAL, rssi REAL, score INTEGER,
path_json TEXT, timestamp TEXT,
resolved_path TEXT
)`)
conn.Exec(`CREATE TABLE nodes (
public_key TEXT PRIMARY KEY, name TEXT, role TEXT,
lat REAL, lon REAL, last_seen TEXT, first_seen TEXT,
advert_count INTEGER DEFAULT 0
)`)
conn.Close()
db, err := OpenDB(dbPath)
if err != nil {
t.Fatal(err)
}
return db, dbPath
}
func TestResolvePathForObs(t *testing.T) {
// Build a prefix map with known nodes
nodes := []nodeInfo{
{PublicKey: "aabbccddee1234567890aabbccddee1234567890aabbccddee1234567890aabb", Name: "Node-AA"},
{PublicKey: "bbccddee1234567890aabbccddee1234567890aabbccddee1234567890aabb11", Name: "Node-BB"},
}
pm := buildPrefixMap(nodes)
graph := NewNeighborGraph()
tx := &StoreTx{
DecodedJSON: `{"pubKey": "originator1234567890"}`,
PayloadType: intPtr(4),
}
// Unambiguous prefixes should resolve
rp := resolvePathForObs(`["aa","bb"]`, "observer1", tx, pm, graph)
if len(rp) != 2 {
t.Fatalf("expected 2 resolved hops, got %d", len(rp))
}
if rp[0] == nil || !strings.HasPrefix(*rp[0], "aabbcc") {
t.Errorf("expected first hop to resolve to Node-AA, got %v", rp[0])
}
if rp[1] == nil || !strings.HasPrefix(*rp[1], "bbccdd") {
t.Errorf("expected second hop to resolve to Node-BB, got %v", rp[1])
}
}
func TestResolvePathForObs_EmptyPath(t *testing.T) {
pm := buildPrefixMap(nil)
rp := resolvePathForObs(`[]`, "", &StoreTx{}, pm, nil)
if rp != nil {
t.Errorf("expected nil for empty path, got %v", rp)
}
rp = resolvePathForObs("", "", &StoreTx{}, pm, nil)
if rp != nil {
t.Errorf("expected nil for empty string, got %v", rp)
}
}
func TestResolvePathForObs_Unresolvable(t *testing.T) {
nodes := []nodeInfo{
{PublicKey: "aabbccddee1234567890aabbccddee1234567890aabbccddee1234567890aabb", Name: "Node-AA"},
}
pm := buildPrefixMap(nodes)
// "zz" prefix doesn't match any node
rp := resolvePathForObs(`["zz"]`, "", &StoreTx{}, pm, nil)
if len(rp) != 1 {
t.Fatalf("expected 1 hop, got %d", len(rp))
}
if rp[0] != nil {
t.Errorf("expected nil for unresolvable hop, got %v", *rp[0])
}
}
func TestMarshalUnmarshalResolvedPath(t *testing.T) {
pk1 := "aabbccdd"
var rp []*string
rp = append(rp, &pk1, nil)
j := marshalResolvedPath(rp)
if j == "" {
t.Fatal("expected non-empty JSON")
}
parsed := unmarshalResolvedPath(j)
if len(parsed) != 2 {
t.Fatalf("expected 2 elements, got %d", len(parsed))
}
if parsed[0] == nil || *parsed[0] != "aabbccdd" {
t.Errorf("first element wrong: %v", parsed[0])
}
if parsed[1] != nil {
t.Errorf("second element should be nil, got %v", *parsed[1])
}
}
func TestMarshalResolvedPath_Empty(t *testing.T) {
if marshalResolvedPath(nil) != "" {
t.Error("expected empty for nil")
}
if marshalResolvedPath([]*string{}) != "" {
t.Error("expected empty for empty slice")
}
}
func TestUnmarshalResolvedPath_Invalid(t *testing.T) {
if unmarshalResolvedPath("") != nil {
t.Error("expected nil for empty string")
}
if unmarshalResolvedPath("not json") != nil {
t.Error("expected nil for invalid JSON")
}
}
func TestEnsureNeighborEdgesTable(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
// Create initial DB
conn, _ := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
conn.Exec("CREATE TABLE test (id INTEGER PRIMARY KEY)")
conn.Close()
if err := ensureNeighborEdgesTable(dbPath); err != nil {
t.Fatal(err)
}
// Verify table exists
conn, _ = sql.Open("sqlite", "file:"+dbPath+"?mode=ro")
defer conn.Close()
var cnt int
if err := conn.QueryRow("SELECT COUNT(*) FROM neighbor_edges").Scan(&cnt); err != nil {
t.Fatalf("neighbor_edges table not created: %v", err)
}
}
func TestLoadNeighborEdgesFromDB(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
conn, _ := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
conn.Exec(`CREATE TABLE neighbor_edges (
node_a TEXT NOT NULL, node_b TEXT NOT NULL,
count INTEGER DEFAULT 1, last_seen TEXT,
PRIMARY KEY (node_a, node_b)
)`)
conn.Exec("INSERT INTO neighbor_edges VALUES ('aaa', 'bbb', 5, '2024-01-01T00:00:00Z')")
conn.Exec("INSERT INTO neighbor_edges VALUES ('ccc', 'ddd', 3, '2024-01-02T00:00:00Z')")
g := loadNeighborEdgesFromDB(conn)
conn.Close()
// Should have 2 edges
edges := g.AllEdges()
if len(edges) != 2 {
t.Errorf("expected 2 edges, got %d", len(edges))
}
// Check neighbors
n := g.Neighbors("aaa")
if len(n) != 1 {
t.Errorf("expected 1 neighbor for aaa, got %d", len(n))
}
}
func TestStoreObsResolvedPathInBroadcast(t *testing.T) {
// Verify resolved_path appears in broadcast maps
pk := "aabbccdd"
obs := &StoreObs{
ID: 1,
ObserverID: "obs1",
ObserverName: "Observer 1",
PathJSON: `["aa"]`,
ResolvedPath: []*string{&pk},
Timestamp: "2024-01-01T00:00:00Z",
}
tx := &StoreTx{
ID: 1,
Hash: "abc123",
Observations: []*StoreObs{obs},
}
pickBestObservation(tx)
if tx.ResolvedPath == nil {
t.Fatal("expected ResolvedPath to be set on tx after pickBestObservation")
}
if *tx.ResolvedPath[0] != "aabbccdd" {
t.Errorf("expected resolved path to be aabbccdd, got %s", *tx.ResolvedPath[0])
}
}
func TestResolvedPathInTxToMap(t *testing.T) {
pk := "aabbccdd"
tx := &StoreTx{
ID: 1,
Hash: "abc123",
PathJSON: `["aa"]`,
ResolvedPath: []*string{&pk},
obsKeys: make(map[string]bool),
}
m := txToMap(tx)
rp, ok := m["resolved_path"]
if !ok {
t.Fatal("resolved_path not in txToMap output")
}
rpSlice, ok := rp.([]*string)
if !ok || len(rpSlice) != 1 || *rpSlice[0] != "aabbccdd" {
t.Errorf("unexpected resolved_path: %v", rp)
}
}
func TestResolvedPathOmittedWhenNil(t *testing.T) {
tx := &StoreTx{
ID: 1,
Hash: "abc123",
obsKeys: make(map[string]bool),
}
m := txToMap(tx)
if _, ok := m["resolved_path"]; ok {
t.Error("resolved_path should not be in map when nil")
}
}
func TestEnsureResolvedPathColumn(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
conn, _ := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
conn.Exec(`CREATE TABLE observations (
id INTEGER PRIMARY KEY, transmission_id INTEGER,
observer_id TEXT, path_json TEXT, timestamp TEXT
)`)
conn.Close()
if err := ensureResolvedPathColumn(dbPath); err != nil {
t.Fatal(err)
}
// Verify column exists
conn, _ = sql.Open("sqlite", "file:"+dbPath+"?mode=ro")
defer conn.Close()
rows, _ := conn.Query("PRAGMA table_info(observations)")
found := false
for rows.Next() {
var cid int
var colName string
var colType sql.NullString
var notNull, pk int
var dflt sql.NullString
rows.Scan(&cid, &colName, &colType, &notNull, &dflt, &pk)
if colName == "resolved_path" {
found = true
}
}
rows.Close()
if !found {
t.Error("resolved_path column not added")
}
// Running again should be idempotent
if err := ensureResolvedPathColumn(dbPath); err != nil {
t.Fatal("second call should be idempotent:", err)
}
}
func TestDBDetectsResolvedPathColumn(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
// Create DB without resolved_path
conn, _ := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
conn.Exec(`CREATE TABLE observations (id INTEGER PRIMARY KEY, observer_idx INTEGER)`)
conn.Exec(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY)`)
conn.Close()
db, err := OpenDB(dbPath)
if err != nil {
t.Fatal(err)
}
if db.hasResolvedPath {
t.Error("should not detect resolved_path when column missing")
}
db.Close()
// Add resolved_path column
conn, _ = sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
conn.Exec("ALTER TABLE observations ADD COLUMN resolved_path TEXT")
conn.Close()
db, err = OpenDB(dbPath)
if err != nil {
t.Fatal(err)
}
if !db.hasResolvedPath {
t.Error("should detect resolved_path when column exists")
}
db.Close()
}
func TestLoadWithResolvedPath(t *testing.T) {
db, dbPath := createTestDBWithSchema(t)
defer db.Close()
// Insert test data
rw, _ := openRW(dbPath)
rw.Exec(`INSERT INTO transmissions (id, hash, first_seen, payload_type, decoded_json)
VALUES (1, 'hash1', '2024-01-01T00:00:00Z', 4, '{"pubKey":"origpk"}')`)
rw.Exec(`INSERT INTO observations (id, transmission_id, observer_id, observer_name, path_json, timestamp, resolved_path)
VALUES (1, 1, 'obs1', 'Observer1', '["aa"]', '2024-01-01T00:00:00Z', '["aabbccdd"]')`)
rw.Close()
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatal(err)
}
if len(store.packets) != 1 {
t.Fatalf("expected 1 packet, got %d", len(store.packets))
}
tx := store.packets[0]
if len(tx.Observations) != 1 {
t.Fatalf("expected 1 observation, got %d", len(tx.Observations))
}
obs := tx.Observations[0]
if obs.ResolvedPath == nil {
t.Fatal("expected ResolvedPath to be loaded")
}
if len(obs.ResolvedPath) != 1 || *obs.ResolvedPath[0] != "aabbccdd" {
t.Errorf("unexpected ResolvedPath: %v", obs.ResolvedPath)
}
// Check that pickBestObservation propagated resolved_path to tx
if tx.ResolvedPath == nil || len(tx.ResolvedPath) != 1 {
t.Error("expected ResolvedPath to be propagated to tx")
}
}
func TestResolvedPathInAPIResponse(t *testing.T) {
// Test that TransmissionResp properly marshals resolved_path
pk := "aabbccddee"
resp := TransmissionResp{
ID: 1,
Hash: "test",
ResolvedPath: []*string{&pk, nil},
}
data, err := json.Marshal(resp)
if err != nil {
t.Fatal(err)
}
var m map[string]interface{}
json.Unmarshal(data, &m)
rp, ok := m["resolved_path"]
if !ok {
t.Fatal("resolved_path missing from JSON")
}
rpArr, ok := rp.([]interface{})
if !ok || len(rpArr) != 2 {
t.Fatalf("unexpected resolved_path shape: %v", rp)
}
if rpArr[0] != "aabbccddee" {
t.Errorf("first element wrong: %v", rpArr[0])
}
if rpArr[1] != nil {
t.Errorf("second element should be null: %v", rpArr[1])
}
}
func TestResolvedPathOmittedWhenEmpty(t *testing.T) {
resp := TransmissionResp{
ID: 1,
Hash: "test",
}
data, _ := json.Marshal(resp)
var m map[string]interface{}
json.Unmarshal(data, &m)
if _, ok := m["resolved_path"]; ok {
t.Error("resolved_path should be omitted when nil")
}
}
func TestExtractEdgesFromObs_AdvertNoPath(t *testing.T) {
tx := &StoreTx{
DecodedJSON: `{"pubKey":"aaaa1111"}`,
PayloadType: intPtr(4),
}
obs := &StoreObs{
ObserverID: "bbbb2222",
PathJSON: "",
Timestamp: "2024-01-01T00:00:00Z",
}
edges := extractEdgesFromObs(obs, tx, nil)
if len(edges) != 1 {
t.Fatalf("expected 1 edge for zero-hop advert, got %d", len(edges))
}
// Canonical ordering: aaaa < bbbb
if edges[0].A != "aaaa1111" || edges[0].B != "bbbb2222" {
t.Errorf("unexpected edge: %+v", edges[0])
}
}
func TestExtractEdgesFromObs_NonAdvertNoPath(t *testing.T) {
tx := &StoreTx{PayloadType: intPtr(1)}
obs := &StoreObs{ObserverID: "obs1", PathJSON: ""}
edges := extractEdgesFromObs(obs, tx, nil)
if len(edges) != 0 {
t.Errorf("expected 0 edges for non-advert without path, got %d", len(edges))
}
}
func TestExtractEdgesFromObs_WithPath(t *testing.T) {
nodes := []nodeInfo{
{PublicKey: "aabbccddee1234567890aabbccddee1234567890aabbccddee1234567890aabb", Name: "Node-AA"},
{PublicKey: "ffgghhii1234567890aabbccddee1234567890aabbccddee1234567890aabb11", Name: "Node-FF"},
}
pm := buildPrefixMap(nodes)
tx := &StoreTx{
DecodedJSON: `{"pubKey":"originator00"}`,
PayloadType: intPtr(4),
}
obs := &StoreObs{
ObserverID: "observer00",
PathJSON: `["aa","ff"]`,
Timestamp: "2024-01-01T00:00:00Z",
}
edges := extractEdgesFromObs(obs, tx, pm)
// Should get: originator↔aa (advert), observer↔ff (last hop)
if len(edges) != 2 {
t.Fatalf("expected 2 edges, got %d", len(edges))
}
}
func TestExtractEdgesFromObs_SameNodeNoEdge(t *testing.T) {
tx := &StoreTx{
DecodedJSON: `{"pubKey":"same1234"}`,
PayloadType: intPtr(4),
}
obs := &StoreObs{
ObserverID: "same1234",
PathJSON: "",
Timestamp: "2024-01-01T00:00:00Z",
}
edges := extractEdgesFromObs(obs, tx, nil)
if len(edges) != 0 {
t.Errorf("expected 0 edges when originator == observer, got %d", len(edges))
}
}
func TestPersistSemaphoreTryAcquireSkipsBatch(t *testing.T) {
// Verify that persistSem is a buffered channel of size 1.
if cap(persistSem) != 1 {
t.Errorf("persistSem capacity = %d, want 1", cap(persistSem))
}
// Acquire the semaphore to simulate an in-progress persistence.
persistSem <- struct{}{}
// asyncPersistResolvedPathsAndEdges should skip (not block, not
// spawn a goroutine) when the semaphore is already held.
done := make(chan struct{})
go func() {
asyncPersistResolvedPathsAndEdges(
"/nonexistent/path.db",
[]persistObsUpdate{{obsID: 1, resolvedPath: "x"}},
nil,
"test",
)
close(done)
}()
// If the function blocks on the semaphore instead of skipping,
// this select will hit the timeout.
select {
case <-done:
// Expected: returned immediately because semaphore was busy.
case <-time.After(500 * time.Millisecond):
<-persistSem
t.Fatal("asyncPersistResolvedPathsAndEdges blocked instead of skipping when semaphore was held")
}
<-persistSem // release
}
+134
View File
@@ -0,0 +1,134 @@
package main
import (
"fmt"
"testing"
)
// TestObsDedupCorrectness verifies that the map-based dedup produces correct
// results: no duplicate observations (same observerID + pathJSON) on a single
// transmission.
func TestObsDedupCorrectness(t *testing.T) {
tx := &StoreTx{
ID: 1,
Hash: "abc123",
obsKeys: make(map[string]bool),
}
// Add 5 unique observations
for i := 0; i < 5; i++ {
obsID := fmt.Sprintf("obs-%d", i)
pathJSON := fmt.Sprintf(`["path-%d"]`, i)
dk := obsID + "|" + pathJSON
if tx.obsKeys[dk] {
t.Fatalf("observation %d should not be a duplicate", i)
}
tx.Observations = append(tx.Observations, &StoreObs{
ID: i,
ObserverID: obsID,
PathJSON: pathJSON,
})
tx.obsKeys[dk] = true
tx.ObservationCount++
}
if tx.ObservationCount != 5 {
t.Fatalf("expected 5 observations, got %d", tx.ObservationCount)
}
// Try to add duplicates of each — all should be rejected
for i := 0; i < 5; i++ {
obsID := fmt.Sprintf("obs-%d", i)
pathJSON := fmt.Sprintf(`["path-%d"]`, i)
dk := obsID + "|" + pathJSON
if !tx.obsKeys[dk] {
t.Fatalf("observation %d should be detected as duplicate", i)
}
}
// Same observer, different path — should NOT be a duplicate
dk := "obs-0" + "|" + `["different-path"]`
if tx.obsKeys[dk] {
t.Fatal("different path should not be a duplicate")
}
// Different observer, same path — should NOT be a duplicate
dk = "obs-new" + "|" + `["path-0"]`
if tx.obsKeys[dk] {
t.Fatal("different observer should not be a duplicate")
}
}
// TestObsDedupNilMapSafety ensures obsKeys lazy init works for pre-existing
// transmissions that may not have the map initialized.
func TestObsDedupNilMapSafety(t *testing.T) {
tx := &StoreTx{ID: 1, Hash: "abc"}
// obsKeys is nil — the lazy init pattern used in IngestNewFromDB/IngestNewObservations
if tx.obsKeys == nil {
tx.obsKeys = make(map[string]bool)
}
dk := "obs1|path1"
if tx.obsKeys[dk] {
t.Fatal("should not be duplicate on empty map")
}
tx.obsKeys[dk] = true
if !tx.obsKeys[dk] {
t.Fatal("should be duplicate after insert")
}
}
// BenchmarkObsDedupMap benchmarks the map-based O(1) dedup approach.
func BenchmarkObsDedupMap(b *testing.B) {
for _, obsCount := range []int{10, 50, 100, 500} {
b.Run(fmt.Sprintf("obs=%d", obsCount), func(b *testing.B) {
// Pre-populate a tx with obsCount observations
tx := &StoreTx{
ID: 1,
obsKeys: make(map[string]bool),
}
for i := 0; i < obsCount; i++ {
obsID := fmt.Sprintf("obs-%d", i)
pathJSON := fmt.Sprintf(`["hop-%d"]`, i)
dk := obsID + "|" + pathJSON
tx.Observations = append(tx.Observations, &StoreObs{
ObserverID: obsID,
PathJSON: pathJSON,
})
tx.obsKeys[dk] = true
}
// Benchmark: check dedup for a new observation (not duplicate)
newDK := "new-obs|new-path"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = tx.obsKeys[newDK]
}
})
}
}
// BenchmarkObsDedupLinear benchmarks the old O(n) linear scan for comparison.
func BenchmarkObsDedupLinear(b *testing.B) {
for _, obsCount := range []int{10, 50, 100, 500} {
b.Run(fmt.Sprintf("obs=%d", obsCount), func(b *testing.B) {
tx := &StoreTx{ID: 1}
for i := 0; i < obsCount; i++ {
tx.Observations = append(tx.Observations, &StoreObs{
ObserverID: fmt.Sprintf("obs-%d", i),
PathJSON: fmt.Sprintf(`["hop-%d"]`, i),
})
}
newObsID := "new-obs"
newPath := "new-path"
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, existing := range tx.Observations {
if existing.ObserverID == newObsID && existing.PathJSON == newPath {
break
}
}
}
})
}
}
+309
View File
@@ -0,0 +1,309 @@
package main
import (
"encoding/json"
"net/http/httptest"
"testing"
"time"
)
// ─── resolveWithContext unit tests ─────────────────────────────────────────────
func TestResolveWithContext_UniquePrefix(t *testing.T) {
pm := buildPrefixMap([]nodeInfo{
{PublicKey: "a1b2c3d4", Name: "Node-A", HasGPS: true, Lat: 1, Lon: 2},
})
ni, confidence, _ := pm.resolveWithContext("a1b2c3d4", nil, nil)
if ni == nil || ni.Name != "Node-A" {
t.Fatal("expected Node-A")
}
if confidence != "unique_prefix" {
t.Fatalf("expected unique_prefix, got %s", confidence)
}
}
func TestResolveWithContext_NoMatch(t *testing.T) {
pm := buildPrefixMap([]nodeInfo{
{PublicKey: "a1b2c3d4", Name: "Node-A"},
})
ni, confidence, _ := pm.resolveWithContext("ff", nil, nil)
if ni != nil {
t.Fatal("expected nil")
}
if confidence != "no_match" {
t.Fatalf("expected no_match, got %s", confidence)
}
}
func TestResolveWithContext_AffinityWins(t *testing.T) {
pm := buildPrefixMap([]nodeInfo{
{PublicKey: "a1aaaaaa", Name: "Node-A1"},
{PublicKey: "a1bbbbbb", Name: "Node-A2"},
})
graph := NewNeighborGraph()
for i := 0; i < 100; i++ {
graph.upsertEdge("c0c0c0c0", "a1aaaaaa", "a1", "obs1", nil, time.Now())
}
ni, confidence, score := pm.resolveWithContext("a1", []string{"c0c0c0c0"}, graph)
if ni == nil || ni.Name != "Node-A1" {
t.Fatalf("expected Node-A1, got %v", ni)
}
if confidence != "neighbor_affinity" {
t.Fatalf("expected neighbor_affinity, got %s", confidence)
}
if score <= 0 {
t.Fatalf("expected positive score, got %f", score)
}
}
func TestResolveWithContext_AffinityTooClose_FallsToGeo(t *testing.T) {
pm := buildPrefixMap([]nodeInfo{
{PublicKey: "a1aaaaaa", Name: "Node-A1", HasGPS: true, Lat: 10, Lon: 20},
{PublicKey: "a1bbbbbb", Name: "Node-A2", HasGPS: true, Lat: 11, Lon: 21},
{PublicKey: "c0c0c0c0", Name: "Ctx", HasGPS: true, Lat: 10.1, Lon: 20.1},
})
graph := NewNeighborGraph()
for i := 0; i < 50; i++ {
graph.upsertEdge("c0c0c0c0", "a1aaaaaa", "a1", "obs1", nil, time.Now())
graph.upsertEdge("c0c0c0c0", "a1bbbbbb", "a1", "obs1", nil, time.Now())
}
ni, confidence, _ := pm.resolveWithContext("a1", []string{"c0c0c0c0"}, graph)
if ni == nil {
t.Fatal("expected a result")
}
if confidence != "geo_proximity" {
t.Fatalf("expected geo_proximity, got %s", confidence)
}
if ni.Name != "Node-A1" {
t.Fatalf("expected Node-A1 (closer to context), got %s", ni.Name)
}
}
func TestResolveWithContext_GPSPreference(t *testing.T) {
pm := buildPrefixMap([]nodeInfo{
{PublicKey: "a1aaaaaa", Name: "NoGPS"},
{PublicKey: "a1bbbbbb", Name: "HasGPS", HasGPS: true, Lat: 1, Lon: 2},
})
ni, confidence, _ := pm.resolveWithContext("a1", nil, nil)
if ni == nil || ni.Name != "HasGPS" {
t.Fatalf("expected HasGPS, got %v", ni)
}
if confidence != "gps_preference" {
t.Fatalf("expected gps_preference, got %s", confidence)
}
}
func TestResolveWithContext_FirstMatchFallback(t *testing.T) {
pm := buildPrefixMap([]nodeInfo{
{PublicKey: "a1aaaaaa", Name: "First"},
{PublicKey: "a1bbbbbb", Name: "Second"},
})
ni, confidence, _ := pm.resolveWithContext("a1", nil, nil)
if ni == nil || ni.Name != "First" {
t.Fatalf("expected First, got %v", ni)
}
if confidence != "first_match" {
t.Fatalf("expected first_match, got %s", confidence)
}
}
func TestResolveWithContext_NilGraphFallsToGPS(t *testing.T) {
pm := buildPrefixMap([]nodeInfo{
{PublicKey: "a1aaaaaa", Name: "NoGPS"},
{PublicKey: "a1bbbbbb", Name: "HasGPS", HasGPS: true, Lat: 1, Lon: 2},
})
ni, confidence, _ := pm.resolveWithContext("a1", []string{"someone"}, nil)
if ni == nil || ni.Name != "HasGPS" {
t.Fatalf("expected HasGPS, got %v", ni)
}
if confidence != "gps_preference" {
t.Fatalf("expected gps_preference, got %s", confidence)
}
}
func TestResolveWithContext_BackwardCompatResolve(t *testing.T) {
// Verify original resolve() still works unchanged
pm := buildPrefixMap([]nodeInfo{
{PublicKey: "a1aaaaaa", Name: "NoGPS"},
{PublicKey: "a1bbbbbb", Name: "HasGPS", HasGPS: true, Lat: 1, Lon: 2},
})
ni := pm.resolve("a1")
if ni == nil || ni.Name != "HasGPS" {
t.Fatalf("expected HasGPS from resolve(), got %v", ni)
}
}
// ─── geoDistApprox ─────────────────────────────────────────────────────────────
func TestGeoDistApprox_SamePoint(t *testing.T) {
d := geoDistApprox(37.0, -122.0, 37.0, -122.0)
if d != 0 {
t.Fatalf("expected 0, got %f", d)
}
}
func TestGeoDistApprox_Ordering(t *testing.T) {
d1 := geoDistApprox(37.0, -122.0, 37.01, -122.01)
d2 := geoDistApprox(37.0, -122.0, 38.0, -121.0)
if d1 >= d2 {
t.Fatal("closer point should have smaller distance")
}
}
// ─── handleResolveHops enhanced response (API tests) ───────────────────────────
func TestResolveHopsAPI_UniquePrefix(t *testing.T) {
srv, router := setupTestServer(t)
_ = srv
// Insert a unique node
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
"ff11223344", "UniqueNode", 37.0, -122.0)
srv.store.InvalidateNodeCache()
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=ff11223344", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
var result ResolveHopsResponse
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
t.Fatalf("bad JSON: %v", err)
}
hr, ok := result.Resolved["ff11223344"]
if !ok {
t.Fatal("expected hop in resolved map")
}
if hr.Confidence != "unique_prefix" {
t.Fatalf("expected unique_prefix, got %s", hr.Confidence)
}
}
func TestResolveHopsAPI_AmbiguousNoContext(t *testing.T) {
srv, router := setupTestServer(t)
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
"ee1aaaaaaa", "Node-E1", 37.0, -122.0)
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
"ee1bbbbbbb", "Node-E2", 38.0, -121.0)
srv.store.InvalidateNodeCache()
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=ee1", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
var result ResolveHopsResponse
json.Unmarshal(rr.Body.Bytes(), &result)
hr := result.Resolved["ee1"]
if hr == nil {
t.Fatal("expected hop in resolved map")
}
// With both candidates having GPS and no affinity context, the resolver
// picks the GPS-preferred candidate → confidence is "gps_preference".
if hr.Confidence != "gps_preference" {
t.Fatalf("expected gps_preference, got %s", hr.Confidence)
}
if len(hr.Candidates) != 2 {
t.Fatalf("expected 2 candidates, got %d", len(hr.Candidates))
}
for _, c := range hr.Candidates {
if c.AffinityScore != nil {
t.Fatal("expected nil affinity score without context")
}
}
}
func TestResolveHopsAPI_WithAffinityContext(t *testing.T) {
srv, router := setupTestServer(t)
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
"dd1aaaaaaa", "Node-D1", 37.0, -122.0)
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
"dd1bbbbbbb", "Node-D2", 38.0, -121.0)
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
"c0c0c0c0c0", "Context", 37.1, -122.1)
// Invalidate node cache so the PM includes newly inserted nodes.
srv.store.cacheMu.Lock()
srv.store.nodeCacheTime = time.Time{}
srv.store.cacheMu.Unlock()
// Build graph with strong affinity
graph := NewNeighborGraph()
for i := 0; i < 100; i++ {
graph.upsertEdge("c0c0c0c0c0", "dd1aaaaaaa", "dd1", "obs1", nil, time.Now())
}
graph.builtAt = time.Now()
srv.neighborMu.Lock()
srv.neighborGraph = graph
srv.neighborMu.Unlock()
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=dd1&from_node=c0c0c0c0c0", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
var result ResolveHopsResponse
json.Unmarshal(rr.Body.Bytes(), &result)
hr := result.Resolved["dd1"]
if hr == nil {
t.Fatal("expected hop in resolved map")
}
if hr.Confidence != "neighbor_affinity" {
t.Fatalf("expected neighbor_affinity, got %s", hr.Confidence)
}
if hr.BestCandidate == nil || *hr.BestCandidate != "dd1aaaaaaa" {
t.Fatalf("expected bestCandidate dd1aaaaaaa, got %v", hr.BestCandidate)
}
// Verify affinity scores present
hasScore := false
for _, c := range hr.Candidates {
if c.AffinityScore != nil && *c.AffinityScore > 0 {
hasScore = true
}
}
if !hasScore {
t.Fatal("expected at least one candidate with affinity score")
}
}
func TestResolveHopsAPI_ResponseShape(t *testing.T) {
srv, router := setupTestServer(t)
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
"bb1aaaaaaa", "Node-B1", 37.0, -122.0)
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=bb1a", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
var raw map[string]json.RawMessage
json.Unmarshal(rr.Body.Bytes(), &raw)
if _, ok := raw["resolved"]; !ok {
t.Fatal("missing 'resolved' key")
}
var resolved map[string]map[string]interface{}
json.Unmarshal(raw["resolved"], &resolved)
for _, hr := range resolved {
if _, ok := hr["confidence"]; !ok {
t.Error("missing 'confidence' field in HopResolution")
}
if _, ok := hr["candidates"]; !ok {
t.Error("missing 'candidates' field")
}
}
}
// ─── Helpers used only in this test file ───────────────────────────────────────
+312 -64
View File
@@ -115,8 +115,10 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/perf", s.handlePerf).Methods("GET")
r.Handle("/api/perf/reset", s.requireAPIKey(http.HandlerFunc(s.handlePerfReset))).Methods("POST")
r.Handle("/api/admin/prune", s.requireAPIKey(http.HandlerFunc(s.handleAdminPrune))).Methods("POST")
r.Handle("/api/debug/affinity", s.requireAPIKey(http.HandlerFunc(s.handleDebugAffinity))).Methods("GET")
// Packet endpoints
r.HandleFunc("/api/packets/observations", s.handleBatchObservations).Methods("POST")
r.HandleFunc("/api/packets/timestamps", s.handlePacketTimestamps).Methods("GET")
r.HandleFunc("/api/packets/{id}", s.handlePacketDetail).Methods("GET")
r.HandleFunc("/api/packets", s.handlePackets).Methods("GET")
@@ -144,6 +146,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/analytics/hash-sizes", s.handleAnalyticsHashSizes).Methods("GET")
r.HandleFunc("/api/analytics/hash-collisions", s.handleAnalyticsHashCollisions).Methods("GET")
r.HandleFunc("/api/analytics/subpaths", s.handleAnalyticsSubpaths).Methods("GET")
r.HandleFunc("/api/analytics/subpaths-bulk", s.handleAnalyticsSubpathsBulk).Methods("GET")
r.HandleFunc("/api/analytics/subpath-detail", s.handleAnalyticsSubpathDetail).Methods("GET")
r.HandleFunc("/api/analytics/neighbor-graph", s.handleNeighborGraph).Methods("GET")
@@ -252,6 +255,7 @@ func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) {
ExternalUrls: s.cfg.ExternalUrls,
PropagationBufferMs: float64(s.cfg.PropagationBufferMs()),
Timestamps: s.cfg.GetTimestampConfig(),
DebugAffinity: s.cfg.DebugAffinity,
})
}
@@ -282,6 +286,26 @@ func (s *Server) handleConfigTheme(w http.ResponseWriter, r *http.Request) {
"accentHover": "#6db3ff",
"navBg": "#0f0f23",
"navBg2": "#1a1a2e",
"navText": "#ffffff",
"navTextMuted": "#cbd5e1",
"background": "#f4f5f7",
"text": "#1a1a2e",
"textMuted": "#5b6370",
"border": "#e2e5ea",
"surface1": "#ffffff",
"surface2": "#ffffff",
"surface3": "#ffffff",
"sectionBg": "#eef2ff",
"cardBg": "#ffffff",
"contentBg": "#f4f5f7",
"detailBg": "#ffffff",
"inputBg": "#ffffff",
"rowStripe": "#f9fafb",
"rowHover": "#eef2ff",
"selectedBg": "#dbeafe",
"statusGreen": "#22c55e",
"statusYellow": "#eab308",
"statusRed": "#ef4444",
}, s.cfg.Theme, theme.Theme)
nodeColors := mergeMap(map[string]interface{}{
@@ -292,15 +316,60 @@ func (s *Server) handleConfigTheme(w http.ResponseWriter, r *http.Request) {
"observer": "#8b5cf6",
}, s.cfg.NodeColors, theme.NodeColors)
themeDark := mergeMap(map[string]interface{}{}, s.cfg.ThemeDark, theme.ThemeDark)
typeColors := mergeMap(map[string]interface{}{}, s.cfg.TypeColors, theme.TypeColors)
themeDark := mergeMap(map[string]interface{}{
"accent": "#4a9eff",
"accentHover": "#6db3ff",
"navBg": "#0f0f23",
"navBg2": "#1a1a2e",
"navText": "#ffffff",
"navTextMuted": "#cbd5e1",
"background": "#0f0f23",
"text": "#e2e8f0",
"textMuted": "#a8b8cc",
"border": "#334155",
"surface1": "#1a1a2e",
"surface2": "#232340",
"cardBg": "#1a1a2e",
"contentBg": "#0f0f23",
"detailBg": "#232340",
"inputBg": "#1e1e34",
"rowStripe": "#1e1e34",
"rowHover": "#2d2d50",
"selectedBg": "#1e3a5f",
"statusGreen": "#22c55e",
"statusYellow": "#eab308",
"statusRed": "#ef4444",
"surface3": "#2d2d50",
"sectionBg": "#1e1e34",
}, s.cfg.ThemeDark, theme.ThemeDark)
typeColors := mergeMap(map[string]interface{}{
"ADVERT": "#22c55e",
"GRP_TXT": "#3b82f6",
"TXT_MSG": "#f59e0b",
"ACK": "#6b7280",
"REQUEST": "#a855f7",
"RESPONSE": "#06b6d4",
"TRACE": "#ec4899",
"PATH": "#14b8a6",
"ANON_REQ": "#f43f5e",
"UNKNOWN": "#6b7280",
}, s.cfg.TypeColors, theme.TypeColors)
var home interface{}
if theme.Home != nil {
home = theme.Home
} else if s.cfg.Home != nil {
home = s.cfg.Home
defaultHome := map[string]interface{}{
"heroTitle": "CoreScope",
"heroSubtitle": "Real-time MeshCore LoRa mesh network analyzer",
"steps": []interface{}{
map[string]interface{}{"emoji": "🔵", "title": "Connect via Bluetooth", "description": "Flash **BLE companion** firmware from [MeshCore Flasher](https://flasher.meshcore.co.uk/).\n- Screenless devices: default PIN `123456`\n- Screen devices: random PIN shown on display\n- If pairing fails: forget device, reboot, re-pair"},
map[string]interface{}{"emoji": "📻", "title": "Set the right frequency preset", "description": "**US Recommended:**\n`910.525 MHz · BW 62.5 kHz · SF 7 · CR 5`\nSelect **\"US Recommended\"** in the app or flasher."},
map[string]interface{}{"emoji": "📡", "title": "Advertise yourself", "description": "Tap the signal icon → **Flood** to broadcast your node to the mesh. Companions only advert when you trigger it manually."},
map[string]interface{}{"emoji": "🔁", "title": "Check \"Heard N repeats\"", "description": "- **\"Sent\"** = transmitted, no confirmation\n- **\"Heard 0 repeats\"** = no repeater picked it up\n- **\"Heard 1+ repeats\"** = you're on the mesh!"},
},
"footerLinks": []interface{}{
map[string]interface{}{"label": "📦 Packets", "url": "#/packets"},
map[string]interface{}{"label": "🗺️ Network Map", "url": "#/map"},
},
}
home := mergeMap(defaultHome, s.cfg.Home, theme.Home)
writeJSON(w, ThemeResponse{
Branding: branding,
@@ -651,7 +720,8 @@ func (s *Server) handlePackets(w http.ResponseWriter, r *http.Request) {
Until: r.URL.Query().Get("until"),
Region: r.URL.Query().Get("region"),
Node: r.URL.Query().Get("node"),
Order: "DESC",
Order: "DESC",
ExpandObservations: r.URL.Query().Get("expand") == "observations",
}
if r.URL.Query().Get("order") == "asc" {
q.Order = "ASC"
@@ -693,13 +763,6 @@ func (s *Server) handlePackets(w http.ResponseWriter, r *http.Request) {
return
}
// Strip observations from default response
if r.URL.Query().Get("expand") != "observations" {
for _, p := range result.Packets {
delete(p, "observations")
}
}
writeJSON(w, result)
}
@@ -724,6 +787,38 @@ var muxBraceParam = regexp.MustCompile(`\{([^}]+)\}`)
// perfHexFallback matches hex IDs for perf path normalization fallback.
var perfHexFallback = regexp.MustCompile(`[0-9a-f]{8,}`)
// handleBatchObservations returns observations for multiple hashes in a single request.
// POST /api/packets/observations with JSON body: {"hashes": ["abc123", "def456", ...]}
// Response: {"results": {"abc123": [...observations...], "def456": [...], ...}}
// Limited to 200 hashes per request to prevent abuse.
func (s *Server) handleBatchObservations(w http.ResponseWriter, r *http.Request) {
var body struct {
Hashes []string `json:"hashes"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, 400, "invalid JSON body")
return
}
const maxHashes = 200
if len(body.Hashes) > maxHashes {
writeError(w, 400, fmt.Sprintf("too many hashes (max %d)", maxHashes))
return
}
if len(body.Hashes) == 0 {
writeJSON(w, map[string]interface{}{"results": map[string]interface{}{}})
return
}
results := make(map[string][]ObservationResp, len(body.Hashes))
if s.store != nil {
for _, hash := range body.Hashes {
obs := s.store.GetObservationsForHash(hash)
results[hash] = mapSliceToObservations(obs)
}
}
writeJSON(w, map[string]interface{}{"results": results})
}
func (s *Server) handlePacketDetail(w http.ResponseWriter, r *http.Request) {
param := mux.Vars(r)["id"]
var packet map[string]interface{}
@@ -998,16 +1093,44 @@ func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) {
return
}
prefix1 := strings.ToLower(pubkey)
if len(prefix1) > 2 {
prefix1 = prefix1[:2]
}
prefix2 := strings.ToLower(pubkey)
// Use the precomputed byPathHop index instead of scanning all packets.
// Look up by full pubkey (resolved hops) and by short prefixes (raw hops).
lowerPK := strings.ToLower(pubkey)
prefix2 := lowerPK
if len(prefix2) > 4 {
prefix2 = prefix2[:4]
}
prefix1 := lowerPK
if len(prefix1) > 2 {
prefix1 = prefix1[:2]
}
s.store.mu.RLock()
_, pm := s.store.getCachedNodesAndPM()
// Collect candidate transmissions from the index, deduplicating by tx ID.
seen := make(map[int]bool)
var candidates []*StoreTx
addCandidates := func(key string) {
for _, tx := range s.store.byPathHop[key] {
if !seen[tx.ID] {
seen[tx.ID] = true
candidates = append(candidates, tx)
}
}
}
addCandidates(lowerPK) // full pubkey match (from resolved_path)
addCandidates(prefix1) // 2-char raw hop match
addCandidates(prefix2) // 4-char raw hop match
// Also check any raw hops that start with prefix2 (longer prefixes).
// Raw hops are typically 2 chars, so iterate only keys with HasPrefix
// on the small set of index keys rather than all packets.
for key := range s.store.byPathHop {
if len(key) > 4 && len(key) < len(lowerPK) && strings.HasPrefix(key, prefix2) {
addCandidates(key)
}
}
type pathAgg struct {
Hops []PathHopResp
Count int
@@ -1021,28 +1144,13 @@ func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) {
if cached, ok := hopCache[hop]; ok {
return cached
}
r := pm.resolve(hop)
r, _, _ := pm.resolveWithContext(hop, nil, s.store.graph)
hopCache[hop] = r
return r
}
for _, tx := range s.store.packets {
hops := txGetParsedPath(tx)
if len(hops) == 0 {
continue
}
found := false
for _, hop := range hops {
hl := strings.ToLower(hop)
if hl == prefix1 || hl == prefix2 || strings.HasPrefix(hl, prefix2) {
found = true
break
}
}
if !found {
continue
}
for _, tx := range candidates {
totalTransmissions++
hops := txGetParsedPath(tx)
resolvedHops := make([]PathHopResp, len(hops))
sigParts := make([]string, len(hops))
for i, hop := range hops {
@@ -1270,6 +1378,57 @@ func (s *Server) handleAnalyticsSubpaths(w http.ResponseWriter, r *http.Request)
})
}
// handleAnalyticsSubpathsBulk returns multiple length-range buckets in a single
// response, avoiding repeated scans of the same packet data. Query format:
// ?groups=2-2:50,3-3:30,4-4:20,5-8:15 (minLen-maxLen:limit per group)
func (s *Server) handleAnalyticsSubpathsBulk(w http.ResponseWriter, r *http.Request) {
region := r.URL.Query().Get("region")
groupsParam := r.URL.Query().Get("groups")
if groupsParam == "" {
writeJSON(w, ErrorResp{Error: "groups parameter required (e.g. groups=2-2:50,3-3:30)"})
return
}
var groups []subpathGroup
for _, g := range strings.Split(groupsParam, ",") {
parts := strings.SplitN(g, ":", 2)
if len(parts) != 2 {
writeJSON(w, ErrorResp{Error: "invalid group format: " + g})
return
}
rangeParts := strings.SplitN(parts[0], "-", 2)
if len(rangeParts) != 2 {
writeJSON(w, ErrorResp{Error: "invalid range format: " + parts[0]})
return
}
mn, err1 := strconv.Atoi(rangeParts[0])
mx, err2 := strconv.Atoi(rangeParts[1])
lim, err3 := strconv.Atoi(parts[1])
if err1 != nil || err2 != nil || err3 != nil || mn < 2 || mx < mn || lim < 1 {
writeJSON(w, ErrorResp{Error: "invalid group: " + g})
return
}
groups = append(groups, subpathGroup{mn, mx, lim})
}
if s.store == nil {
results := make([]map[string]interface{}, len(groups))
for i := range groups {
results[i] = map[string]interface{}{"subpaths": []interface{}{}, "totalPaths": 0}
}
writeJSON(w, map[string]interface{}{"results": results})
return
}
results := s.store.GetAnalyticsSubpathsBulk(region, groups)
writeJSON(w, map[string]interface{}{"results": results})
}
// subpathGroup defines a length-range + limit for the bulk subpaths endpoint.
type subpathGroup struct {
MinLen, MaxLen, Limit int
}
func (s *Server) handleAnalyticsSubpathDetail(w http.ResponseWriter, r *http.Request) {
hops := r.URL.Query().Get("hops")
if hops == "" {
@@ -1309,43 +1468,128 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) {
hops := strings.Split(hopsParam, ",")
resolved := map[string]*HopResolution{}
// Context for affinity-based disambiguation.
fromNode := r.URL.Query().Get("from_node")
observer := r.URL.Query().Get("observer")
var contextPubkeys []string
if fromNode != "" {
contextPubkeys = append(contextPubkeys, fromNode)
}
if observer != "" {
contextPubkeys = append(contextPubkeys, observer)
}
// Get the neighbor graph for affinity scoring (may be nil).
var graph *NeighborGraph
if len(contextPubkeys) > 0 {
graph = s.getNeighborGraph()
}
// Get the server's prefix map for resolveWithContext.
var pm *prefixMap
if s.store != nil {
s.store.mu.RLock()
_, pm = s.store.getCachedNodesAndPM()
s.store.mu.RUnlock()
}
for _, hop := range hops {
if hop == "" {
continue
}
hopLower := strings.ToLower(hop)
rows, err := s.db.conn.Query("SELECT public_key, name, lat, lon FROM nodes WHERE LOWER(public_key) LIKE ?", hopLower+"%")
if err != nil {
resolved[hop] = &HopResolution{Name: nil, Candidates: []HopCandidate{}, Conflicts: []interface{}{}}
continue
}
// Resolve candidates from the in-memory prefix map instead of
// issuing per-hop DB queries (fixes N+1 pattern, see #369).
var candidates []HopCandidate
for rows.Next() {
var pk string
var name sql.NullString
var lat, lon sql.NullFloat64
rows.Scan(&pk, &name, &lat, &lon)
candidates = append(candidates, HopCandidate{
Name: nullStr(name), Pubkey: pk,
Lat: nullFloat(lat), Lon: nullFloat(lon),
})
if pm != nil {
if matched, ok := pm.m[hopLower]; ok {
for _, ni := range matched {
c := HopCandidate{Pubkey: ni.PublicKey}
if ni.Name != "" {
c.Name = ni.Name
}
if ni.HasGPS {
c.Lat = ni.Lat
c.Lon = ni.Lon
}
candidates = append(candidates, c)
}
}
}
rows.Close()
if len(candidates) == 0 {
resolved[hop] = &HopResolution{Name: nil, Candidates: []HopCandidate{}, Conflicts: []interface{}{}}
resolved[hop] = &HopResolution{Name: nil, Candidates: []HopCandidate{}, Conflicts: []interface{}{}, Confidence: "no_match"}
} else if len(candidates) == 1 {
resolved[hop] = &HopResolution{
Name: candidates[0].Name, Pubkey: candidates[0].Pubkey,
Candidates: candidates, Conflicts: []interface{}{},
Confidence: "unique_prefix",
}
} else {
// Compute affinity scores for each candidate if we have context.
if graph != nil && len(contextPubkeys) > 0 {
now := time.Now()
for i := range candidates {
candPK := strings.ToLower(candidates[i].Pubkey)
bestScore := 0.0
for _, ctxPK := range contextPubkeys {
edges := graph.Neighbors(strings.ToLower(ctxPK))
for _, e := range edges {
if e.Ambiguous {
continue
}
otherPK := e.NodeA
if strings.EqualFold(otherPK, ctxPK) {
otherPK = e.NodeB
}
if strings.EqualFold(otherPK, candPK) {
sc := e.Score(now)
if sc > bestScore {
bestScore = sc
}
}
}
}
if bestScore > 0 {
s := bestScore
candidates[i].AffinityScore = &s
}
}
}
// Use resolveWithContext for 4-tier disambiguation.
var best *nodeInfo
var confidence string
if pm != nil {
best, confidence, _ = pm.resolveWithContext(hopLower, contextPubkeys, graph)
}
ambig := true
resolved[hop] = &HopResolution{
hr := &HopResolution{
Name: candidates[0].Name, Pubkey: candidates[0].Pubkey,
Ambiguous: &ambig, Candidates: candidates, Conflicts: hopCandidatesToConflicts(candidates),
Confidence: "ambiguous",
}
// Use the resolved node as the default (best-effort pick).
if best != nil {
hr.Name = best.Name
hr.Pubkey = best.PublicKey
}
// Only promote to bestCandidate when affinity is confident.
if confidence == "neighbor_affinity" && best != nil {
pk := best.PublicKey
hr.BestCandidate = &pk
hr.Confidence = "neighbor_affinity"
} else if (confidence == "geo_proximity" || confidence == "gps_preference" || confidence == "first_match") && best != nil {
// Propagate lower-priority tiers so the API reflects the actual
// resolution strategy used, rather than collapsing everything to "ambiguous".
hr.Confidence = confidence
}
resolved[hop] = hr
}
}
writeJSON(w, ResolveHopsResponse{Resolved: resolved})
@@ -1395,8 +1639,12 @@ func (s *Server) handleObservers(w http.ResponseWriter, r *http.Request) {
oneHourAgo := time.Now().Add(-1 * time.Hour).Unix()
pktCounts := s.db.GetObserverPacketCounts(oneHourAgo)
// Batch lookup: node locations (observer ID may match a node public_key)
nodeLocations := s.db.GetNodeLocations()
// Batch lookup: node locations only for observer IDs (not all nodes)
observerIDs := make([]string, len(observers))
for i, o := range observers {
observerIDs[i] = o.ID
}
nodeLocations := s.db.GetNodeLocationsByKeys(observerIDs)
result := make([]ObserverResp, 0, len(observers))
for _, o := range observers {
@@ -1807,13 +2055,7 @@ func percentile(sorted []float64, p float64) float64 {
func sortedCopy(arr []float64) []float64 {
cp := make([]float64, len(arr))
copy(cp, arr)
for i := 0; i < len(cp); i++ {
for j := i + 1; j < len(cp); j++ {
if cp[j] < cp[i] {
cp[i], cp[j] = cp[j], cp[i]
}
}
}
sort.Float64s(cp)
return cp
}
@@ -1852,6 +2094,9 @@ func mapSliceToTransmissions(maps []map[string]interface{}) []TransmissionResp {
tx.PathJSON = m["path_json"]
tx.Direction = m["direction"]
tx.Score = m["score"]
if rp, ok := m["resolved_path"].([]*string); ok {
tx.ResolvedPath = rp
}
result = append(result, tx)
}
return result
@@ -1873,6 +2118,9 @@ func mapSliceToObservations(maps []map[string]interface{}) []ObservationResp {
obs.RSSI = m["rssi"]
obs.PathJSON = m["path_json"]
obs.Timestamp = m["timestamp"]
if rp, ok := m["resolved_path"].([]*string); ok {
obs.ResolvedPath = rp
}
result = append(result, obs)
}
return result
+450 -12
View File
@@ -1105,6 +1105,63 @@ func TestAnalyticsSubpaths(t *testing.T) {
}
}
func TestAnalyticsSubpathsBulk(t *testing.T) {
_, router := setupTestServer(t)
// Valid request with multiple groups.
req := httptest.NewRequest("GET", "/api/analytics/subpaths-bulk?groups=2-2:50,3-3:30,5-8:15", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
results, ok := body["results"].([]interface{})
if !ok {
t.Fatal("expected results array")
}
if len(results) != 3 {
t.Errorf("expected 3 result groups, got %d", len(results))
}
// Each result should have subpaths and totalPaths.
for i, r := range results {
rm, ok := r.(map[string]interface{})
if !ok {
t.Fatalf("result %d not a map", i)
}
if _, ok := rm["subpaths"]; !ok {
t.Errorf("result %d missing subpaths", i)
}
if _, ok := rm["totalPaths"]; !ok {
t.Errorf("result %d missing totalPaths", i)
}
}
// Missing groups param → error.
req2 := httptest.NewRequest("GET", "/api/analytics/subpaths-bulk", nil)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
if w2.Code != 200 {
t.Fatalf("expected 200 with error body, got %d", w2.Code)
}
var errBody map[string]interface{}
json.Unmarshal(w2.Body.Bytes(), &errBody)
if _, ok := errBody["error"]; !ok {
t.Error("expected error field for missing groups param")
}
// Invalid group format.
req3 := httptest.NewRequest("GET", "/api/analytics/subpaths-bulk?groups=bad", nil)
w3 := httptest.NewRecorder()
router.ServeHTTP(w3, req3)
var errBody3 map[string]interface{}
json.Unmarshal(w3.Body.Bytes(), &errBody3)
if _, ok := errBody3["error"]; !ok {
t.Error("expected error for invalid group format")
}
}
func TestAnalyticsSubpathDetailWithHops(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/analytics/subpath-detail?hops=aa,bb", nil)
@@ -1170,6 +1227,11 @@ func TestResolveHopsAmbiguous(t *testing.T) {
cfg := &Config{Port: 3000}
hub := NewHub()
srv := NewServer(db, cfg, hub)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
srv.store = store
router := mux.NewRouter()
srv.RegisterRoutes(router)
@@ -1596,6 +1658,47 @@ func TestConfigThemeWithCustomConfig(t *testing.T) {
}
}
func TestConfigThemeHomeDefaults(t *testing.T) {
// When no home config is set, server should return built-in defaults
db := setupTestDB(t)
seedTestData(t, db)
cfg := &Config{Port: 3000} // no Home set
hub := NewHub()
srv := NewServer(db, cfg, hub)
router := mux.NewRouter()
srv.RegisterRoutes(router)
req := httptest.NewRequest("GET", "/api/config/theme", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
home, ok := body["home"].(map[string]interface{})
if !ok || home == nil {
t.Fatal("expected non-null home object in theme response")
}
if home["heroTitle"] != "CoreScope" {
t.Errorf("expected heroTitle=CoreScope, got %v", home["heroTitle"])
}
if home["heroSubtitle"] == nil {
t.Error("expected heroSubtitle in home defaults")
}
steps, ok := home["steps"].([]interface{})
if !ok || len(steps) == 0 {
t.Error("expected non-empty steps array in home defaults")
}
footerLinks, ok := home["footerLinks"].([]interface{})
if !ok || len(footerLinks) == 0 {
t.Error("expected non-empty footerLinks array in home defaults")
}
}
func TestConfigCacheWithCustomTTL(t *testing.T) {
db := setupTestDB(t)
seedTestData(t, db)
@@ -2064,7 +2167,7 @@ tx := &StoreTx{
ID: 9000 + i,
RawHex: rawHex,
Hash: "testhash" + strconv.Itoa(i),
FirstSeen: "2024-01-01T00:00:00Z",
FirstSeen: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
PayloadType: &payloadType,
DecodedJSON: decoded,
}
@@ -2110,7 +2213,7 @@ for i, raw := range raws {
ID: 8000 + i,
RawHex: raw,
Hash: "dominant" + strconv.Itoa(i),
FirstSeen: "2024-01-01T00:00:00Z",
FirstSeen: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
PayloadType: &payloadType,
DecodedJSON: decoded,
}
@@ -2149,12 +2252,13 @@ func TestGetNodeHashSizeInfoLatestWins(t *testing.T) {
// 4 historical 1-byte adverts, then 1 recent 2-byte advert (latest).
// Mode would pick 1 (majority), but latest-wins should pick 2.
raws := []string{raw1byte, raw1byte, raw1byte, raw1byte, raw2byte}
baseTime := time.Now().UTC().Add(-1 * time.Hour)
for i, raw := range raws {
tx := &StoreTx{
ID: 7000 + i,
RawHex: raw,
Hash: "latest" + strconv.Itoa(i),
FirstSeen: "2024-01-01T0" + strconv.Itoa(i) + ":00:00Z",
FirstSeen: baseTime.Add(time.Duration(i) * time.Minute).Format("2006-01-02T15:04:05.000Z"),
PayloadType: &payloadType,
DecodedJSON: decoded,
}
@@ -2195,12 +2299,13 @@ func TestGetNodeHashSizeInfoIgnoreDirectZeroHop(t *testing.T) {
payloadType := 4
raws := []string{rawFlood2B, rawDirect0, rawFlood2B, rawDirect0, rawFlood2B}
baseTime2 := time.Now().UTC().Add(-1 * time.Hour)
for i, raw := range raws {
tx := &StoreTx{
ID: 9150 + i,
RawHex: raw,
Hash: "dirignore" + strconv.Itoa(i),
FirstSeen: "2024-01-01T0" + strconv.Itoa(i) + ":00:00Z",
FirstSeen: baseTime2.Add(time.Duration(i) * time.Minute).Format("2006-01-02T15:04:05.000Z"),
PayloadType: &payloadType,
DecodedJSON: decoded,
}
@@ -2243,7 +2348,7 @@ func TestGetNodeHashSizeInfoOnlyDirectZeroHopIgnored(t *testing.T) {
ID: 9160,
RawHex: rawDirect0,
Hash: "onlydirect0",
FirstSeen: "2024-01-01T00:00:00Z",
FirstSeen: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
PayloadType: &payloadType,
DecodedJSON: decoded,
}
@@ -2279,7 +2384,7 @@ func TestGetNodeHashSizeInfoDirectNonZeroHopCounted(t *testing.T) {
ID: 9170,
RawHex: rawDirectNonZero,
Hash: "dirnonzero0",
FirstSeen: "2024-01-01T00:00:00Z",
FirstSeen: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
PayloadType: &payloadType,
DecodedJSON: decoded,
}
@@ -2314,7 +2419,7 @@ func TestGetNodeHashSizeInfoNoAdverts(t *testing.T) {
ID: 6000,
RawHex: "0440aabb",
Hash: "noadverts0",
FirstSeen: "2024-01-01T00:00:00Z",
FirstSeen: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
PayloadType: &payloadType,
DecodedJSON: `{"pubKey":"` + pk + `"}`,
}
@@ -2356,7 +2461,7 @@ func TestHashAnalyticsZeroHopAdvert(t *testing.T) {
ID: 8000,
RawHex: raw,
Hash: "zerohop0",
FirstSeen: "2024-01-01T00:00:00Z",
FirstSeen: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
PayloadType: &payloadType,
DecodedJSON: decoded,
// No PathJSON → txGetParsedPath returns nil (zero hops)
@@ -2410,7 +2515,7 @@ func TestAnalyticsHashSizeSameNameDifferentPubkey(t *testing.T) {
ID: 6100 + i,
RawHex: raw2byte,
Hash: "samename" + strconv.Itoa(i),
FirstSeen: "2024-01-01T00:00:00Z",
FirstSeen: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
PayloadType: &payloadType,
DecodedJSON: decoded,
PathJSON: `["AABB"]`,
@@ -2450,6 +2555,158 @@ t.Errorf("field %q is null, expected []", field)
}
}
}
func TestInconsistentNodesExcludesCompanions(t *testing.T) {
// Issue #566: inconsistentNodes should only include repeaters and room servers.
db := setupTestDB(t)
seedTestData(t, db)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
payloadType := 4
// Create three nodes: repeater, room_server, companion — all with inconsistent hash sizes
nodes := []struct {
pk string
role string
}{
{"aa11111111111111111111111111111111111111111111111111111111111111", "repeater"},
{"bb22222222222222222222222222222222222222222222222222222222222222", "room_server"},
{"cc33333333333333333333333333333333333333333333333333333333333333", "companion"},
}
for ni, n := range nodes {
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, ?, ?)", n.pk, "Node-"+n.role, n.role)
decoded := `{"name":"Node-` + n.role + `","pubKey":"` + n.pk + `"}`
// Create flip-flop pattern: 1-byte, 2-byte, 1-byte (transitions=2 → inconsistent)
// Use header 0x11 (routeType=FLOOD, payloadType=4) and pathByte 0x41/0x81
// (non-zero hop count) so packets aren't skipped by direct zero-hop filter.
raws := []string{"11" + "41" + "aabb", "11" + "81" + "aabb", "11" + "41" + "aabb"}
for i, raw := range raws {
tx := &StoreTx{
ID: 7000 + ni*10 + i,
RawHex: raw,
Hash: "incon-" + n.role + strconv.Itoa(i),
FirstSeen: now,
PayloadType: &payloadType,
DecodedJSON: decoded,
}
store.packets = append(store.packets, tx)
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
}
}
cfg := &Config{Port: 3000}
hub := NewHub()
srv := NewServer(db, cfg, hub)
srv.store = store
router := mux.NewRouter()
srv.RegisterRoutes(router)
req := httptest.NewRequest("GET", "/api/analytics/hash-collisions", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
incon := body["inconsistent_nodes"].([]interface{})
for _, item := range incon {
node := item.(map[string]interface{})
role := node["role"].(string)
if role == "companion" {
t.Error("companion node should be excluded from inconsistent_nodes")
}
}
// Repeater and room_server should be present
roles := make(map[string]bool)
for _, item := range incon {
node := item.(map[string]interface{})
roles[node["role"].(string)] = true
}
if !roles["repeater"] {
t.Error("expected repeater in inconsistent_nodes")
}
if !roles["room_server"] {
t.Error("expected room_server in inconsistent_nodes")
}
}
func TestHashSizeInfoTimeWindow(t *testing.T) {
// Issue #566: adverts older than 7 days should be excluded from hash size computation.
db := setupTestDB(t)
seedTestData(t, db)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
pk := "dd44444444444444444444444444444444444444444444444444444444444444"
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'OldNode', 'repeater')", pk)
decoded := `{"name":"OldNode","pubKey":"` + pk + `"}`
payloadType := 4
// Old adverts (>7 days ago) with flip-flop pattern
// Use header 0x11 (routeType=FLOOD) and pathByte 0x41/0x81 (non-zero hop count)
// so packets aren't skipped by direct zero-hop filter.
oldTime := time.Now().UTC().Add(-10 * 24 * time.Hour).Format("2006-01-02T15:04:05.000Z")
oldRaws := []string{"11" + "41" + "aabb", "11" + "81" + "aabb", "11" + "41" + "aabb"}
for i, raw := range oldRaws {
tx := &StoreTx{
ID: 6000 + i,
RawHex: raw,
Hash: "old-" + strconv.Itoa(i),
FirstSeen: oldTime,
PayloadType: &payloadType,
DecodedJSON: decoded,
}
store.packets = append(store.packets, tx)
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
}
info := store.GetNodeHashSizeInfo()
ni := info[pk]
if ni != nil && ni.Inconsistent {
t.Error("old adverts (>7 days) should be excluded; node should not be flagged as inconsistent")
}
// Now add recent adverts with consistent hash size — should appear in info
pk2 := "ee55555555555555555555555555555555555555555555555555555555555555"
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'NewNode', 'repeater')", pk2)
decoded2 := `{"name":"NewNode","pubKey":"` + pk2 + `"}`
recentTime := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
for i := 0; i < 3; i++ {
tx := &StoreTx{
ID: 6100 + i,
RawHex: "11" + "41" + "aabb",
Hash: "new-" + strconv.Itoa(i),
FirstSeen: recentTime,
PayloadType: &payloadType,
DecodedJSON: decoded2,
}
store.packets = append(store.packets, tx)
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
}
// Invalidate cache before second call
store.hashSizeInfoMu.Lock()
store.hashSizeInfoCache = nil
store.hashSizeInfoMu.Unlock()
info2 := store.GetNodeHashSizeInfo()
ni2 := info2[pk2]
if ni2 == nil {
t.Error("recent adverts should be included in hash size info")
}
}
func TestObserverAnalyticsNoStore(t *testing.T) {
_, router := setupNoStoreServer(t)
req := httptest.NewRequest("GET", "/api/observers/obs1/analytics", nil)
@@ -3018,11 +3275,11 @@ func TestHashCollisionsWithCollision(t *testing.T) {
now := time.Now().UTC()
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
// Two nodes with same first byte 'CC', no adverts so hash_size=0 (included in all buckets)
// Two repeater nodes with same first byte 'CC' and hash_size=1
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES ('CC11223344556677', 'Node1', 'repeater', 37.5, -122.0, ?, '2026-01-01T00:00:00Z', 0)`, recent)
VALUES ('CC11223344556677', 'Node1', 'repeater', 37.5, -122.0, ?, '2026-01-01T00:00:00Z', 5)`, recent)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES ('CC99887766554433', 'Node2', 'repeater', 37.51, -122.01, ?, '2026-01-01T00:00:00Z', 0)`, recent)
VALUES ('CC99887766554433', 'Node2', 'repeater', 37.51, -122.01, ?, '2026-01-01T00:00:00Z', 5)`, recent)
cfg := &Config{Port: 3000}
hub := NewHub()
@@ -3031,6 +3288,14 @@ func TestHashCollisionsWithCollision(t *testing.T) {
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
// Inject hash_size=1 for both nodes so they appear in the 1-byte bucket
store.hashSizeInfoMu.Lock()
store.hashSizeInfoCache = map[string]*hashSizeNodeInfo{
"CC11223344556677": {HashSize: 1, AllSizes: map[int]bool{1: true}},
"CC99887766554433": {HashSize: 1, AllSizes: map[int]bool{1: true}},
}
store.hashSizeInfoAt = time.Now()
store.hashSizeInfoMu.Unlock()
srv.store = store
router := mux.NewRouter()
srv.RegisterRoutes(router)
@@ -3145,3 +3410,176 @@ func TestHashCollisionsMissingCoordinates(t *testing.T) {
}
}
}
// TestHashCollisionsOnlyRepeaters verifies that only repeater nodes
// are included in collision analysis. Companions, rooms, sensors, and
// hash_size==0 nodes are excluded — per firmware analysis, only repeaters
// forward packets and appear in path[] arrays. (#441)
func TestHashCollisionsOnlyRepeaters(t *testing.T) {
db := setupTestDB(t)
// Insert nodes sharing the same 1-byte prefix "AA":
// 1. repeater with hash_size=1 → should be counted
// 2. repeater with hash_size=0 (unknown) → should be excluded
// 3. companion with hash_size=1 → should be excluded
// 4. room with hash_size=1 → should be excluded
// 5. sensor with hash_size=1 → should be excluded
now := time.Now().Format("2006-01-02 15:04:05")
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen) VALUES
('aa11223344556677', 'Repeater1', 'repeater', ?),
('aa99887766554433', 'UnknownNode', 'repeater', ?),
('aadeadbeefcafe01', 'Companion1', 'companion', ?),
('aabbcc1122334455', 'Room1', 'room', ?),
('aabbcc9988776655', 'Sensor1', 'sensor', ?)`, now, now, now, now, now)
// We also need a second repeater with hash_size=1 and same prefix to
// confirm that genuine collisions ARE still detected.
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen) VALUES
('aa00112233445566', 'Repeater2', 'repeater', ?)`, now)
cfg := &Config{Port: 3000}
hub := NewHub()
srv := NewServer(db, cfg, hub)
store := NewPacketStore(db, nil)
store.Load()
srv.store = store
// Inject hash size info directly into the cache
store.hashSizeInfoMu.Lock()
store.hashSizeInfoCache = map[string]*hashSizeNodeInfo{
"aa11223344556677": {HashSize: 1, AllSizes: map[int]bool{1: true}},
"aa00112233445566": {HashSize: 1, AllSizes: map[int]bool{1: true}},
"aa99887766554433": {HashSize: 0, AllSizes: map[int]bool{}}, // unknown
"aadeadbeefcafe01": {HashSize: 1, AllSizes: map[int]bool{1: true}}, // companion
"aabbcc1122334455": {HashSize: 1, AllSizes: map[int]bool{1: true}}, // room
"aabbcc9988776655": {HashSize: 1, AllSizes: map[int]bool{1: true}}, // sensor
}
store.hashSizeInfoAt = time.Now()
store.hashSizeInfoMu.Unlock()
result := store.computeHashCollisions("")
bySize, ok := result["by_size"].(map[string]interface{})
if !ok {
t.Fatal("missing by_size")
}
size1, ok := bySize["1"].(map[string]interface{})
if !ok {
t.Fatal("missing by_size[1]")
}
stats, ok := size1["stats"].(map[string]interface{})
if !ok {
t.Fatal("missing stats")
}
// Only Repeater1 and Repeater2 should be in nodesForByte (hash_size=1, role=repeater).
// UnknownNode (hash_size=0), Companion1, Room1, Sensor1 must all be excluded.
nodesForByte := stats["nodes_for_byte"]
if nodesForByte != 2 {
t.Errorf("expected nodes_for_byte=2 (only repeaters with hash_size=1), got %v", nodesForByte)
}
// They share prefix "AA", so there should be exactly 1 collision entry.
collisions, ok := size1["collisions"].([]collisionEntry)
if !ok {
t.Fatalf("collisions is not []collisionEntry")
}
if len(collisions) != 1 {
t.Errorf("expected 1 collision entry, got %d", len(collisions))
}
if len(collisions) == 1 && len(collisions[0].Nodes) != 2 {
t.Errorf("expected 2 nodes in collision, got %d", len(collisions[0].Nodes))
}
}
func TestNodePathsEndpointUsesIndex(t *testing.T) {
srv, router := setupTestServer(t)
// Verify byPathHop index was built during Load
srv.store.mu.RLock()
hopKeys := len(srv.store.byPathHop)
srv.store.mu.RUnlock()
if hopKeys == 0 {
t.Fatal("byPathHop index is empty after Load")
}
// Query paths for TestRepeater (pubkey aabbccdd11223344, prefix "aa")
// Should find transmissions with hop "aa" in path
req := httptest.NewRequest("GET", "/api/nodes/aabbccdd11223344/paths", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Paths []json.RawMessage `json:"paths"`
TotalTransmissions int `json:"totalTransmissions"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("bad JSON: %v", err)
}
// Transmission 1 has path ["aa","bb"] which contains "aa" matching prefix of aabbccdd11223344
if resp.TotalTransmissions == 0 {
t.Error("expected at least 1 transmission matching node paths")
}
if len(resp.Paths) == 0 {
t.Error("expected at least 1 path group")
}
}
func TestPathHopIndexIncrementalUpdate(t *testing.T) {
// Test that addTxToPathHopIndex and removeTxFromPathHopIndex work correctly
idx := make(map[string][]*StoreTx)
pk1 := "fullpubkey1"
tx1 := &StoreTx{
ID: 1,
PathJSON: `["ab","cd"]`,
ResolvedPath: []*string{&pk1, nil},
}
addTxToPathHopIndex(idx, tx1)
// Should be indexed under "ab", "cd", and "fullpubkey1"
if len(idx["ab"]) != 1 {
t.Errorf("expected 1 entry for 'ab', got %d", len(idx["ab"]))
}
if len(idx["cd"]) != 1 {
t.Errorf("expected 1 entry for 'cd', got %d", len(idx["cd"]))
}
if len(idx["fullpubkey1"]) != 1 {
t.Errorf("expected 1 entry for resolved pubkey, got %d", len(idx["fullpubkey1"]))
}
// Add another tx with overlapping hop
tx2 := &StoreTx{
ID: 2,
PathJSON: `["ab","ef"]`,
}
addTxToPathHopIndex(idx, tx2)
if len(idx["ab"]) != 2 {
t.Errorf("expected 2 entries for 'ab', got %d", len(idx["ab"]))
}
if len(idx["ef"]) != 1 {
t.Errorf("expected 1 entry for 'ef', got %d", len(idx["ef"]))
}
// Remove tx1
removeTxFromPathHopIndex(idx, tx1)
if len(idx["ab"]) != 1 {
t.Errorf("expected 1 entry for 'ab' after removal, got %d", len(idx["ab"]))
}
if _, ok := idx["cd"]; ok {
t.Error("expected 'cd' key to be deleted after removal")
}
if _, ok := idx["fullpubkey1"]; ok {
t.Error("expected resolved pubkey key to be deleted after removal")
}
}
+1201 -427
View File
File diff suppressed because it is too large Load Diff
+15 -9
View File
@@ -240,6 +240,7 @@ type TransmissionResp struct {
SNR interface{} `json:"snr"`
RSSI interface{} `json:"rssi"`
PathJSON interface{} `json:"path_json"`
ResolvedPath []*string `json:"resolved_path,omitempty"`
Direction interface{} `json:"direction"`
Score interface{} `json:"score,omitempty"`
Observations []ObservationResp `json:"observations,omitempty"`
@@ -254,6 +255,7 @@ type ObservationResp struct {
SNR interface{} `json:"snr"`
RSSI interface{} `json:"rssi"`
PathJSON interface{} `json:"path_json"`
ResolvedPath []*string `json:"resolved_path,omitempty"`
Timestamp interface{} `json:"timestamp"`
}
@@ -873,18 +875,21 @@ type TraceResponse struct {
// ─── Resolve Hops ──────────────────────────────────────────────────────────────
type HopCandidate struct {
Name interface{} `json:"name"`
Pubkey string `json:"pubkey"`
Lat interface{} `json:"lat"`
Lon interface{} `json:"lon"`
Name interface{} `json:"name"`
Pubkey string `json:"pubkey"`
Lat interface{} `json:"lat"`
Lon interface{} `json:"lon"`
AffinityScore *float64 `json:"affinityScore"`
}
type HopResolution struct {
Name interface{} `json:"name"`
Pubkey interface{} `json:"pubkey,omitempty"`
Ambiguous *bool `json:"ambiguous,omitempty"`
Candidates []HopCandidate `json:"candidates"`
Conflicts []interface{} `json:"conflicts"`
Name interface{} `json:"name"`
Pubkey interface{} `json:"pubkey,omitempty"`
Ambiguous *bool `json:"ambiguous,omitempty"`
Candidates []HopCandidate `json:"candidates"`
Conflicts []interface{} `json:"conflicts"`
BestCandidate *string `json:"bestCandidate,omitempty"`
Confidence string `json:"confidence,omitempty"`
}
type ResolveHopsResponse struct {
@@ -921,6 +926,7 @@ type ClientConfigResponse struct {
ExternalUrls interface{} `json:"externalUrls"`
PropagationBufferMs float64 `json:"propagationBufferMs"`
Timestamps TimestampConfig `json:"timestamps"`
DebugAffinity bool `json:"debugAffinity,omitempty"`
}
// ─── IATA Coords ───────────────────────────────────────────────────────────────
+47
View File
@@ -0,0 +1,47 @@
# CoreScope v3.4 Release Notes
**The neighbor affinity release.** CoreScope now understands how nodes relate to each other — not just that they exist, but how strongly they're connected. This powers smarter hop resolution, richer node detail pages, and a new graph visualization in analytics.
---
## 🎯 Features
### Neighbor Affinity System (7 milestones)
A complete neighbor relationship engine, from backend graph building to frontend visualization:
- **Affinity graph builder** — computes neighbor relationships and connection strength from packet traffic (#507)
- **Affinity API endpoints** — REST endpoints to query neighbor data (#508)
- **Show Neighbors via affinity API** — the existing Show Neighbors feature now uses real affinity data instead of raw packet heuristics (#512, fixes #484)
- **Affinity-aware hop resolution** — hop resolver uses neighbor affinity to pick better paths (#511)
- **Node detail neighbors section** — dedicated neighbors panel on the node detail page (#510)
- **Affinity debugging tools** — inspect and troubleshoot affinity calculations (#521)
- **Neighbor graph visualization** — interactive neighbor graph in the analytics tab (#513)
### Customizer v2
- Event-driven state management replaces the old imperative approach — cleaner, more predictable theme/config updates (#503)
---
## 🐛 Bug Fixes
- **Stale parsed cache on observation packets** — observation packets now correctly invalidate the JSON parse cache (#505)
- **Null-guard rAF callbacks** — live page no longer crashes when `requestAnimationFrame` callbacks fire after cleanup (#506)
- **Customizer v2 phantom overrides** — fixed phantom config entries, missing defaults, and stale dark mode state (#520)
- **Neighbor affinity empty results** — fixed pubKey field name mismatch causing empty affinity graphs (#524)
- **Home defaults in server theme** — server-side theme config now includes home page defaults (#526)
- **Neighbor UI crash + dark mode** — fixed Show Neighbors crash and improved dark mode contrast (#527)
- **Home page steps + FAQ** — both steps AND FAQ now render correctly on the home page (#529)
---
## ⚡ Performance
- **Cached JSON.parse for packet data** — packet payloads are parsed once and cached, avoiding redundant `JSON.parse` calls on repeated access (#400)
---
## Known Limitations
- **Affinity graph scales with traffic volume** — networks with very low packet rates may show weak or missing neighbor relationships until enough data accumulates
- **Debugging tools are developer-facing** — the affinity debug panel (#521) is functional but not polished for end-user consumption
- **Customizer v2 migration** — custom themes saved under v1 may need to be re-applied after upgrade
+403
View File
@@ -0,0 +1,403 @@
# Security Analysis: MeshCore Channel Encryption
## Scope
This analysis covers MeshCore's encryption vulnerabilities in order of practical severity. Section 1 addresses PSK brute-force (the highest-priority practical threat). Sections 29 cover AES-128-ECB structural weaknesses. Section 8 covers TXT_MSG. All claims are derived from firmware source (`BaseChatMesh.cpp`, `Utils.cpp`, `Mesh.cpp`, `MeshCore.h`) unless explicitly marked as conjecture.
## 1. PSK Brute-Force with Timestamp Oracle
### 1.1 The No-KDF Design
MeshCore channel PSKs are base64-decoded directly into AES-128 keys with no key derivation function (from `BaseChatMesh::addChannel()`):
```cpp
int len = decode_base64((unsigned char *) psk_base64, strlen(psk_base64), dest->channel.secret);
```
No PBKDF2, scrypt, argon2, or HKDF is applied. The base64-decoded bytes ARE the AES key. This means:
1. **Human-memorable passphrases have drastically reduced entropy.** If a user types "SecretChannel" as their PSK, the base64-decoded output is ~10 bytes of ASCII-range values. The key space is determined by the passphrase complexity, not by AES-128's theoretical 2^128 key space.
2. **Short passphrases produce short keys.** `decode_base64` maps every 4 base64 characters to 3 bytes. A passphrase shorter than ~22 base64 characters produces fewer than 16 bytes, and the remainder of the 16-byte key buffer depends on whatever was previously in memory (likely zeros from initialization). An 8-character passphrase decodes to only 6 bytes — the effective key space may be as low as 2^48.
3. **No salt.** Identical passphrases on different meshes produce identical keys. A single precomputed dictionary attack works globally against all MeshCore deployments.
### 1.2 Timestamp as Known-Plaintext Oracle
Every GRP_TXT plaintext begins with a structured, largely predictable header:
```
Block 0: [TS₀][TS₁][TS₂][TS₃][0x00][sender_name][: ][message_start...]
```
An attacker who captures a single packet can verify a candidate PSK by:
1. Decrypting block 0 with the candidate key
2. Checking if bytes 03 produce a plausible Unix timestamp (within a reasonable window of the capture time)
3. Checking if byte 4 is 0x00 (TXT_TYPE_PLAIN)
4. Optionally checking if bytes 5+ are valid ASCII (sender name)
The timestamp alone constrains the search: a ±1-hour window around capture time yields ~7,200 valid timestamps out of 2^32 possibilities — a false-positive rate of ~1.7×10^-6. Combined with the type byte and ASCII sender-name check, false positives are effectively zero. **One captured packet is sufficient for definitive key verification.**
### 1.3 Attack Cost Estimates
Hardware assumption: commodity GPU (e.g., RTX 4090) performing ~10 billion AES-128-ECB block encryptions per second. This is conservative — optimized implementations achieve higher throughput.
| Passphrase style | Search space | Time at 10^10 AES/sec |
|---|---|---|
| Single common English word (10K-word list) | ~10^4 | microseconds |
| Single English word (170K full dictionary) | ~1.7×10^5 | microseconds |
| Two concatenated common words | ~10^8 | ~10 milliseconds |
| Three concatenated common words | ~10^12 | ~100 seconds (~2 min) |
| Four random common words (Diceware-style) | ~10^16 | ~10^6 seconds (~12 days) |
| Random 8-char alphanumeric (62^8) | ~2.2×10^14 | ~22,000 seconds (~6 hours) |
| Random 12-char alphanumeric (62^12) | ~3.2×10^21 | ~10^11 seconds (infeasible) |
| Full random 16-byte key (2^128) | ~3.4×10^38 | infeasible |
**Important caveats on search space:**
- Dictionary sizes vary: "common English words" ≈ 3K10K; full dictionary ≈ 170K. Estimates above use 10K for "common" lists.
- Humans do not choose words uniformly. Zipf's law applies — a small fraction of words account for most selections. The effective entropy is **lower** than the uniform assumption, making attacks faster.
- Concatenation without separators creates ambiguity ("therapist" = "therapist" or "the"+"rapist"), but this marginally reduces search space rather than increasing it.
- Multi-channel amortization: an attacker can test each candidate against ALL captured channels simultaneously, paying the AES cost once per candidate.
### 1.4 Attack Properties
- **Offline attack.** No rate limiting, no lockout, no detection. The attacker works entirely on captured ciphertext.
- **Single-packet verification.** One GRP_TXT packet is sufficient. No need to collect multiple messages.
- **No KDF stretching.** Each candidate requires exactly one AES-128 block decryption (16 bytes), not thousands of hash iterations.
- **Global applicability.** No salt means precomputed tables work across all MeshCore deployments using the same passphrase.
- **Side-channel exposure.** Since the PSK IS the key (no KDF), any AES key-schedule side-channel directly reveals the passphrase. PSK reuse across systems (e.g., same passphrase for MeshCore and WiFi) means compromise of one compromises both.
### 1.5 Severity Assessment
**PSK brute-force is the #1 practical threat to MeshCore channel confidentiality.** Unlike ECB frequency analysis (§5), which requires hundreds of captured messages with repeated content, PSK brute-force requires a single captured packet and succeeds whenever users choose human-memorable passphrases — which is the common case for manually-configured channels.
Any channel using a passphrase of 3 or fewer common words, or any alphanumeric string shorter than 12 characters, should be considered **vulnerable to offline brute-force within hours to days** using commodity hardware.
### 1.6 Recommended Mitigations
**Priority 0 (Critical):** Apply a memory-hard KDF (argon2id preferred; scrypt or PBKDF2 with ≥100K iterations as fallback) to derive the AES key from the passphrase. This transforms each candidate test from ~1 nanosecond to ~100 milliseconds, increasing attack cost by a factor of ~10^8.
**Priority 0a:** Add a per-channel salt (random bytes stored alongside the channel config) to prevent precomputed/global attacks.
**Priority 0b:** Document that channel PSKs should be random 16-byte keys (e.g., generated with `openssl rand -base64 22`), not human-memorable passphrases. This is a stopgap until KDF support is added.
## 2. How Encryption Works
### Constants (from `MeshCore.h`)
- `CIPHER_KEY_SIZE = 16` (AES-128)
- `PUB_KEY_SIZE = 32`
- `CIPHER_MAC_SIZE` = HMAC-SHA256 truncated output size
### encrypt() (from `Utils.cpp`)
AES-128-ECB, block-by-block. No IV, no counter, no chaining:
```cpp
aes.setKey(shared_secret, CIPHER_KEY_SIZE); // first 16 bytes of shared_secret
while (src_len >= 16) {
aes.encryptBlock(dp, src); // each 16-byte block independently
dp += 16; src += 16; src_len -= 16;
}
if (src_len > 0) { // partial final block
uint8_t tmp[16];
memset(tmp, 0, 16); // zero-fill
memcpy(tmp, src, src_len); // copy remaining bytes
aes.encryptBlock(dp, tmp);
}
```
### encryptThenMAC() (from `Utils.cpp`)
```cpp
int enc_len = encrypt(shared_secret, dest + CIPHER_MAC_SIZE, src, src_len);
SHA256 sha;
sha.resetHMAC(shared_secret, PUB_KEY_SIZE); // HMAC uses full 32 bytes
sha.update(dest + CIPHER_MAC_SIZE, enc_len);
sha.finalizeHMAC(shared_secret, PUB_KEY_SIZE, dest, CIPHER_MAC_SIZE);
```
**Key reuse flaw:** The same `shared_secret` buffer serves both AES and HMAC. AES uses `shared_secret[0..15]` (first 16 bytes). HMAC uses `shared_secret[0..31]` (full 32 bytes). The AES key is a prefix of the HMAC key. See §7 for implications.
### GRP_TXT Plaintext Construction (from `BaseChatMesh::sendGroupMessage()`)
```cpp
memcpy(temp, &timestamp, 4); // bytes 0-3: Unix timestamp (seconds)
temp[4] = 0; // byte 4: TXT_TYPE_PLAIN
sprintf((char *)&temp[5], "%s: ", sender_name); // bytes 5+: "SenderName: "
char *ep = strchr((char *)&temp[5], 0);
int prefix_len = ep - (char *)&temp[5]; // length of "SenderName: "
memcpy(ep, text, text_len); // message text (no null terminator)
ep[text_len] = 0; // null written AFTER data boundary
// data_len passed to encrypt = 5 + prefix_len + text_len
```
**The null terminator is NOT part of the encrypted data length.** The call to `createGroupDatagram` passes length `5 + prefix_len + text_len`. The null at `ep[text_len]` is written to the buffer but is beyond `data_len`. In the final partial block, `encrypt()` zero-fills with `memset(tmp, 0, 16)` before copying the remaining bytes — so a zero byte appears at the position where the null would be, but this is an artifact of zero-padding, not an explicit null in the plaintext.
On the receiving side, this is confirmed:
```cpp
data[len] = 0; // need to make a C string again, with null terminator
```
The receiver must re-add the null after decryption.
## 3. Block Layout Analysis
### Notation
Let `N` = length of sender name. Then:
- `prefix_len` = N + 2 (for ": " suffix from `sprintf("%s: ", sender_name)`)
- Header overhead = 4 (timestamp) + 1 (type) + prefix_len = N + 7 bytes
- Message text begins at byte offset N + 7
### Block 0
Block 0 = bytes 015 of plaintext:
```
[TS₀][TS₁][TS₂][TS₃][0x00][sender_name: ][...message start...]
```
The first 9 N bytes of message text fit in block 0 (when N < 9). For N ≥ 9, no message text fits in block 0.
### Boundary Condition: Sender Name ≥ 12 Characters
When N ≥ 12, the header overhead (N + 7 ≥ 19) exceeds 16 bytes. The header itself spills into block 1:
**Example: sender name "LongUserName1" (N = 13), message "hi":**
```
Header = 13 + 7 = 20 bytes. Total plaintext = 20 + 2 = 22 bytes.
Block 0 (bytes 0-15): [TS₀][TS₁][TS₂][TS₃][0x00][L][o][n][g][U][s][e][r][N][a][m]
Block 1 (bytes 16-31): [e][1][:][space][h][i][0x00 ×10] ← zero-padded partial block
```
Block 1 here contains the tail of the sender name, the ": " separator, message text, AND zero-padding. For sender names of length 1215, block 1 is a mix of header and message — **it is NOT "pure message text."**
For sender names ≥ 16, blocks 0 and 1 are both pure header, and message text doesn't begin until block 1 or later.
### General Block Content Table
| Sender name length N | Header bytes | Message starts at byte | Block 0 content | Block 1+ content |
|---|---|---|---|---|
| 18 | 815 | 815 | timestamp + header + message start | message text + zero-pad |
| 911 | 1618 | 1618 | timestamp + header (no message) | header tail + message + zero-pad |
| 1215 | 1922 | 1922 | timestamp + partial header | header tail + message + zero-pad |
| ≥16 | ≥23 | ≥23 | timestamp + partial header | header continuation, then message |
### Typical Case (N = 5, e.g. "Alice")
Header = 12 bytes. Message starts at byte 12. Block 0 holds 4 bytes of message text.
```
Message "hello world" (11 chars). Total plaintext = 12 + 11 = 23 bytes.
Block 0 (bytes 0-15): [TS₀][TS₁][TS₂][TS₃][0x00][A][l][i][c][e][:][space][h][e][l][l]
Block 1 (bytes 16-22): [o][space][w][o][r][l][d] → padded to: [o][space][w][o][r][l][d][0×9]
```
Block 1 contains 7 bytes of message text and 9 bytes of zero-padding.
## 4. Attack Surface by Block Position
### Block 0: Accidental Nonce from Timestamp
The 4-byte Unix timestamp in bytes 03 acts as an **accidental nonce** — it was included "mostly as an extra blob to help make packet_hash unique" (per firmware comment), not as a cryptographic countermeasure against ECB determinism. Nevertheless, it has the effect of making block 0's plaintext vary per message.
**Precision on uniqueness:** Block 0 is unique per (sender, timestamp-second) pair, not per message. Two messages from the same sender within the same second, on the same channel, with the same type byte, produce identical block 0 plaintext and therefore identical block 0 ciphertext. At typical mesh chat rates, same-second collisions are rare but not impossible for automated/scripted senders.
**Known-plaintext observation:** Bytes 415 of block 0 are largely predictable per sender (type byte is always 0x00 for plain text; sender name and ": " are static). The timestamp is predictable within a window (Unix seconds). An attacker who knows the sender name and approximate time can compute all 16 plaintext bytes of block 0. However, **AES-128 is resistant to known-plaintext attacks** — knowing plaintext-ciphertext pairs for block 0 does not help recover the key or decrypt other blocks.
### Blocks 1+: Deterministic ECB (for short sender names)
When the sender name is short enough that the header fits in block 0 (N ≤ 8), blocks 1+ contain **only message text and zero-padding.** No timestamp, no nonce, no per-message varying data. Identical message text at the same block offset produces identical ciphertext, always.
When N ≥ 9, block 1 contains header spillover, which includes static sender name bytes — these vary per sender but not per message, so block 1 is still deterministic for a given sender once the header portion is fixed.
**The fundamental ECB property:** For any block beyond the timestamp's reach, `E_K(P) = E_K(P)`. Same plaintext block → same ciphertext block, regardless of when or how many times it's sent.
### Partial Final Block: Strongest Attack Target
The final block of every message is zero-padded by `encrypt()` to 16 bytes. The padding bytes are deterministic and known (always 0x00). For a message whose final block contains `B` bytes of actual content:
- `B` bytes are unknown message text
- `16 - B` bytes are known zeros
When B is small (short final fragment), most of the block is known plaintext. For B = 1, the attacker knows 15 of 16 bytes — only 256 possible plaintext blocks exist. This means:
- **The final block has at most 2^(8B) possible plaintexts** (versus 2^128 for a full unknown block)
- For B ≤ 4, there are ≤ 2^32 possibilities — a small enough space for dictionary attacks given enough ciphertext samples
- The attacker can precompute all possible final-block plaintexts for small B values and match against observed ciphertext blocks
This makes the partial final block a **stronger frequency analysis target** than interior blocks, where all 16 bytes may be unknown text.
## 5. Feasible Attack Scenarios
### 4.1 Block Frequency Analysis on Blocks 1+
**Preconditions (all must hold):**
1. Attacker can observe encrypted GRP_TXT packets (passive radio capture)
2. Messages from the same sender (or senders with identical name lengths — same block alignment)
3. Messages long enough to produce blocks beyond block 0 (text > 9 N chars)
4. Sufficient message volume with repeated content at the same block positions
**Method:**
1. Collect GRP_TXT packets, group by sender hash
2. Decompose encrypted payloads into 16-byte blocks (after stripping HMAC prefix)
3. Discard block 0 (timestamp-varying)
4. Build frequency tables for blocks 1, 2, 3, etc., per sender
5. Match high-frequency ciphertext blocks against expected plaintext distributions
**Practical constraints limiting this attack:**
- LoRa bandwidth severely limits message length. Most mesh chat messages are short — many fit entirely within block 0 (≤ 9 N chars of text), yielding zero analyzable blocks.
- Messages that spill into block 1+ tend to be longer and more varied — fewer repeated patterns.
- The attack requires repeated identical 16-byte-aligned text fragments from the same sender over time.
**Conditions under which this attack succeeds:** Automated or scripted senders transmitting repetitive messages longer than block 0 capacity, on a channel with a static PSK, over an extended collection period. For human-typed conversational messages with typical length and variety, the number of repeated block 1+ patterns is likely too low for meaningful frequency analysis. (This is an empirical claim that depends on actual traffic patterns — no formal bound is established here.)
### 4.2 Partial Final Block Dictionary Attack
**Preconditions:**
1. Attacker knows (or can estimate) the message length modulo 16
2. Final block has few content bytes (B ≤ 4)
**Method:** Enumerate all 2^(8B) candidate plaintexts for the final block. Since AES-ECB is deterministic with a fixed key, the attacker can build a lookup table: if they ever observe a ciphertext block matching one of the candidates in a known-plaintext scenario (e.g., from a leaked or guessed message), they can identify which final-block value corresponds to which ciphertext.
**Limitation:** Without the key, the attacker cannot compute E_K(candidate) directly. The attack requires collecting enough ciphertext final blocks to perform frequency analysis within the reduced plaintext space. With only 256 possibilities (B=1), convergence is fast given sufficient samples.
### 4.3 Cross-Sender Correlation
Senders with identical name lengths produce identical block alignments. Messages from "Alice" (N=5) and "Bobby" (N=5) place message text at the same byte offsets. If both send the same message, their blocks 1+ are identical ciphertext — **but only if they share the same channel PSK** (same AES key). On the same channel, this enables cross-sender frequency analysis within same-name-length groups.
### 4.4 Message Length Leakage
Ciphertext length = ⌈(5 + prefix_len + text_len) / 16⌉ × 16 bytes. This reveals the message text length within a 16-byte window (not 15, because the block count is the observable quantity). Not ECB-specific — any block cipher without constant-length padding leaks this.
### 4.5 Replay Attacks
`encryptThenMAC()` authenticates the ciphertext, but if the mesh doesn't track previously-seen packet MACs, captured packets can be replayed. The embedded timestamp may be checked for staleness — this requires firmware verification beyond the scope of this analysis.
### 4.6 No Forward Secrecy
Channel PSKs are static and shared among all participants. ECDH shared secrets for direct messages are also static (no ephemeral key exchange). Compromise of any key decrypts all past and future traffic encrypted under that key.
## 6. What Known-Plaintext Does NOT Achieve
AES-128 is designed to resist known-plaintext attacks. An attacker who knows the full plaintext and ciphertext of block 0 (or any block) **cannot**:
- Recover the AES key
- Decrypt other blocks encrypted under the same key
- Derive any information about other plaintexts from their ciphertexts
The ECB weakness is **determinism** (identical plaintext → identical ciphertext), not key recovery. The attacks in §5 exploit pattern matching and frequency analysis, not cryptanalysis of AES itself.
## 7. HMAC Key Reuse: Cryptographic Design Flaw
From `encryptThenMAC()`:
- AES key: `shared_secret[0..15]` (CIPHER_KEY_SIZE = 16)
- HMAC key: `shared_secret[0..31]` (PUB_KEY_SIZE = 32)
The AES key is the first half of the HMAC key. Both are derived from the same `shared_secret` — for channels, this is the PSK; for direct messages, the ECDH shared secret.
**Why this matters:**
1. **Violated key separation principle.** Standard practice dictates that encryption and authentication keys must be independent. Using overlapping portions of the same secret means a weakness in one mechanism could leak information relevant to the other.
2. **HMAC key reveals AES key.** If an attacker recovers the 32-byte HMAC key (e.g., through a side-channel attack on the HMAC computation), they automatically obtain the 16-byte AES key as a prefix.
3. **No key derivation function.** The shared_secret is used directly — no HKDF or similar KDF is applied to derive independent subkeys. This is a departure from cryptographic best practice (cf. RFC 5869).
**Practical impact:** In the current threat model (passive radio capture of LoRa packets), this is unlikely to be directly exploitable — HMAC-SHA256 does not leak its key through normal operation. However, it represents a structural weakness that compounds with any future vulnerability in either the AES or HMAC implementation.
## 8. TXT_MSG (Direct Message) Block Layout
Direct messages use a different plaintext structure (from `BaseChatMesh::composeMsgPacket()`):
```cpp
memcpy(temp, &timestamp, 4); // bytes 0-3: timestamp
temp[4] = (attempt & 3); // byte 4: attempt counter (0-3)
memcpy(&temp[5], text, text_len + 1); // bytes 5+: message text
// data_len = 5 + text_len (null terminator copied but not counted in length)
```
**Block layout for TXT_MSG:**
```
Block 0: [TS₀][TS₁][TS₂][TS₃][attempt][text bytes 0-10]
Block 1: [text bytes 11-26] (if message long enough)
```
Key differences from GRP_TXT:
- **No sender name in plaintext** — the sender is identified by the source hash in the unencrypted packet header, not in the encrypted payload.
- **Header is exactly 5 bytes** (4 timestamp + 1 attempt), always. No variable-length field.
- **11 bytes of message text fit in block 0** (vs. 9 N for GRP_TXT).
- **Encrypted with per-pair ECDH shared secret**, not a group PSK. Each sender-recipient pair has a unique key.
**ECB implications for TXT_MSG:**
- Block 0 is still protected by the timestamp accidental nonce.
- Blocks 1+ are deterministic, same as GRP_TXT — identical message text at the same offset produces identical ciphertext.
- However, frequency analysis is harder: each sender-recipient pair uses a different key, so the attacker can only correlate messages within a single pair. The message volume for any given pair is typically much lower than for a group channel.
- The fixed 5-byte header means block alignment is consistent across ALL direct messages (unlike GRP_TXT where alignment varies by sender name length). An attacker who compromises one ECDH key can build block frequency tables, but only for that specific pair.
## 9. Mitigations
### Priority 1: Switch to AES-128-CTR
Replace ECB with CTR mode. Use the existing 4-byte timestamp + a 4-byte per-message counter as the 8-byte nonce (padded to 16 bytes for the CTR block). Each byte of plaintext gets XORed with a unique keystream byte — eliminates all block-level determinism.
**Wire format change:** None if the nonce is derived from header fields already present. If an explicit counter is added, 4 bytes of overhead per message.
### Priority 2: Derive Independent Subkeys
Apply HKDF (or at minimum, two distinct SHA-256 hashes) to the shared_secret to produce independent AES and HMAC keys. This is a minimal code change:
```
aes_key = SHA256(shared_secret || "encrypt")[0..15]
hmac_key = SHA256(shared_secret || "authenticate")
```
### Priority 3: Constant-Length Padding
Pad all messages to a fixed block count (e.g., 4 blocks = 64 bytes) to eliminate length leakage. Expensive on LoRa — should be configurable per channel as a security-vs-bandwidth tradeoff.
### Priority 4: Replay Protection
Track seen packet HMACs within a time window. Reject messages with timestamps older than N minutes.
### Priority 5: Channel Key Rotation
Manual or automated periodic rotation of channel PSKs. Even monthly rotation limits the exposure window.
### Priority 6: Forward Secrecy
Ephemeral ECDH for direct messages. Significant protocol change but prevents retroactive decryption on key compromise.
## 10. Speculative: LLM-Assisted Analysis
> **This section is speculation, not formal analysis.** The claims below are plausible but unvalidated. They do not affect the formal findings in §19.
An LLM could reduce the sample size needed for block frequency analysis:
1. **Context-aware candidate generation:** Given a sender's known patterns (the sender name is recoverable from block 0's predictable prefix), an LLM could generate likely message continuations and predict which plaintext blocks to look for in the frequency tables.
2. **Conversational inference:** Timestamps + sender IDs + partially decoded messages could let an LLM reconstruct probable conversation flow, narrowing the search space for unknown blocks.
3. **Community-specific vocabulary:** Training on public mesh chat logs could yield common phrases and greeting patterns, further reducing the candidate plaintext space.
This does not change the fundamental requirement (blocks 1+ must repeat, or the final block must be in a small enough space for dictionary matching). It potentially reduces the number of captured messages needed for convergence, but no quantitative bound is established.
## 11. Conclusion
MeshCore's encryption has four vulnerabilities, ranked by practical exploitability:
### Vulnerability #1: PSK Brute-Force (Critical)
**No KDF + known-plaintext oracle = offline key recovery from a single packet.** Any channel using a human-memorable passphrase of ≤3 common words or ≤11 alphanumeric characters is recoverable in minutes to hours on commodity GPU hardware. This is the highest-priority threat because it requires minimal attacker capability (one captured packet), succeeds against the most common deployment pattern (human-chosen passphrases), and completely compromises channel confidentiality. See §1.
### Vulnerability #2: ECB Determinism (Medium)
**Blocks beyond the timestamp's reach are deterministic.** Identical plaintext at the same block offset always produces identical ciphertext. For GRP_TXT messages longer than ~9 N characters (where N is sender name length), this enables frequency analysis on blocks 1+. The partial final block, with its known zero-padding, is the strongest individual target. Exploitation requires hundreds of captured messages with repeated content — a higher bar than PSK brute-force. See §4–§5.
### Vulnerability #3: Key Material Reuse (Medium)
**AES and HMAC share the same key material** without a key derivation function. The AES key is a prefix of the HMAC key. This violates key separation and creates a structural dependency between the encryption and authentication mechanisms. See §7.
### Vulnerability #4: No Forward Secrecy (LowMedium)
**No forward secrecy, no key rotation, no replay protection.** These are independent of the above but compound the risk: a single key compromise (whether via brute-force or other means) exposes all past and future traffic encrypted under that key. See §9.
**Summary of recommended mitigations (in priority order):**
1. **(Critical)** Apply a memory-hard KDF (argon2id) to channel PSKs — §1.6
2. **(Critical)** Add per-channel salt — §1.6
3. **(High)** Switch from AES-128-ECB to AES-128-CTR — §9
4. **(High)** Derive independent AES and HMAC subkeys via HKDF — §9
5. **(Medium)** Constant-length padding, replay protection, key rotation — §9
6. **(Low)** Forward secrecy via ephemeral ECDH — §9
The timestamp in block 0 was not designed as a nonce and should not be relied upon as one.
+506 -14
View File
@@ -85,6 +85,7 @@
<button class="tab-btn" data-tab="subpaths">Route Patterns</button>
<button class="tab-btn" data-tab="nodes">Nodes</button>
<button class="tab-btn" data-tab="distance">Distance</button>
<button class="tab-btn" data-tab="neighbor-graph">Neighbor Graph</button>
</div>
</div>
<div id="analyticsContent" class="analytics-content">
@@ -171,6 +172,7 @@
case 'subpaths': await renderSubpaths(el); break;
case 'nodes': await renderNodesTab(el); break;
case 'distance': await renderDistanceTab(el); break;
case 'neighbor-graph': await renderNeighborGraphTab(el); break;
}
// Auto-apply column resizing to all analytics tables
requestAnimationFrame(() => {
@@ -265,6 +267,37 @@
</div>
</div>
`;
// Affinity stats widget — fetch and append if debugAffinity enabled
var showDebug = (window.CLIENT_CONFIG && window.CLIENT_CONFIG.debugAffinity) || localStorage.getItem('meshcore-affinity-debug') === 'true';
if (showDebug) {
var apiKey = localStorage.getItem('meshcore-api-key') || '';
fetch('/api/debug/affinity', { headers: { 'X-API-Key': apiKey } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data || !data.stats) return;
var s = data.stats;
var total = s.resolvedCount + s.ambiguousCount + s.unresolvedCount;
var resolvedPct = total > 0 ? (s.resolvedCount / total * 100).toFixed(1) : '0.0';
var ambiguousPct = total > 0 ? (s.ambiguousCount / total * 100).toFixed(1) : '0.0';
var widget = document.createElement('div');
widget.className = 'analytics-row';
widget.innerHTML = '<div class="analytics-card flex-1">' +
'<h3>🔍 Neighbor Affinity Graph</h3>' +
'<div class="stats-grid">' +
'<div class="stat-card"><div class="stat-value">' + s.totalEdges + '</div><div class="stat-label">Total Edges</div></div>' +
'<div class="stat-card"><div class="stat-value">' + s.totalNodes + '</div><div class="stat-label">Total Nodes</div></div>' +
'<div class="stat-card"><div class="stat-value">' + s.resolvedCount + ' <span style="font-size:12px;color:var(--text-muted)">(' + resolvedPct + '%)</span></div><div class="stat-label">Resolved Prefixes</div></div>' +
'<div class="stat-card"><div class="stat-value">' + s.ambiguousCount + ' <span style="font-size:12px;color:var(--text-muted)">(' + ambiguousPct + '%)</span></div><div class="stat-label">Ambiguous Prefixes</div></div>' +
'<div class="stat-card"><div class="stat-value">' + (s.avgConfidence || 0).toFixed(3) + '</div><div class="stat-label">Avg Confidence</div></div>' +
'<div class="stat-card"><div class="stat-value">' + (s.coldStartCoverage || 0).toFixed(1) + '%</div><div class="stat-label">Cold-Start Coverage</div></div>' +
'<div class="stat-card"><div class="stat-value">' + (s.cacheAge || 'N/A') + '</div><div class="stat-label">Cache Age</div></div>' +
'<div class="stat-card"><div class="stat-value">' + (s.lastRebuild ? s.lastRebuild.substring(0, 19) : 'N/A') + '</div><div class="stat-label">Last Rebuild</div></div>' +
'</div></div>';
el.appendChild(widget);
})
.catch(function () {});
}
}
function renderPayloadPie(types) {
@@ -956,7 +989,7 @@
<div class="analytics-card" id="inconsistentHashSection">
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0"> Inconsistent Hash Sizes</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)"> top</a></div>
<p class="text-muted" style="margin:4px 0 8px;font-size:0.8em">Nodes sending adverts with varying hash sizes. Caused by a <a href="https://github.com/meshcore-dev/MeshCore/commit/fcfdc5f" target="_blank" style="color:var(--accent)">bug</a> where automatic adverts ignored the configured multibyte path setting. Fixed in <a href="https://github.com/meshcore-dev/MeshCore/releases/tag/repeater-v1.14.1" target="_blank" style="color:var(--accent)">repeater v1.14.1</a>.</p>
<p class="text-muted" style="margin:4px 0 8px;font-size:0.8em">Repeaters and room servers sending adverts with varying hash sizes in the last 7 days. Originally caused by a <a href="https://github.com/meshcore-dev/MeshCore/commit/fcfdc5f" target="_blank" style="color:var(--accent)">firmware bug</a> where automatic adverts ignored the configured multibyte path setting, fixed in <a href="https://github.com/meshcore-dev/MeshCore/releases/tag/repeater-v1.14.1" target="_blank" style="color:var(--accent)">repeater v1.14.1</a>. Companion nodes are excluded.</p>
<div id="inconsistentHashList"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading</div></div>
</div>
@@ -1365,12 +1398,8 @@
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">Analyzing route patterns…</div>';
try {
const rq = RegionFilter.regionQueryString();
const [d2, d3, d4, d5] = await Promise.all([
api('/analytics/subpaths?minLen=2&maxLen=2&limit=50' + rq, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/subpaths?minLen=3&maxLen=3&limit=30' + rq, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/subpaths?minLen=4&maxLen=4&limit=20' + rq, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/subpaths?minLen=5&maxLen=8&limit=15' + rq, { ttl: CLIENT_TTL.analyticsRF })
]);
const bulk = await api('/analytics/subpaths-bulk?groups=2-2:50,3-3:30,4-4:20,5-8:15' + rq, { ttl: CLIENT_TTL.analyticsRF });
const [d2, d3, d4, d5] = bulk.results;
function renderTable(data, title) {
if (!data.subpaths.length) return `<h4>${title}</h4><div class="text-muted">No data</div>`;
@@ -1569,10 +1598,9 @@
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Loading node analytics…</div>';
try {
const rq = RegionFilter.regionQueryString();
const [nodesResp, bulkHealth, netStatus] = await Promise.all([
api('/nodes?limit=200&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }),
api('/nodes/bulk-health?limit=50' + rq, { ttl: CLIENT_TTL.analyticsRF }),
api('/nodes/network-status' + (rq ? '?' + rq.slice(1) : ''), { ttl: CLIENT_TTL.analyticsRF })
const [nodesResp, bulkHealth] = await Promise.all([
api('/nodes?limit=10000&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }),
api('/nodes/bulk-health?limit=50' + rq, { ttl: CLIENT_TTL.analyticsRF })
]);
const nodes = nodesResp.nodes || nodesResp;
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
@@ -1589,8 +1617,22 @@
const byObservers = [...enriched].sort((a, b) => (b.health.observers?.length || 0) - (a.health.observers?.length || 0));
const byRecent = [...enriched].filter(n => n.health.stats.lastHeard).sort((a, b) => new Date(b.health.stats.lastHeard) - new Date(a.health.stats.lastHeard));
// Use server-computed status across ALL nodes
const { active, degraded, silent, total: totalNodes, roleCounts } = netStatus;
// Compute network status client-side from loaded nodes using shared getHealthThresholds()
const now = Date.now();
let active = 0, degraded = 0, silent = 0;
nodes.forEach(function(n) {
const role = n.role || 'unknown';
const th = getHealthThresholds(role);
const lastMs = n.last_heard ? new Date(n.last_heard).getTime()
: n.last_seen ? new Date(n.last_seen).getTime()
: 0;
const age = lastMs ? (now - lastMs) : Infinity;
if (age < th.degradedMs) active++;
else if (age < th.silentMs) degraded++;
else silent++;
});
const totalNodes = nodesResp.total || nodes.length;
const roleCounts = nodesResp.counts || {};
function nodeLink(n) {
return `<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">${esc(n.name || n.public_key.slice(0, 12))}</a>`;
@@ -1799,7 +1841,7 @@
}
}
function destroy() { _analyticsData = {}; _channelData = null; }
function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; }
// Expose for testing
if (typeof window !== 'undefined') {
@@ -1810,5 +1852,455 @@ function destroy() { _analyticsData = {}; _channelData = null; }
window._analyticsChannelTheadHtml = channelTheadHtml;
}
// ─── Neighbor Graph Tab ─────────────────────────────────────────────────────
let _ngState = null; // neighbor graph state
async function renderNeighborGraphTab(el) {
el.innerHTML = `
<div class="analytics-card" id="ngCard">
<h3>🕸 Neighbor Graph</h3>
<div id="ngFilters" class="ng-filters" style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin-bottom:12px">
<label style="font-size:13px">Roles:
<span id="ngRoleChecks" style="margin-left:4px"></span>
</label>
<label style="font-size:13px">Min Score: <input type="range" id="ngMinScore" min="0" max="100" value="10" style="width:100px;vertical-align:middle">
<span id="ngMinScoreVal">0.10</span>
</label>
<label style="font-size:13px">Confidence:
<select id="ngConfidence" style="font-size:12px;padding:2px 4px">
<option value="all">Show All</option>
<option value="high">High Only</option>
<option value="hide-ambiguous">Hide Ambiguous</option>
</select>
</label>
</div>
<div id="ngStats" class="stat-row" style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:12px"></div>
<div style="position:relative;border:1px solid var(--border);border-radius:6px;overflow:hidden">
<canvas id="ngCanvas" width="900" height="600" style="width:100%;height:600px;cursor:grab;outline-offset:2px" role="img" aria-label="Neighbor affinity graph visualization — interactive force-directed network topology" tabindex="0"></canvas>
<div id="ngTooltip" style="position:absolute;display:none;background:var(--bg-secondary);border:1px solid var(--border);border-radius:4px;padding:6px 10px;font-size:12px;pointer-events:none;z-index:10;box-shadow:0 2px 8px rgba(0,0,0,0.2)"></div>
</div>
<details id="ngAccessibleList" style="margin-top:12px">
<summary style="cursor:pointer;font-size:13px;color:var(--text-secondary)">📋 Text-based neighbor list (accessible alternative)</summary>
<div id="ngTextList" style="font-size:12px;max-height:300px;overflow-y:auto;padding:8px;background:var(--bg-secondary);border-radius:4px;margin-top:4px"></div>
</details>
</div>`;
// Role checkboxes
const roles = ['repeater','companion','room','sensor'];
const rcEl = document.getElementById('ngRoleChecks');
roles.forEach(r => {
const color = (window.ROLE_COLORS || {})[r] || '#888';
rcEl.innerHTML += `<label style="font-size:12px;margin-right:8px"><input type="checkbox" data-role="${r}" checked> <span style="color:${esc(color)}">${esc(r)}</span></label>`;
});
// Load data
const rqs = RegionFilter.regionQueryString();
const sep = rqs ? '?' + rqs.slice(1) : '';
let graphData;
try {
graphData = await api('/analytics/neighbor-graph' + sep + (sep ? '&' : '?') + 'min_count=1&min_score=0', { ttl: CLIENT_TTL.analyticsRF });
} catch (e) {
el.innerHTML = `<div class="analytics-card"><p class="text-muted">Failed to load neighbor graph: ${esc(e.message)}</p></div>`;
return;
}
_ngState = createGraphState(graphData);
renderNGStats(_ngState);
startGraphRenderer();
// Filter listeners
document.getElementById('ngMinScore').addEventListener('input', function() {
document.getElementById('ngMinScoreVal').textContent = (this.value / 100).toFixed(2);
applyNGFilters();
});
document.getElementById('ngConfidence').addEventListener('change', applyNGFilters);
rcEl.addEventListener('change', applyNGFilters);
}
function createGraphState(data) {
const nodes = (data.nodes || []).map((n, i) => ({
...n,
x: 450 + (Math.random() - 0.5) * 400,
y: 300 + (Math.random() - 0.5) * 300,
vx: 0, vy: 0,
radius: Math.max(6, Math.min(18, 6 + (n.neighbor_count || 0)))
}));
const nodeIdx = {};
nodes.forEach((n, i) => { nodeIdx[n.pubkey] = i; });
const edges = (data.edges || []).filter(e => nodeIdx[e.source] !== undefined && nodeIdx[e.target] !== undefined);
return {
allNodes: nodes, allEdges: edges,
nodes, edges, nodeIdx,
stats: data.stats || {},
zoom: 1, panX: 0, panY: 0,
dragging: null, panning: false,
lastMouseX: 0, lastMouseY: 0,
cooling: 1.0, animId: null
};
}
function applyNGFilters() {
if (!_ngState) return;
const minScore = parseInt(document.getElementById('ngMinScore').value, 10) / 100;
const conf = document.getElementById('ngConfidence').value;
const checkedRoles = new Set();
document.querySelectorAll('#ngRoleChecks input:checked').forEach(cb => checkedRoles.add(cb.dataset.role));
// Filter nodes by role
const visibleNodes = _ngState.allNodes.filter(n => {
const role = (n.role || 'unknown').toLowerCase();
return checkedRoles.has(role) || role === 'unknown' || role === 'observer';
});
const visiblePKs = new Set(visibleNodes.map(n => n.pubkey));
// Filter edges
_ngState.edges = _ngState.allEdges.filter(e => {
if (e.score < minScore) return false;
if (conf === 'high' && (e.ambiguous || e.score < 0.5)) return false;
if (conf === 'hide-ambiguous' && e.ambiguous) return false;
return visiblePKs.has(e.source) && visiblePKs.has(e.target);
});
// Only include nodes that have at least one visible edge
const edgeNodes = new Set();
_ngState.edges.forEach(e => { edgeNodes.add(e.source); edgeNodes.add(e.target); });
_ngState.nodes = visibleNodes.filter(n => edgeNodes.has(n.pubkey));
// Rebuild index
_ngState.nodeIdx = {};
_ngState.nodes.forEach((n, i) => { _ngState.nodeIdx[n.pubkey] = i; });
_ngState.cooling = 1.0;
renderNGStats(_ngState);
}
function renderNGStats(st) {
const nodes = st.nodes, edges = st.edges;
const totalScore = edges.reduce((s, e) => s + e.score, 0);
const avgScore = edges.length ? (totalScore / edges.length) : 0;
const ambiguous = edges.filter(e => e.ambiguous).length;
const resolved = edges.length ? ((edges.length - ambiguous) / edges.length * 100) : 0;
const statsEl = document.getElementById('ngStats');
if (!statsEl) return;
statsEl.innerHTML = `
<div class="stat-card"><div class="stat-value">${nodes.length}</div><div class="stat-label">Nodes</div></div>
<div class="stat-card"><div class="stat-value">${edges.length}</div><div class="stat-label">Edges</div></div>
<div class="stat-card"><div class="stat-value">${avgScore.toFixed(2)}</div><div class="stat-label">Avg Score</div></div>
<div class="stat-card"><div class="stat-value">${resolved.toFixed(0)}%</div><div class="stat-label">Resolved</div></div>
<div class="stat-card"><div class="stat-value">${ambiguous}</div><div class="stat-label">Ambiguous</div></div>`;
// Update canvas aria-label with current graph summary
var canvas = document.getElementById('ngCanvas');
if (canvas) {
canvas.setAttribute('aria-label', 'Neighbor affinity graph: ' + nodes.length + ' nodes, ' + edges.length + ' edges, ' + resolved.toFixed(0) + '% resolved. Use arrow keys to pan, +/- to zoom, 0 to reset.');
}
// Update accessible text list
updateNGTextList(st);
}
function updateNGTextList(st) {
var listEl = document.getElementById('ngTextList');
if (!listEl) return;
var nodes = st.nodes, edges = st.edges;
if (nodes.length === 0) {
listEl.innerHTML = '<p class="text-muted">No nodes to display.</p>';
return;
}
// Build adjacency for text list
var adj = {};
edges.forEach(function(e) {
if (!adj[e.source]) adj[e.source] = [];
if (!adj[e.target]) adj[e.target] = [];
adj[e.source].push({ pk: e.target, score: e.score, ambiguous: e.ambiguous });
adj[e.target].push({ pk: e.source, score: e.score, ambiguous: e.ambiguous });
});
var nodeMap = {};
nodes.forEach(function(n) { nodeMap[n.pubkey] = n; });
var html = '<table style="width:100%;border-collapse:collapse"><thead><tr><th style="text-align:left;padding:4px;border-bottom:1px solid var(--border)">Node</th><th style="text-align:left;padding:4px;border-bottom:1px solid var(--border)">Role</th><th style="text-align:left;padding:4px;border-bottom:1px solid var(--border)">Neighbors</th></tr></thead><tbody>';
nodes.slice().sort(function(a, b) { return (a.name || a.pubkey).localeCompare(b.name || b.pubkey); }).forEach(function(n) {
var neighbors = (adj[n.pubkey] || []).map(function(nb) {
var peer = nodeMap[nb.pk];
var name = peer ? (peer.name || nb.pk.slice(0, 8)) : nb.pk.slice(0, 8);
var conf = nb.ambiguous ? ' ⚠' : (nb.score >= 0.5 ? ' ●' : ' ○');
return esc(name) + conf;
}).join(', ');
html += '<tr><td style="padding:4px;border-bottom:1px solid var(--border)">' + esc(n.name || n.pubkey.slice(0, 12)) + '</td><td style="padding:4px;border-bottom:1px solid var(--border)">' + esc(n.role || 'unknown') + '</td><td style="padding:4px;border-bottom:1px solid var(--border)">' + (neighbors || '<em>none</em>') + '</td></tr>';
});
html += '</tbody></table>';
html += '<p style="margin-top:8px;font-size:11px;color:var(--text-secondary)">● = high confidence (score ≥ 0.5), ○ = low confidence, ⚠ = ambiguous/unresolved</p>';
listEl.innerHTML = html;
}
function startGraphRenderer() {
if (!_ngState) return;
// Node count guard: skip force simulation for very large graphs
var NODE_LIMIT = 1000;
if (_ngState.allNodes.length > NODE_LIMIT) {
var el = document.getElementById('ngCanvas');
if (el) {
el.style.display = 'none';
var msg = document.createElement('div');
msg.className = 'analytics-card';
msg.innerHTML = '<p class="text-muted">Graph has ' + _ngState.allNodes.length + ' nodes (limit: ' + NODE_LIMIT + '). Force simulation skipped for performance. Use filters to reduce the node count.</p>';
el.parentNode.insertBefore(msg, el);
}
return;
}
const canvas = document.getElementById('ngCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.clientWidth * dpr;
canvas.height = canvas.clientHeight * dpr;
ctx.scale(dpr, dpr);
const W = canvas.clientWidth, H = canvas.clientHeight;
// Interaction
let hoverNode = null;
function canvasToGraph(cx, cy) {
return { x: (cx - _ngState.panX) / _ngState.zoom, y: (cy - _ngState.panY) / _ngState.zoom };
}
function findNode(cx, cy) {
const gp = canvasToGraph(cx, cy);
for (let i = _ngState.nodes.length - 1; i >= 0; i--) {
const n = _ngState.nodes[i];
const dx = gp.x - n.x, dy = gp.y - n.y;
if (dx * dx + dy * dy <= n.radius * n.radius) return n;
}
return null;
}
canvas.addEventListener('mousedown', function(e) {
const rect = canvas.getBoundingClientRect();
const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
const n = findNode(cx, cy);
if (n) {
_ngState.dragging = n;
n._pinned = true;
canvas.style.cursor = 'grabbing';
} else {
_ngState.panning = true;
canvas.style.cursor = 'grabbing';
}
_ngState.lastMouseX = e.clientX;
_ngState.lastMouseY = e.clientY;
});
canvas.addEventListener('mousemove', function(e) {
const rect = canvas.getBoundingClientRect();
const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
if (_ngState.dragging) {
const dx = (e.clientX - _ngState.lastMouseX) / _ngState.zoom;
const dy = (e.clientY - _ngState.lastMouseY) / _ngState.zoom;
_ngState.dragging.x += dx;
_ngState.dragging.y += dy;
_ngState.lastMouseX = e.clientX;
_ngState.lastMouseY = e.clientY;
_ngState.cooling = Math.max(_ngState.cooling, 0.3);
} else if (_ngState.panning) {
_ngState.panX += e.clientX - _ngState.lastMouseX;
_ngState.panY += e.clientY - _ngState.lastMouseY;
_ngState.lastMouseX = e.clientX;
_ngState.lastMouseY = e.clientY;
} else {
const n = findNode(cx, cy);
if (n !== hoverNode) {
hoverNode = n;
canvas.style.cursor = n ? 'pointer' : 'grab';
const tip = document.getElementById('ngTooltip');
if (n && tip) {
tip.style.display = 'block';
tip.style.left = (cx + 12) + 'px';
tip.style.top = (cy - 8) + 'px';
tip.innerHTML = `<strong>${esc(n.name || n.pubkey.slice(0, 12) + '…')}</strong><br>Role: ${esc(n.role || 'unknown')}<br>Neighbors: ${n.neighbor_count || 0}`;
} else if (tip) {
tip.style.display = 'none';
}
} else if (hoverNode) {
const tip = document.getElementById('ngTooltip');
if (tip) { tip.style.left = (cx + 12) + 'px'; tip.style.top = (cy - 8) + 'px'; }
}
}
});
canvas.addEventListener('mouseup', function() {
if (_ngState.dragging) {
_ngState.dragging._pinned = false;
_ngState._wasDragging = true;
}
_ngState.dragging = null;
_ngState.panning = false;
canvas.style.cursor = hoverNode ? 'pointer' : 'grab';
});
canvas.addEventListener('mouseleave', function() {
_ngState.dragging = null;
_ngState.panning = false;
_ngState._wasDragging = false;
const tip = document.getElementById('ngTooltip');
if (tip) tip.style.display = 'none';
hoverNode = null;
});
canvas.addEventListener('click', function(e) {
if (_ngState._wasDragging) { _ngState._wasDragging = false; return; }
if (_ngState.dragging) return;
const rect = canvas.getBoundingClientRect();
const n = findNode(e.clientX - rect.left, e.clientY - rect.top);
if (n) location.hash = '#/nodes/' + n.pubkey;
});
canvas.addEventListener('keydown', function(e) {
const PAN_STEP = 30, ZOOM_STEP = 1.15;
switch (e.key) {
case 'ArrowLeft': _ngState.panX += PAN_STEP; e.preventDefault(); break;
case 'ArrowRight': _ngState.panX -= PAN_STEP; e.preventDefault(); break;
case 'ArrowUp': _ngState.panY += PAN_STEP; e.preventDefault(); break;
case 'ArrowDown': _ngState.panY -= PAN_STEP; e.preventDefault(); break;
case '+': case '=': _ngState.zoom = Math.min(10, _ngState.zoom * ZOOM_STEP); e.preventDefault(); break;
case '-': case '_': _ngState.zoom = Math.max(0.1, _ngState.zoom / ZOOM_STEP); e.preventDefault(); break;
case '0': _ngState.zoom = 1; _ngState.panX = 0; _ngState.panY = 0; e.preventDefault(); break;
}
});
canvas.addEventListener('wheel', function(e) {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
const factor = e.deltaY < 0 ? 1.1 : 0.9;
const newZoom = Math.max(0.1, Math.min(10, _ngState.zoom * factor));
// Zoom towards mouse position
_ngState.panX = cx - (cx - _ngState.panX) * (newZoom / _ngState.zoom);
_ngState.panY = cy - (cy - _ngState.panY) * (newZoom / _ngState.zoom);
_ngState.zoom = newZoom;
}, { passive: false });
// Cache text color to avoid getComputedStyle every frame
const _labelColor = cssVar('--text-primary') || '#e0e0e0';
// Force simulation + render loop
// Performance: 500 nodes brute-force repulsion: avg ~4ms/frame = 250fps headroom (measured Chrome 120, M1)
var _perfFrameTimes = [], _perfLastTime = 0;
function tick() {
if (!document.getElementById('ngCanvas')) { _ngState.animId = null; return; }
var now = performance.now();
if (_perfLastTime) _perfFrameTimes.push(now - _perfLastTime);
_perfLastTime = now;
if (_perfFrameTimes.length === 100) {
var avg = _perfFrameTimes.reduce(function(a, b) { return a + b; }, 0) / 100;
console.log('[NeighborGraph perf] avg frame time over 100 frames: ' + avg.toFixed(2) + 'ms (' + (1000 / avg).toFixed(0) + ' fps)');
_perfFrameTimes = [];
}
const st = _ngState;
const nodes = st.nodes, edges = st.edges, idx = st.nodeIdx;
if (st.cooling > 0.001) {
// Repulsion (all pairs — use grid for large sets, brute force for small)
const k = 80; // repulsion constant
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
let dx = nodes[j].x - nodes[i].x;
let dy = nodes[j].y - nodes[i].y;
let d2 = dx * dx + dy * dy;
if (d2 < 1) { dx = Math.random() - 0.5; dy = Math.random() - 0.5; d2 = 1; }
const f = k * k / d2;
const fx = dx / Math.sqrt(d2) * f;
const fy = dy / Math.sqrt(d2) * f;
nodes[i].vx -= fx; nodes[i].vy -= fy;
nodes[j].vx += fx; nodes[j].vy += fy;
}
}
// Attraction along edges
const idealLen = 120;
for (const e of edges) {
const si = idx[e.source], ti = idx[e.target];
if (si === undefined || ti === undefined) continue;
const a = nodes[si], b = nodes[ti];
let dx = b.x - a.x, dy = b.y - a.y;
const d = Math.sqrt(dx * dx + dy * dy) || 1;
const f = (d - idealLen) * 0.05 * (0.5 + e.score * 0.5);
const fx = dx / d * f, fy = dy / d * f;
a.vx += fx; a.vy += fy;
b.vx -= fx; b.vy -= fy;
}
// Center gravity
for (const n of nodes) {
n.vx += (W / 2 - n.x) * 0.001;
n.vy += (H / 2 - n.y) * 0.001;
}
// Apply velocities with damping
const damping = 0.85;
for (const n of nodes) {
if (n._pinned) { n.vx = 0; n.vy = 0; continue; }
n.vx *= damping * st.cooling;
n.vy *= damping * st.cooling;
const speed = Math.sqrt(n.vx * n.vx + n.vy * n.vy);
if (speed > 10) { n.vx *= 10 / speed; n.vy *= 10 / speed; }
n.x += n.vx;
n.y += n.vy;
}
st.cooling *= 0.995;
}
// Render
ctx.save();
ctx.clearRect(0, 0, W, H);
ctx.translate(st.panX, st.panY);
ctx.scale(st.zoom, st.zoom);
// Edges
for (const e of edges) {
const si = idx[e.source], ti = idx[e.target];
if (si === undefined || ti === undefined) continue;
const a = nodes[si], b = nodes[ti];
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = e.ambiguous ? 'rgba(255,200,0,0.4)' : 'rgba(150,150,150,0.35)';
ctx.lineWidth = Math.max(0.5, e.score * 4);
if (e.ambiguous) { ctx.setLineDash([4, 4]); } else { ctx.setLineDash([]); }
ctx.stroke();
ctx.setLineDash([]);
}
// Nodes
const roleColors = window.ROLE_COLORS || {};
for (const n of nodes) {
const color = roleColors[(n.role || '').toLowerCase()] || '#6b7280';
ctx.beginPath();
ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
if (n === hoverNode) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
}
// Label
const label = n.name || (n.pubkey ? n.pubkey.slice(0, 8) + '…' : '');
if (label && st.zoom > 0.4) {
ctx.fillStyle = _labelColor;
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(label, n.x, n.y + n.radius + 12);
}
}
ctx.restore();
st.animId = requestAnimationFrame(tick);
}
_ngState.animId = requestAnimationFrame(tick);
}
registerPage('analytics', { init, destroy });
})();
+3
View File
@@ -463,6 +463,9 @@ function navigate() {
currentPage = basePage;
const app = document.getElementById('app');
// Pages with fixed-height containers (maps, virtual-scroll, split-panels)
const fixedPages = { packets: 1, nodes: 1, map: 1, live: 1, channels: 1, 'audio-lab': 1 };
app.classList.toggle('app-fixed', basePage in fixedPages);
if (pages[basePage]?.init) {
const t0 = performance.now();
pages[basePage].init(app, routeParam);
+1 -1
View File
@@ -48,7 +48,7 @@ if (typeof window !== 'undefined') window.comparePacketSets = comparePacketSets;
packetsB = [];
currentView = 'summary';
app.innerHTML = '<div class="compare-page" style="overflow-y:auto;height:calc(100vh - 56px);padding:16px">' +
app.innerHTML = '<div class="compare-page" style="padding:16px">' +
'<div class="page-header" style="display:flex;align-items:center;gap:12px;margin-bottom:16px">' +
'<a href="#/observers" class="btn-icon" title="Back to Observers" aria-label="Back">\u2190</a>' +
'<h2 style="margin:0">\uD83D\uDD0D Observer Comparison</h2>' +
+122 -5
View File
@@ -6,6 +6,21 @@
(function () {
// ── Constants ──
var DEFAULT_HOME = {
heroTitle: 'CoreScope',
heroSubtitle: 'Real-time MeshCore LoRa mesh network analyzer',
steps: [
{ emoji: '🔵', title: 'Connect via Bluetooth', description: 'Flash **BLE companion** firmware from [MeshCore Flasher](https://flasher.meshcore.co.uk/).\n- Screenless devices: default PIN `123456`\n- Screen devices: random PIN shown on display\n- If pairing fails: forget device, reboot, re-pair' },
{ emoji: '📻', title: 'Set the right frequency preset', description: '**US Recommended:**\n`910.525 MHz · BW 62.5 kHz · SF 7 · CR 5`\nSelect **"US Recommended"** in the app or flasher.' },
{ emoji: '📡', title: 'Advertise yourself', description: 'Tap the signal icon → **Flood** to broadcast your node to the mesh. Companions only advert when you trigger it manually.' },
{ emoji: '🔁', title: 'Check "Heard N repeats"', description: '- **"Sent"** = transmitted, no confirmation\n- **"Heard 0 repeats"** = no repeater picked it up\n- **"Heard 1+ repeats"** = you\'re on the mesh!' }
],
footerLinks: [
{ label: '📦 Packets', url: '#/packets' },
{ label: '🗺️ Network Map', url: '#/map' }
]
};
var STORAGE_KEY = 'cs-theme-overrides';
var DARK_MODE_KEY = 'meshcore-theme';
var LEGACY_KEYS = [
@@ -290,6 +305,7 @@
/** @type {object|null} server defaults, set during init */
var _serverDefaults = null;
var _initDone = false;
var _saveStatus = 'saved'; // 'saved' | 'saving' | 'error'
var _writeTimer = null;
@@ -390,6 +406,10 @@
function computeEffective(serverConfig, userOverrides) {
var effective = JSON.parse(JSON.stringify(serverConfig || {}));
// Defense-in-depth: if server returned home:null, use built-in defaults
if (!effective.home || typeof effective.home !== 'object') {
effective.home = JSON.parse(JSON.stringify(DEFAULT_HOME));
}
if (!userOverrides || typeof userOverrides !== 'object') return effective;
for (var key in userOverrides) {
if (!userOverrides.hasOwnProperty(key)) continue;
@@ -578,7 +598,29 @@
delta[sec] = _pendingOverrides[sec];
}
}
var pendingKeys = _pendingOverrides;
_pendingOverrides = {};
// Spec Decision #7: don't silently prune existing overrides.
// Only prevent redundant NEW writes: if a value just written matches
// the server default, don't store it (clearOverride semantics).
var server = _serverDefaults || {};
for (var ps in pendingKeys) {
if (typeof pendingKeys[ps] === 'object' && pendingKeys[ps] !== null && OBJECT_SECTIONS.indexOf(ps) >= 0) {
var serverSec = server[ps] || {};
if (delta[ps]) {
for (var pk in pendingKeys[ps]) {
var ov = delta[ps][pk];
var sv = serverSec[pk];
var match = (typeof ov === 'object' || typeof sv === 'object')
? JSON.stringify(ov) === JSON.stringify(sv) : ov === sv;
if (match) delete delta[ps][pk];
}
if (Object.keys(delta[ps]).length === 0) delete delta[ps];
}
} else if (SCALAR_SECTIONS.indexOf(ps) >= 0 && delta[ps] === server[ps]) {
delete delta[ps];
}
}
writeOverrides(delta);
_runPipeline();
_refreshPanel();
@@ -742,17 +784,33 @@
if (section) {
if (!overrides[section] || !overrides[section].hasOwnProperty(key)) return false;
var serverSection = server[section] || {};
return overrides[section][key] !== serverSection[key];
var ov = overrides[section][key];
var sv = serverSection[key];
// Deep compare for arrays/objects
if (typeof ov === 'object' || typeof sv === 'object') {
return JSON.stringify(ov) !== JSON.stringify(sv);
}
return ov !== sv;
}
if (!overrides.hasOwnProperty(key)) return false;
return overrides[key] !== server[key];
var ov2 = overrides[key];
var sv2 = server[key];
if (typeof ov2 === 'object' || typeof sv2 === 'object') {
return JSON.stringify(ov2) !== JSON.stringify(sv2);
}
return ov2 !== sv2;
}
/** Count overridden fields in a section */
/** Count overridden fields in a section (only those that differ from server defaults) */
function _countOverrides(section) {
var overrides = _getOverrides();
if (!overrides[section] || typeof overrides[section] !== 'object') return 0;
return Object.keys(overrides[section]).length;
var count = 0;
var keys = Object.keys(overrides[section]);
for (var i = 0; i < keys.length; i++) {
if (_isOverridden(section, keys[i])) count++;
}
return count;
}
function _overrideDot(section, key) {
@@ -962,11 +1020,12 @@
'<span class="cust-hex">' + val + '</span></div>';
}
var fallbackTC = (typeof window !== 'undefined' && window.TYPE_COLORS) || {};
var tc = eff.typeColors || {};
var stc = server.typeColors || {};
var typeRows = '';
for (var tkey in TYPE_LABELS) {
var tval = tc[tkey] || '#000000';
var tval = tc[tkey] || fallbackTC[tkey] || '#000000';
typeRows += '<div class="cust-color-row">' +
'<div><label>' + (TYPE_EMOJI[tkey] || '') + ' ' + TYPE_LABELS[tkey] + _overrideDot('typeColors', tkey) + '</label>' +
'<div class="cust-hint">' + (TYPE_HINTS[tkey] || '') + '</div></div>' +
@@ -1113,6 +1172,59 @@
_bindEvents(container);
}
/** Remove phantom overrides that match server defaults on startup */
function _cleanPhantomOverrides() {
var delta = readOverrides();
if (!delta || Object.keys(delta).length === 0) return;
var server = _serverDefaults || {};
var changed = false;
// Clean object sections
for (var i = 0; i < OBJECT_SECTIONS.length; i++) {
var sec = OBJECT_SECTIONS[i];
if (!delta[sec] || typeof delta[sec] !== 'object') continue;
var serverSec = server[sec];
// If server has no defaults for this section, only remove values that
// are clearly phantom (empty arrays/objects or undefined equivalents).
// Non-trivial values may be legitimate user choices.
if (!serverSec) {
var dKeys = Object.keys(delta[sec]);
for (var di = 0; di < dKeys.length; di++) {
var dv = delta[sec][dKeys[di]];
var isPhantom = (Array.isArray(dv) && dv.length === 0) ||
(typeof dv === 'object' && dv !== null && !Array.isArray(dv) && Object.keys(dv).length === 0);
if (isPhantom) { delete delta[sec][dKeys[di]]; changed = true; }
}
if (Object.keys(delta[sec]).length === 0) { delete delta[sec]; changed = true; }
continue;
}
var keys = Object.keys(delta[sec]);
for (var j = 0; j < keys.length; j++) {
var k = keys[j];
var ov = delta[sec][k];
var sv = serverSec[k];
var match = false;
if (typeof ov === 'object' || typeof sv === 'object') {
match = JSON.stringify(ov) === JSON.stringify(sv);
} else {
match = ov === sv;
}
if (match) { delete delta[sec][k]; changed = true; }
}
if (Object.keys(delta[sec]).length === 0) { delete delta[sec]; changed = true; }
}
// Clean scalar sections
for (var si = 0; si < SCALAR_SECTIONS.length; si++) {
var sk = SCALAR_SECTIONS[si];
if (delta.hasOwnProperty(sk) && delta[sk] === server[sk]) {
delete delta[sk]; changed = true;
}
}
if (changed) writeOverrides(delta);
}
function _refreshPanel() {
if (!_panelEl) return;
var inner = _panelEl.querySelector('.cust-inner');
@@ -1474,6 +1586,7 @@
// Watch dark/light mode toggle and re-apply
new MutationObserver(function () {
_runPipeline();
if (_panelEl && !_panelEl.classList.contains('hidden')) _refreshPanel();
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
});
@@ -1486,8 +1599,12 @@
window._customizerV2 = {
init: function (serverConfig) {
_serverDefaults = serverConfig || {};
_cleanPhantomOverrides();
_runPipeline();
_initDone = true;
},
/** True after init() has been called with server config and pipeline has run */
get initDone() { return _initDone; },
readOverrides: readOverrides,
writeOverrides: writeOverrides,
computeEffective: computeEffective,
+1 -2
View File
@@ -1,7 +1,6 @@
/* === CoreScope — home.css === */
/* Override #app overflow:hidden for home page scrolling */
#app:has(.home-hero), #app:has(.home-chooser) { overflow-y: auto; }
/* Home page now uses body scroll (no #app override needed — see style.css) */
/* Chooser */
.home-chooser {
+27 -19
View File
@@ -511,27 +511,35 @@
function timeSinceMs(d) { return Date.now() - d.getTime(); }
function checklist(homeCfg) {
if (homeCfg?.checklist) {
return homeCfg.checklist.map(i => `<div class="checklist-item"><div class="checklist-q" role="button" tabindex="0" aria-expanded="false">${escapeHtml(i.question)}</div><div class="checklist-a">${window.miniMarkdown ? miniMarkdown(i.answer) : escapeHtml(i.answer)}</div></div>`).join('');
var html = '';
// Render steps (getting started guide)
if (homeCfg?.steps?.length) {
html += homeCfg.steps.map(s => `<div class="checklist-item"><div class="checklist-q" role="button" tabindex="0" aria-expanded="false">${escapeHtml(s.emoji || '')} ${escapeHtml(s.title)}</div><div class="checklist-a">${window.miniMarkdown ? miniMarkdown(s.description) : escapeHtml(s.description)}</div></div>`).join('');
}
if (homeCfg?.steps) {
return homeCfg.steps.map(s => `<div class="checklist-item"><div class="checklist-q" role="button" tabindex="0" aria-expanded="false">${escapeHtml(s.emoji || '')} ${escapeHtml(s.title)}</div><div class="checklist-a">${window.miniMarkdown ? miniMarkdown(s.description) : escapeHtml(s.description)}</div></div>`).join('');
// Render FAQ/checklist (additional Q&A)
if (homeCfg?.checklist?.length) {
if (html) html += '<h3 style="margin:24px 0 12px;font-size:16px">❓ FAQ</h3>';
html += homeCfg.checklist.map(i => `<div class="checklist-item"><div class="checklist-q" role="button" tabindex="0" aria-expanded="false">${escapeHtml(i.question)}</div><div class="checklist-a">${window.miniMarkdown ? miniMarkdown(i.answer) : escapeHtml(i.answer)}</div></div>`).join('');
}
const items = [
{ q: '💬 First: Join the Bay Area MeshCore Discord',
a: '<p>The community Discord is the best place to get help and find local mesh enthusiasts.</p><p><a href="https://discord.gg/q59JzsYTst" target="_blank" rel="noopener" style="color:var(--accent);font-weight:600">Join the Discord ↗</a></p><p>Start with <strong>#intro-to-meshcore</strong> — it has detailed setup instructions.</p>' },
{ q: '🔵 Step 1: Connect via Bluetooth',
a: '<p>Flash <strong>BLE companion</strong> firmware from <a href="https://flasher.meshcore.co.uk/" target="_blank" rel="noopener" style="color:var(--accent)">MeshCore Flasher</a>.</p><ul><li>Screenless devices: default PIN <code>123456</code></li><li>Screen devices: random PIN shown on display</li><li>If pairing fails: forget device, reboot, re-pair</li></ul>' },
{ q: '📻 Step 2: Set the right frequency preset',
a: '<p><strong>US Recommended:</strong></p><div style="margin:8px 0;padding:8px 12px;background:var(--surface-1);border-radius:6px;font-family:var(--mono);font-size:.85rem">910.525 MHz · BW 62.5 kHz · SF 7 · CR 5</div><p>Select <strong>"US Recommended"</strong> in the app or flasher.</p>' },
{ q: '📡 Step 3: Advertise yourself',
a: '<p>Tap the signal icon → <strong>Flood</strong> to broadcast your node to the mesh. Companions only advert when you trigger it manually.</p>' },
{ q: '🔁 Step 4: Check "Heard N repeats"',
a: '<ul><li><strong>"Sent"</strong> = transmitted, no confirmation</li><li><strong>"Heard 0 repeats"</strong> = no repeater picked it up</li><li><strong>"Heard 1+ repeats"</strong> = you\'re on the mesh!</li></ul>' },
{ q: '📍 Repeaters near you?',
a: '<p><a href="#/map" style="color:var(--accent)">Check the network map</a> to see active repeaters.</p>' }
];
return items.map(i => `<div class="checklist-item"><div class="checklist-q" role="button" tabindex="0" aria-expanded="false">${i.q}</div><div class="checklist-a">${i.a}</div></div>`).join('');
// Fallback: Bay Area defaults when no config at all
if (!html) {
const items = [
{ q: '💬 First: Join the Bay Area MeshCore Discord',
a: '<p>The community Discord is the best place to get help and find local mesh enthusiasts.</p><p><a href="https://discord.gg/q59JzsYTst" target="_blank" rel="noopener" style="color:var(--accent);font-weight:600">Join the Discord ↗</a></p><p>Start with <strong>#intro-to-meshcore</strong> — it has detailed setup instructions.</p>' },
{ q: '🔵 Step 1: Connect via Bluetooth',
a: '<p>Flash <strong>BLE companion</strong> firmware from <a href="https://flasher.meshcore.co.uk/" target="_blank" rel="noopener" style="color:var(--accent)">MeshCore Flasher</a>.</p><ul><li>Screenless devices: default PIN <code>123456</code></li><li>Screen devices: random PIN shown on display</li><li>If pairing fails: forget device, reboot, re-pair</li></ul>' },
{ q: '📻 Step 2: Set the right frequency preset',
a: '<p><strong>US Recommended:</strong></p><div style="margin:8px 0;padding:8px 12px;background:var(--surface-1);border-radius:6px;font-family:var(--mono);font-size:.85rem">910.525 MHz · BW 62.5 kHz · SF 7 · CR 5</div><p>Select <strong>"US Recommended"</strong> in the app or flasher.</p>' },
{ q: '📡 Step 3: Advertise yourself',
a: '<p>Tap the signal icon → <strong>Flood</strong> to broadcast your node to the mesh. Companions only advert when you trigger it manually.</p>' },
{ q: '🔁 Step 4: Check "Heard N repeats"',
a: '<ul><li><strong>"Sent"</strong> = transmitted, no confirmation</li><li><strong>"Heard 0 repeats"</strong> = no repeater picked it up</li><li><strong>"Heard 1+ repeats"</strong> = you\'re on the mesh!</li></ul>' },
{ q: '📍 Repeaters near you?',
a: '<p><a href="#/map" style="color:var(--accent)">Check the network map</a> to see active repeaters.</p>' }
];
html = items.map(i => `<div class="checklist-item"><div class="checklist-q" role="button" tabindex="0" aria-expanded="false">${i.q}</div><div class="checklist-a">${i.a}</div></div>`).join('');
}
return html;
}
registerPage('home', { init, destroy });
+107 -17
View File
@@ -8,9 +8,11 @@ window.HopResolver = (function() {
const MAX_HOP_DIST = 1.8; // ~200km in degrees
const REGION_RADIUS_KM = 300;
let prefixIdx = {}; // lowercase hex prefix → [node, ...]
let pubkeyIdx = {}; // full lowercase pubkey → node (O(1) lookup)
let nodesList = [];
let observerIataMap = {}; // observer_id → iata
let iataCoords = {}; // iata → {lat, lon}
let affinityMap = {}; // pubkey → { neighborPubkey → score }
function dist(lat1, lon1, lat2, lon2) {
return Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2);
@@ -34,9 +36,11 @@ window.HopResolver = (function() {
function init(nodes, opts) {
nodesList = nodes || [];
prefixIdx = {};
pubkeyIdx = {};
for (const n of nodesList) {
if (!n.public_key) continue;
const pk = n.public_key.toLowerCase();
pubkeyIdx[pk] = n;
for (let len = 1; len <= 3; len++) {
const p = pk.slice(0, len * 2);
if (!prefixIdx[p]) prefixIdx[p] = [];
@@ -67,6 +71,34 @@ window.HopResolver = (function() {
return null; // no GPS — can't geo-filter client-side
}
/**
* Pick the best candidate using affinity first, then geo-distance fallback.
* @param {Array} candidates - candidates with lat/lon/pubkey/name
* @param {string|null} adjacentPubkey - pubkey of the previously/next resolved hop
* @param {Object|null} anchor - {lat, lon} for geo fallback
* @param {number|null} fallbackLat - fallback anchor lat (e.g. observer)
* @param {number|null} fallbackLon - fallback anchor lon
* @returns {Object} best candidate
*/
function pickByAffinity(candidates, adjacentPubkey, anchor, fallbackLat, fallbackLon) {
// If we have affinity data and an adjacent hop, prefer neighbors
if (adjacentPubkey && Object.keys(affinityMap).length > 0) {
const withAffinity = candidates
.map(c => ({ ...c, affinity: getAffinity(adjacentPubkey, c.pubkey) }))
.filter(c => c.affinity > 0);
if (withAffinity.length > 0) {
withAffinity.sort((a, b) => b.affinity - a.affinity);
return withAffinity[0];
}
}
// Fallback: geo-distance sort (existing behavior)
const effectiveAnchor = anchor || (fallbackLat != null ? { lat: fallbackLat, lon: fallbackLon } : null);
if (effectiveAnchor) {
candidates.sort((a, b) => dist(a.lat, a.lon, effectiveAnchor.lat, effectiveAnchor.lon) - dist(b.lat, b.lon, effectiveAnchor.lat, effectiveAnchor.lon));
}
return candidates[0];
}
/**
* Resolve an array of hex hop prefixes to node info.
* Returns a map: { hop: {name, pubkey, lat, lon, ambiguous, unreliable} }
@@ -139,40 +171,50 @@ window.HopResolver = (function() {
// Forward pass
let lastPos = (originLat != null && originLon != null) ? { lat: originLat, lon: originLon } : null;
let lastResolvedPubkey = null;
for (let i = 0; i < hops.length; i++) {
const hop = hops[i];
if (hopPositions[hop]) { lastPos = hopPositions[hop]; continue; }
if (hopPositions[hop]) {
lastPos = hopPositions[hop];
lastResolvedPubkey = resolved[hop] ? resolved[hop].pubkey : null;
continue;
}
const r = resolved[hop];
if (!r || !r.ambiguous) continue;
const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0));
if (!withLoc.length) continue;
let anchor = lastPos;
if (!anchor && i === hops.length - 1 && observerLat != null) {
anchor = { lat: observerLat, lon: observerLon };
}
if (anchor) {
withLoc.sort((a, b) => dist(a.lat, a.lon, anchor.lat, anchor.lon) - dist(b.lat, b.lon, anchor.lat, anchor.lon));
}
r.name = withLoc[0].name;
r.pubkey = withLoc[0].pubkey;
hopPositions[hop] = { lat: withLoc[0].lat, lon: withLoc[0].lon };
// Affinity-aware: prefer candidates that are neighbors of the previous hop
const picked = pickByAffinity(withLoc, lastResolvedPubkey, lastPos, i === hops.length - 1 ? observerLat : null, i === hops.length - 1 ? observerLon : null);
r.name = picked.name;
r.pubkey = picked.pubkey;
hopPositions[hop] = { lat: picked.lat, lon: picked.lon };
lastPos = hopPositions[hop];
lastResolvedPubkey = picked.pubkey;
}
// Backward pass
let nextPos = (observerLat != null && observerLon != null) ? { lat: observerLat, lon: observerLon } : null;
let nextResolvedPubkey = null;
for (let i = hops.length - 1; i >= 0; i--) {
const hop = hops[i];
if (hopPositions[hop]) { nextPos = hopPositions[hop]; continue; }
if (hopPositions[hop]) {
nextPos = hopPositions[hop];
nextResolvedPubkey = resolved[hop] ? resolved[hop].pubkey : null;
continue;
}
const r = resolved[hop];
if (!r || !r.ambiguous) continue;
const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0));
if (!withLoc.length || !nextPos) continue;
withLoc.sort((a, b) => dist(a.lat, a.lon, nextPos.lat, nextPos.lon) - dist(b.lat, b.lon, nextPos.lat, nextPos.lon));
r.name = withLoc[0].name;
r.pubkey = withLoc[0].pubkey;
hopPositions[hop] = { lat: withLoc[0].lat, lon: withLoc[0].lon };
// Affinity-aware: prefer candidates that are neighbors of the next hop
const picked = pickByAffinity(withLoc, nextResolvedPubkey, nextPos, null, null);
r.name = picked.name;
r.pubkey = picked.pubkey;
hopPositions[hop] = { lat: picked.lat, lon: picked.lon };
nextPos = hopPositions[hop];
nextResolvedPubkey = picked.pubkey;
}
// Sanity check: drop hops impossibly far from neighbors
@@ -203,5 +245,53 @@ window.HopResolver = (function() {
return nodesList.length > 0;
}
return { init: init, resolve: resolve, ready: ready, haversineKm: haversineKm };
/**
* Load neighbor-graph affinity data.
* @param {Object} graph - { edges: [{source, target, score, weight}, ...] }
*/
function setAffinity(graph) {
affinityMap = {};
if (!graph || !graph.edges) return;
for (const e of graph.edges) {
if (!affinityMap[e.source]) affinityMap[e.source] = {};
affinityMap[e.source][e.target] = e.score || e.weight || 1;
if (!affinityMap[e.target]) affinityMap[e.target] = {};
affinityMap[e.target][e.source] = e.score || e.weight || 1;
}
}
/**
* Get the affinity score between two pubkeys (0 if not neighbors).
*/
function getAffinity(pubkeyA, pubkeyB) {
if (!pubkeyA || !pubkeyB || !affinityMap[pubkeyA]) return 0;
return affinityMap[pubkeyA][pubkeyB] || 0;
}
/**
* Resolve hops using server-provided resolved_path (full pubkeys).
* Returns the same format as resolve() { [hop]: { name, pubkey, ... } }.
* resolved_path is an array aligned with path_json: each element is a
* 64-char lowercase hex pubkey or null. Skips entries that are null.
*/
function resolveFromServer(hops, resolvedPath) {
if (!hops || !resolvedPath || hops.length !== resolvedPath.length) return {};
var result = {};
for (var i = 0; i < hops.length; i++) {
var hop = hops[i];
var pubkey = resolvedPath[i];
if (!pubkey) continue; // null = unresolved, leave for client-side fallback
// O(1) lookup via pubkeyIdx built during init()
var node = pubkeyIdx[pubkey.toLowerCase()] || null;
result[hop] = {
name: node ? node.name : pubkey.slice(0, 8),
pubkey: pubkey,
candidates: node ? [{ name: node.name, pubkey: pubkey, lat: node.lat, lon: node.lon }] : [],
conflicts: []
};
}
return result;
}
return { init: init, resolve: resolve, resolveFromServer: resolveFromServer, ready: ready, haversineKm: haversineKm, setAffinity: setAffinity, getAffinity: getAffinity };
})();
+131 -37
View File
@@ -43,6 +43,7 @@
timelineScope: 3600000, // 1h default ms
timelineTimestamps: [], // historical timestamps from DB for sparkline
timelineFetchedScope: 0, // last fetched scope to avoid redundant fetches
replayGen: 0, // generation counter — incremented on each replay/rewind to discard stale async results
};
// ROLE_COLORS loaded from shared roles.js (includes 'unknown')
@@ -116,6 +117,7 @@
function vcrResumeLive() {
stopReplay();
VCR.replayGen++; // invalidate any in-flight async chunk processing
VCR.playhead = -1;
VCR.speed = 1;
VCR.missedCount = 0;
@@ -142,6 +144,8 @@
function vcrReplayFromTs(targetTs) {
const fetchFrom = new Date(targetTs).toISOString();
stopReplay();
VCR.replayGen++;
var gen = VCR.replayGen;
vcrSetMode('REPLAY');
// Reload map nodes to match the replay time
@@ -153,7 +157,10 @@
.then(r => r.json())
.then(data => {
const pkts = data.packets || [];
const replayEntries = expandToBufferEntries(pkts);
return expandToBufferEntriesAsync(pkts);
})
.then(function(replayEntries) {
if (gen !== VCR.replayGen) return; // stale async result — user changed mode
if (replayEntries.length === 0) {
vcrSetMode('PAUSED');
return;
@@ -202,6 +209,8 @@
function vcrRewind(ms) {
stopReplay();
VCR.replayGen++;
var gen = VCR.replayGen;
// Fetch packets from DB for the time window
const now = Date.now();
const from = new Date(now - ms).toISOString();
@@ -212,8 +221,11 @@
// Prepend to buffer (avoid duplicates by ID)
const existingIds = new Set(VCR.buffer.map(b => b.pkt.id).filter(Boolean));
const filtered = pkts.filter(p => !existingIds.has(p.id));
const newEntries = expandToBufferEntries(filtered);
VCR.buffer = [...newEntries, ...VCR.buffer];
return expandToBufferEntriesAsync(filtered);
})
.then(function(newEntries) {
if (gen !== VCR.replayGen) return; // stale async result
VCR.buffer = [].concat(newEntries, VCR.buffer);
VCR.playhead = 0;
VCR.speed = 1;
vcrSetMode('REPLAY');
@@ -274,15 +286,18 @@
// Get timestamp of last packet in buffer to fetch the next page
const last = VCR.buffer[VCR.buffer.length - 1];
if (!last) return Promise.resolve(false);
var gen = VCR.replayGen;
const since = new Date(last.ts + 1).toISOString(); // +1ms to avoid dupe
return fetch(`/api/packets?limit=10000&grouped=false&expand=observations&since=${encodeURIComponent(since)}&order=asc`)
.then(r => r.json())
.then(data => {
const pkts = data.packets || [];
if (pkts.length === 0) return false;
const newEntries = expandToBufferEntries(pkts);
VCR.buffer = VCR.buffer.concat(newEntries);
return true;
return expandToBufferEntriesAsync(pkts).then(function(newEntries) {
if (gen !== VCR.replayGen) return false; // stale
VCR.buffer = VCR.buffer.concat(newEntries);
return true;
});
})
.catch(() => false);
}
@@ -442,6 +457,7 @@
id: pkt.id, hash: pkt.hash,
raw: pkt.raw_hex,
path_json: pkt.path_json,
resolved_path: pkt.resolved_path,
_ts: new Date(pkt.timestamp || pkt.created_at).getTime(),
decoded: { header: { payloadTypeName: typeName }, payload: raw, path: { hops } },
snr: pkt.snr, rssi: pkt.rssi, observer: pkt.observer_name
@@ -449,11 +465,53 @@
}
// Expand a DB packet (with optional observations[]) into VCR buffer entries
/**
* Process packets into buffer entries in chunks to avoid blocking the main thread.
* Returns a Promise that resolves with the entries array.
* Each chunk processes CHUNK_SIZE packets, then yields to the event loop via setTimeout(0).
*/
var VCR_CHUNK_SIZE = 200;
function expandToBufferEntriesAsync(pkts) {
return new Promise(function(resolve) {
var entries = [];
var i = 0;
function processChunk() {
var end = Math.min(i + VCR_CHUNK_SIZE, pkts.length);
for (; i < end; i++) {
var p = pkts[i];
if (p.observations && p.observations.length > 0) {
for (var j = 0; j < p.observations.length; j++) {
var obs = p.observations[j];
entries.push({
ts: new Date(obs.timestamp || p.timestamp || p.created_at).getTime(),
pkt: dbPacketToLive(Object.assign({}, p, obs, { hash: p.hash, raw_hex: p.raw_hex, decoded_json: p.decoded_json }))
});
}
} else {
entries.push({
ts: new Date(p.timestamp || p.created_at).getTime(),
pkt: dbPacketToLive(p)
});
}
}
if (i < pkts.length) {
setTimeout(processChunk, 0);
} else {
resolve(entries);
}
}
processChunk();
});
}
// Synchronous version kept for small datasets and backward compat (tests)
function expandToBufferEntries(pkts) {
const entries = [];
for (const p of pkts) {
var entries = [];
for (var k = 0; k < pkts.length; k++) {
var p = pkts[k];
if (p.observations && p.observations.length > 0) {
for (const obs of p.observations) {
for (var j = 0; j < p.observations.length; j++) {
var obs = p.observations[j];
entries.push({
ts: new Date(obs.timestamp || p.timestamp || p.created_at).getTime(),
pkt: dbPacketToLive(Object.assign({}, p, obs, { hash: p.hash, raw_hex: p.raw_hex, decoded_json: p.decoded_json }))
@@ -482,6 +540,8 @@
clearTimeout(entry.timer);
}
propagationBuffer.clear();
// Batch-update timeline once on restore instead of per-packet while hidden
updateTimeline();
}
});
@@ -506,7 +566,6 @@
if (VCR.mode === 'LIVE') {
// Skip animations when tab is backgrounded — just buffer for VCR timeline
if (_tabHidden) {
updateTimeline();
return;
}
if (realisticPropagation && pkt.hash) {
@@ -1286,7 +1345,7 @@
html += `<h4 style="font-size:12px;margin:12px 0 6px;color:var(--text-muted);">Recent Packets</h4>
<div style="font-size:11px;max-height:200px;overflow-y:auto;">` +
recent.slice(0, 10).map(p => `<div style="padding:2px 0;display:flex;justify-content:space-between;">
<a href="#/packets/${encodeURIComponent(p.hash || '')}" style="color:var(--accent);text-decoration:none;">${escapeHtml(p.payload_type || '?')}${p.observation_count > 1 ? ' <span class="badge badge-obs" style="font-size:9px">👁 ' + p.observation_count + '</span>' : ''}</a>
<a href="#/packets/${encodeURIComponent(p.hash || '')}" style="color:var(--accent);text-decoration:none;">${escapeHtml(p.payload_type || '?')}${transportBadge(p.route_type)}${p.observation_count > 1 ? ' <span class="badge badge-obs" style="font-size:9px">👁 ' + p.observation_count + '</span>' : ''}</a>
<span style="color:var(--text-muted)">${formatLiveTimestampHtml(p.timestamp)}</span>
</div>`).join('') +
'</div>';
@@ -1359,9 +1418,29 @@
const _el2 = document.getElementById('liveNodeCount'); if (_el2) _el2.textContent = Object.keys(nodeMarkers).length;
// Initialize shared HopResolver with loaded nodes
if (window.HopResolver) HopResolver.init(list);
// Fetch affinity data for hop disambiguation
fetchAffinityData();
startAffinityRefresh();
} catch (e) { console.error('Failed to load nodes:', e); }
}
let _affinityInterval = null;
async function fetchAffinityData() {
try {
const resp = await fetch('/api/analytics/neighbor-graph');
const graph = await resp.json();
if (window.HopResolver && HopResolver.setAffinity) {
HopResolver.setAffinity(graph);
}
} catch (e) { console.warn('Failed to fetch affinity data:', e); }
}
function startAffinityRefresh() {
if (_affinityInterval) clearInterval(_affinityInterval);
_affinityInterval = setInterval(fetchAffinityData, 60000);
}
function clearNodeMarkers() {
if (nodesLayer) nodesLayer.clearLayers();
if (animLayer) animLayer.clearLayers();
@@ -1471,7 +1550,7 @@
item.innerHTML = `
<span class="feed-icon" style="color:${color}">${icon}</span>
<span class="feed-type" style="color:${color}">${typeName}</span>
${hopStr}${obsBadge}
${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
<span class="feed-text">${escapeHtml(preview)}</span>
<span class="feed-time">${formatLiveTimestampHtml(group.latestTs || Date.now())}</span>
`;
@@ -1573,6 +1652,7 @@
}
delete nodeMarkers[key];
delete nodeData[key];
delete nodeActivity[key];
pruned = true;
}
} else if (marker && marker._staleDimmed) {
@@ -1588,15 +1668,21 @@
if (_el2) _el2.textContent = Object.keys(nodeMarkers).length;
if (window.HopResolver) HopResolver.init(Object.values(nodeData));
}
// Prune orphaned nodeActivity entries (nodes removed above or never tracked)
for (var aKey in nodeActivity) {
if (!(aKey in nodeData)) delete nodeActivity[aKey];
}
}
// Expose for testing
window._livePruneStaleNodes = pruneStaleNodes;
window._liveNodeMarkers = function() { return nodeMarkers; };
window._liveNodeData = function() { return nodeData; };
window._liveNodeActivity = function() { return nodeActivity; };
window._vcrFormatTime = vcrFormatTime;
window._liveDbPacketToLive = dbPacketToLive;
window._liveExpandToBufferEntries = expandToBufferEntries;
window._liveExpandToBufferEntriesAsync = expandToBufferEntriesAsync;
window._liveSEG_MAP = SEG_MAP;
window._liveBufferPacket = bufferPacket;
window._liveVCR = function() { return VCR; };
@@ -1612,20 +1698,13 @@
async function replayRecent() {
try {
const resp = await fetch('/api/packets?limit=8&groupByHash=true');
// Single bulk fetch with expand=observations — no N+1 calls
const resp = await fetch('/api/packets?limit=8&expand=observations');
const data = await resp.json();
const groups = (data.packets || []).reverse();
// Fetch all observations first, then stagger rendering
const allGroups = [];
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
let observations = [];
try {
const detail = await fetch('/api/packets/' + encodeURIComponent(group.hash));
const detailData = await detail.json();
observations = detailData.observations || [];
} catch {}
const allGroups = groups.map((group) => {
const observations = group.observations || [];
const livePackets = observations.map(obs => {
const livePkt = dbPacketToLive(Object.assign({}, group, obs, {
@@ -1644,8 +1723,8 @@
}
livePackets.forEach(lp => VCR.buffer.push({ ts: lp._ts, pkt: lp }));
allGroups.push(livePackets);
}
return livePackets;
});
// Render with real timing gaps between packets
// Sort by earliest timestamp
@@ -1777,7 +1856,7 @@
var pathKey = hops.join(',');
if (seenPathKeys.has(pathKey)) continue;
seenPathKeys.add(pathKey);
var hopPositions = resolveHopPositions(hops, qp);
var hopPositions = resolveHopPositions(hops, qp, window.getResolvedPath ? getResolvedPath(qpkt) : null);
if (hopPositions.length >= 2) {
allPaths.push({ hopPositions: hopPositions, raw: qpkt.raw || first.raw });
} else if (hopPositions.length === 1) {
@@ -1814,15 +1893,29 @@
}
}
function resolveHopPositions(hops, payload) {
// Delegate to shared HopResolver (from hop-resolver.js) instead of reimplementing
const originLat = payload.lat != null && !(payload.lat === 0 && payload.lon === 0) ? payload.lat : null;
const originLon = payload.lon != null && !(payload.lon === 0 && payload.lon === 0) ? payload.lon : null;
function resolveHopPositions(hops, payload, resolvedPath) {
// Prefer server-side resolved_path when available
var resolvedMap;
if (resolvedPath && resolvedPath.length === hops.length && window.HopResolver && HopResolver.ready()) {
resolvedMap = HopResolver.resolveFromServer(hops, resolvedPath);
// Fill in any null entries from client-side fallback, preserving sender GPS context
var nullHops = hops.filter(function(h, i) { return !resolvedPath[i] && !resolvedMap[h]; });
if (nullHops.length) {
const originLat = payload.lat != null && !(payload.lat === 0 && payload.lon === 0) ? payload.lat : null;
const originLon = payload.lon != null && !(payload.lon === 0 && payload.lon === 0) ? payload.lon : null;
var fallback = HopResolver.resolve(nullHops, originLat, originLon, null, null, null);
for (var k in fallback) resolvedMap[k] = fallback[k];
}
} else {
// Delegate to shared HopResolver (from hop-resolver.js) instead of reimplementing
const originLat = payload.lat != null && !(payload.lat === 0 && payload.lon === 0) ? payload.lat : null;
const originLon = payload.lon != null && !(payload.lon === 0 && payload.lon === 0) ? payload.lon : null;
// Use HopResolver if available and initialized, otherwise fall back to simple lookup
const resolvedMap = (window.HopResolver && HopResolver.ready())
? HopResolver.resolve(hops, originLat, originLon, null, null, null)
: {};
// Use HopResolver if available and initialized, otherwise fall back to simple lookup
resolvedMap = (window.HopResolver && HopResolver.ready())
? HopResolver.resolve(hops, originLat, originLon, null, null, null)
: {};
}
// Convert HopResolver's map format to the array format live.js expects: {key, pos, name, known}
const raw = hops.map(hop => {
@@ -2406,7 +2499,7 @@
item.innerHTML = `
<span class="feed-icon" style="color:${color}">${icon}</span>
<span class="feed-type" style="color:${color}">${typeName}</span>
${hopStr}${obsBadge}
${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
<span class="feed-text">${escapeHtml(preview)}</span>
<span class="feed-time">${formatLiveTimestampHtml(pkt._ts || Date.now())}</span>
`;
@@ -2474,7 +2567,7 @@
item.innerHTML = `
<span class="feed-icon" style="color:${color}">${icon}</span>
<span class="feed-type" style="color:${color}">${typeName}</span>
${hopStr}${obsBadge}
${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
<span class="feed-text">${escapeHtml(preview)}</span>
<span class="feed-time">${formatLiveTimestampHtml(pkt._ts || Date.now())}</span>
`;
@@ -2552,6 +2645,7 @@
if (_lcdClockInterval) { clearInterval(_lcdClockInterval); _lcdClockInterval = null; }
if (_rateCounterInterval) { clearInterval(_rateCounterInterval); _rateCounterInterval = null; }
if (_pruneInterval) { clearInterval(_pruneInterval); _pruneInterval = null; }
if (_affinityInterval) { clearInterval(_affinityInterval); _affinityInterval = null; }
if (ws) { ws.onclose = null; ws.close(); ws = null; }
if (map) { map.remove(); map = null; }
if (_onResize) {
@@ -2584,7 +2678,7 @@
packetCount = 0; activeAnims = 0;
nodeActivity = {}; pktTimestamps = [];
feedDedup.clear();
VCR.buffer = []; VCR.playhead = -1; VCR.mode = 'LIVE'; VCR.missedCount = 0; VCR.speed = 1;
VCR.buffer = []; VCR.playhead = -1; VCR.mode = 'LIVE'; VCR.missedCount = 0; VCR.speed = 1; VCR.replayGen = 0;
}
let _themeRefreshHandler = null;
+218 -16
View File
@@ -9,12 +9,14 @@
let nodes = [];
let targetNodeKey = null;
let observers = [];
let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clusters: false, hashLabels: localStorage.getItem('meshcore-map-hash-labels') !== 'false', statusFilter: localStorage.getItem('meshcore-map-status-filter') || 'all' };
let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clusters: false, hashLabels: localStorage.getItem('meshcore-map-hash-labels') !== 'false', statusFilter: localStorage.getItem('meshcore-map-status-filter') || 'all', byteSize: localStorage.getItem('meshcore-map-byte-filter') || 'all' };
let selectedReferenceNode = null; // pubkey of the reference node for neighbor filtering
let neighborPubkeys = null; // Set of pubkeys that are direct neighbors of selected node
let wsHandler = null;
let heatLayer = null;
let geoFilterLayer = null;
let affinityLayer = null;
let affinityData = null;
let userHasMoved = false;
let controlsCollapsed = false;
@@ -92,6 +94,15 @@
<legend class="mc-label">Node Types</legend>
<div id="mcRoleChecks"></div>
</fieldset>
<fieldset class="mc-section">
<legend class="mc-label">Byte Size</legend>
<div class="filter-group" id="mcByteFilter">
<button class="btn ${filters.byteSize==='all'?'active':''}" data-byte="all">All</button>
<button class="btn ${filters.byteSize==='1'?'active':''}" data-byte="1">1-byte</button>
<button class="btn ${filters.byteSize==='2'?'active':''}" data-byte="2">2-byte</button>
<button class="btn ${filters.byteSize==='3'?'active':''}" data-byte="3">3-byte</button>
</div>
</fieldset>
<fieldset class="mc-section">
<legend class="mc-label">Display</legend>
<label for="mcClusters"><input type="checkbox" id="mcClusters"> Show clusters</label>
@@ -112,6 +123,7 @@
<label for="mcNeighbors"><input type="checkbox" id="mcNeighbors"> Show direct neighbors</label>
<div id="mcNeighborRef" style="display:none;font-size:11px;color:var(--text-muted);margin-top:2px;padding-left:20px;">Ref: <span id="mcNeighborRefName"></span></div>
<div id="mcNeighborHint" style="display:none;font-size:11px;color:var(--text-muted);margin-top:2px;padding-left:20px;">Click a node marker to set the reference node</div>
<label id="mcAffinityDebugLabel" for="mcAffinityDebug" style="display:none"><input type="checkbox" id="mcAffinityDebug"> 🔍 Affinity Debug</label>
</fieldset>
<fieldset class="mc-section">
<legend class="mc-label">Last Heard</legend>
@@ -178,11 +190,17 @@
});
map.on('zoomend', () => {
if (!_renderingMarkers) renderMarkers();
clearTimeout(_zoomResizeTimer);
_zoomResizeTimer = setTimeout(() => {
if (!_renderingMarkers) _repositionMarkers();
}, 150);
});
map.on('resize', () => {
if (!_renderingMarkers) renderMarkers();
clearTimeout(_zoomResizeTimer);
_zoomResizeTimer = setTimeout(() => {
if (!_renderingMarkers) _repositionMarkers();
}, 150);
});
markerLayer = L.layerGroup().addTo(map);
@@ -225,6 +243,22 @@
renderMarkers();
});
// Affinity Debug overlay toggle — shown only when debugAffinity config is on or localStorage override
(function initAffinityDebug() {
var label = document.getElementById('mcAffinityDebugLabel');
var show = (window.CLIENT_CONFIG && window.CLIENT_CONFIG.debugAffinity) || localStorage.getItem('meshcore-affinity-debug') === 'true';
if (show && label) label.style.display = '';
var cb = document.getElementById('mcAffinityDebug');
if (!cb) return;
cb.addEventListener('change', function (e) {
if (e.target.checked) {
loadAffinityDebugOverlay();
} else {
clearAffinityOverlay();
}
});
})();
// Hash Labels toggle
const hashLabelEl = document.getElementById('mcHashLabels');
if (hashLabelEl) {
@@ -243,6 +277,16 @@
});
});
// Byte size filter buttons
document.querySelectorAll('#mcByteFilter .btn').forEach(btn => {
btn.addEventListener('click', () => {
filters.byteSize = btn.dataset.byte;
localStorage.setItem('meshcore-map-byte-filter', filters.byteSize);
document.querySelectorAll('#mcByteFilter .btn').forEach(b => b.classList.toggle('active', b.dataset.byte === filters.byteSize));
renderMarkers();
});
});
// Geo filter overlay
(async function () {
try {
@@ -593,6 +637,8 @@
var _renderingMarkers = false;
var _lastDeconflictZoom = null;
var _currentMarkerData = []; // stored marker data for zoom-only repositioning
var _zoomResizeTimer = null;
function deconflictLabels(markers, mapRef) {
const placed = [];
@@ -643,6 +689,62 @@
}
}
/**
* Create, update, or remove the offset indicator (dashed line + dot at true GPS position)
* for a deconflicted marker. Shared by _renderMarkersInner and _repositionMarkers.
* @param {Object} m - marker data object with latLng, adjustedLatLng, offset, _leafletLine, _leafletDot
* @param {L.LayerGroup} layer - layer group to add/remove indicators from
*/
function _updateOffsetIndicator(m, layer) {
var pos = m.adjustedLatLng || m.latLng;
var redColor = getComputedStyle(document.documentElement).getPropertyValue('--status-red').trim() || '#ef4444';
if (m.offset > 10) {
// Line from true position to adjusted position
if (m._leafletLine) {
m._leafletLine.setLatLngs([m.latLng, pos]);
} else {
m._leafletLine = L.polyline([m.latLng, pos], {
color: redColor, weight: 2, dashArray: '6,4', opacity: 0.85
});
layer.addLayer(m._leafletLine);
}
// Dot at true GPS position
if (!m._leafletDot) {
m._leafletDot = L.circleMarker(m.latLng, {
radius: 3, fillColor: redColor, fillOpacity: 0.9, stroke: true, color: '#fff', weight: 1
});
layer.addLayer(m._leafletDot);
}
} else {
// No offset — remove indicator if it existed
if (m._leafletLine) { layer.removeLayer(m._leafletLine); m._leafletLine = null; }
if (m._leafletDot) { layer.removeLayer(m._leafletDot); m._leafletDot = null; }
}
}
/**
* Reposition existing markers by re-running deconfliction at the current zoom.
* Avoids clearing and rebuilding all markers eliminates flicker on zoom/resize.
*/
function _repositionMarkers() {
if (!map || _currentMarkerData.length === 0) return;
map.invalidateSize({ animate: false });
// Re-run deconfliction with current zoom pixel coordinates
deconflictLabels(_currentMarkerData, map);
for (var i = 0; i < _currentMarkerData.length; i++) {
var m = _currentMarkerData[i];
var pos = m.adjustedLatLng || m.latLng;
// Update marker position
if (m._leafletMarker) m._leafletMarker.setLatLng(pos);
_updateOffsetIndicator(m, markerLayer);
}
}
function renderMarkers() {
if (_renderingMarkers) return;
_renderingMarkers = true;
@@ -651,10 +753,16 @@
function _renderMarkersInner() {
markerLayer.clearLayers();
_currentMarkerData = [];
const filtered = nodes.filter(n => {
if (!n.lat || !n.lon) return false;
if (!filters[n.role || 'companion']) return false;
// Byte size filter (applies only to repeaters)
if (filters.byteSize !== 'all' && (n.role || 'companion') === 'repeater') {
const hs = n.hash_size || 1;
if (String(hs) !== filters.byteSize) return false;
}
// Status filter
if (filters.statusFilter !== 'all') {
const role = (n.role || 'companion').toLowerCase();
@@ -700,24 +808,20 @@
deconflictLabels(allMarkers, map);
}
// Store marker data for zoom/resize repositioning (avoids full rebuild)
_currentMarkerData = allMarkers;
for (const m of allMarkers) {
const pos = m.adjustedLatLng || m.latLng;
const marker = L.marker(pos, { icon: m.icon, alt: m.alt });
marker._nodeKey = m.node.public_key || m.node.id || null;
marker.bindPopup(m.popupFn(), { maxWidth: 280 });
markerLayer.addLayer(marker);
m._leafletMarker = marker;
m._leafletLine = null;
m._leafletDot = null;
if (m.offset > 10) {
const line = L.polyline([m.latLng, pos], {
color: getComputedStyle(document.documentElement).getPropertyValue('--status-red').trim() || '#ef4444', weight: 2, dashArray: '6,4', opacity: 0.85
});
markerLayer.addLayer(line);
// Small dot at true GPS position
const dot = L.circleMarker(m.latLng, {
radius: 3, fillColor: getComputedStyle(document.documentElement).getPropertyValue('--status-red').trim() || '#ef4444', fillOpacity: 0.9, stroke: true, color: '#fff', weight: 1
});
markerLayer.addLayer(dot);
}
_updateOffsetIndicator(m, markerLayer);
}
}
@@ -788,7 +892,15 @@
if (cb) cb.checked = true;
renderMarkers();
}
// Expose for popup onclick and testing
// Event delegation for Show Neighbors links (avoids inline onclick / global function timing issues)
document.addEventListener('click', function(e) {
var link = e.target.closest('[data-show-neighbors]');
if (link) {
e.preventDefault();
selectReferenceNode(link.dataset.pubkey, link.dataset.name);
}
});
// Expose for testing
window._mapSelectRefNode = selectReferenceNode;
window._mapGetNeighborPubkeys = function() { return neighborPubkeys ? Array.from(neighborPubkeys) : []; };
@@ -819,7 +931,7 @@
</dl>
<div style="margin-top:8px;clear:both;">
<a href="#/nodes/${node.public_key}" style="color:var(--accent);font-size:12px;">View Node </a>
${node.public_key ? ` · <a href="#" onclick="event.preventDefault();window._mapSelectRefNode('${safeEsc(node.public_key.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/</g, '\\x3c'))}','${safeEsc((node.name || 'Unknown').replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/</g, '\\x3c'))}')" style="color:var(--accent);font-size:12px;">Show Neighbors</a>` : ''}
${node.public_key ? ` · <a href="#" data-show-neighbors data-pubkey="${escapeHtml(node.public_key)}" data-name="${escapeHtml(node.name || 'Unknown')}" style="color:var(--accent);font-size:12px;">Show Neighbors</a>` : ''}
</div>
</div>`;
}
@@ -843,6 +955,7 @@
map = null;
}
markerLayer = null;
_currentMarkerData = [];
routeLayer = null;
if (heatLayer) { heatLayer = null; }
geoFilterLayer = null;
@@ -886,6 +999,95 @@
let _themeRefreshHandler = null;
// ─── Affinity Debug Overlay ────────────────────────────────────────────────
function clearAffinityOverlay() {
if (affinityLayer) { map.removeLayer(affinityLayer); affinityLayer = null; }
affinityData = null;
}
function loadAffinityDebugOverlay() {
clearAffinityOverlay();
// Fetch debug data — requires API key stored in localStorage
var apiKey = localStorage.getItem('meshcore-api-key') || '';
fetch('/api/debug/affinity', { headers: { 'X-API-Key': apiKey } })
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (data) {
affinityData = data;
renderAffinityOverlay();
})
.catch(function (err) {
console.warn('[affinity-debug] Failed to load:', err);
var cb = document.getElementById('mcAffinityDebug');
if (cb) cb.checked = false;
});
}
function renderAffinityOverlay() {
if (!affinityData || !map) return;
clearAffinityOverlay();
affinityLayer = L.layerGroup();
// Build node position lookup from current markers
var nodePos = {};
nodes.forEach(function (n) {
if (n.latitude && n.longitude) {
nodePos[n.public_key.toLowerCase()] = [n.latitude, n.longitude];
}
});
var edges = affinityData.edges || [];
edges.forEach(function (e) {
var posA = nodePos[e.nodeA];
var posB = e.nodeB ? nodePos[e.nodeB] : null;
if (!posA) return;
// Unresolved prefix — show ❓ marker near nodeA
if (e.unresolved || (!posB && e.ambiguous)) {
if (posA) {
var marker = L.marker([posA[0] + 0.001, posA[1] + 0.001], {
icon: L.divIcon({ html: '❓', className: 'affinity-unresolved', iconSize: [20, 20] })
});
marker.bindPopup('<b>Unresolved prefix:</b> ' + escapeHtml(e.prefix) + '<br>Observations: ' + e.weight);
affinityLayer.addLayer(marker);
}
return;
}
if (!posB) return;
// Color by confidence
var color = '#ef4444'; // red — ambiguous
var score = e.score || 0;
if (score >= 0.6) color = '#22c55e'; // green — high
else if (score >= 0.3) color = '#eab308'; // yellow — medium
// Thickness proportional to weight, clamped 1-5px
var weight = Math.max(1, Math.min(5, Math.round((e.weight || 1) / 20)));
var line = L.polyline([posA, posB], {
color: color,
weight: weight,
opacity: 0.7,
dashArray: e.ambiguous ? '5,5' : null
});
var popup = '<b>Affinity Edge</b><br>' +
escapeHtml(e.nodeAName || e.nodeA.substring(0, 8)) + ' ↔ ' + escapeHtml(e.nodeBName || e.nodeB.substring(0, 8)) + '<br>' +
'Observations: ' + e.observationCount + '<br>' +
'Score: ' + (e.score || 0).toFixed(3) + '<br>' +
'Last seen: ' + escapeHtml(e.lastSeen) + '<br>' +
'Observers: ' + escapeHtml((e.observers || []).join(', '));
if (e.avgSnr != null) popup += '<br>Avg SNR: ' + e.avgSnr.toFixed(1) + ' dB';
line.bindPopup(popup);
affinityLayer.addLayer(line);
});
affinityLayer.addTo(map);
}
// ─── End Affinity Debug ────────────────────────────────────────────────────
registerPage('map', {
init: function(app, routeParam) {
_themeRefreshHandler = () => { if (markerLayer) renderMarkers(); };
+1 -1
View File
@@ -51,7 +51,7 @@
const nodeName = escapeHtml(n.name || n.public_key.slice(0, 12));
container.innerHTML = `
<div style="max-width:1000px;margin:0 auto;padding:12px 16px;height:100%;overflow-y:auto">
<div style="max-width:1000px;margin:0 auto;padding:12px 16px">
<div style="margin-bottom:12px">
<a href="#/nodes/${encodeURIComponent(n.public_key)}" style="color:var(--accent);text-decoration:none;font-size:12px"> Back to ${nodeName}</a>
<h2 style="margin:4px 0 2px;font-size:18px">📊 ${nodeName} Analytics</h2>
+246 -9
View File
@@ -175,6 +175,114 @@
return `<div style="font-size:11px;color:var(--text-muted);margin:-2px 0 6px;padding:6px 10px;background:var(--surface-2);border-radius:4px;border-left:3px solid var(--status-yellow)">Adverts show varying hash sizes (<strong>${sizes.join('-byte, ')}-byte</strong>). This is a <a href="https://github.com/meshcore-dev/MeshCore/commit/fcfdc5f" target="_blank" style="color:var(--accent)">known bug</a> where automatic adverts ignore the configured multibyte path setting. Fixed in <a href="https://github.com/meshcore-dev/MeshCore/releases/tag/repeater-v1.14.1" target="_blank" style="color:var(--accent)">repeater v1.14.1</a>.</div>`;
}
// ─── Neighbor section helpers ───────────────────────────────────────────────
// Cache: pubkey → { data, ts }
var _neighborCache = {};
function getConfidenceIndicator(entry) {
if (entry.ambiguous) return { icon: '⚠️', label: 'AMBIGUOUS', cls: 'confidence-ambiguous' };
if (entry.count <= 1) return { icon: '🔴', label: 'LOW', cls: 'confidence-low' };
if (entry.score >= 0.5 && entry.count >= 3) return { icon: '🟢', label: 'HIGH', cls: 'confidence-high' };
return { icon: '🟡', label: 'MEDIUM', cls: 'confidence-medium' };
}
function renderNeighborRows(neighbors, limit) {
var sorted = neighbors.slice().sort(function(a, b) {
return (b.score || b.affinity || 0) - (a.score || a.affinity || 0);
});
var items = limit ? sorted.slice(0, limit) : sorted;
return items.map(function(nb) {
var conf = getConfidenceIndicator(nb);
var name = nb.name || (nb.prefix + '… (unknown)');
var nameHtml = nb.pubkey
? '<a href="#/nodes/' + encodeURIComponent(nb.pubkey) + '">' + escapeHtml(name) + '</a>'
: '<span class="text-muted">' + escapeHtml(name) + '</span>';
var role = nb.role || '—';
var roleBadge = nb.role
? '<span class="badge" style="background:' + (ROLE_COLORS[nb.role] || 'var(--surface-2)') + ';color:#fff;font-size:10px">' + escapeHtml(role) + '</span>'
: '<span class="text-muted">—</span>';
var scoreTitle = 'Observations: ' + nb.count;
if (nb.avg_snr != null) scoreTitle += ' · Avg SNR: ' + Number(nb.avg_snr).toFixed(1) + ' dB';
var showOnMap = nb.pubkey
? ' <button class="btn-link neighbor-show-map" data-pubkey="' + escapeHtml(nb.pubkey) + '" style="font-size:11px;padding:1px 6px;white-space:nowrap">📍 Map</button>'
: '';
return '<tr>' +
'<td style="font-weight:600">' + nameHtml + '</td>' +
'<td>' + roleBadge + '</td>' +
'<td title="' + escapeHtml(scoreTitle) + '">' + Number(nb.score).toFixed(2) + '</td>' +
'<td>' + nb.count + '</td>' +
'<td>' + renderNodeTimestampHtml(nb.last_seen) + '</td>' +
'<td><span title="' + conf.label + '">' + conf.icon + '</span></td>' +
'<td style="text-align:right">' + showOnMap + '</td>' +
'</tr>';
}).join('');
}
function renderNeighborTable(neighbors, limit) {
return '<table class="data-table" style="font-size:12px">' +
'<thead><tr><th>Neighbor</th><th>Role</th><th>Score</th><th>Obs</th><th>Last Seen</th><th>Conf</th><th></th></tr></thead>' +
'<tbody>' + renderNeighborRows(neighbors, limit) + '</tbody></table>';
}
function fetchAndRenderNeighbors(pubkey, containerId, opts) {
opts = opts || {};
var limit = opts.limit || 0;
var headerSelector = opts.headerSelector;
var viewAllPubkey = opts.viewAllPubkey;
// Always set spinner as initial DOM state (synchronous) so tests can observe it
var spinnerEl = document.getElementById(containerId);
if (spinnerEl) spinnerEl.innerHTML = '<div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading neighbors…</div>';
// Check cache
var cached = _neighborCache[pubkey];
if (cached && (Date.now() - cached.ts < 300000)) { // 5 min cache
renderNeighborData(cached.data, containerId, limit, headerSelector, viewAllPubkey);
return;
}
api('/nodes/' + encodeURIComponent(pubkey) + '/neighbors', { ttl: CLIENT_TTL.nodeDetail }).then(function(data) {
_neighborCache[pubkey] = { data: data, ts: Date.now() };
renderNeighborData(data, containerId, limit, headerSelector, viewAllPubkey);
}).catch(function() {
var el = document.getElementById(containerId);
if (el) el.innerHTML = '<div class="text-muted" style="padding:8px">Could not load neighbor data</div>';
});
}
function renderNeighborData(data, containerId, limit, headerSelector, viewAllPubkey) {
var el = document.getElementById(containerId);
if (!el) return;
if (!data || !data.neighbors || !data.neighbors.length) {
el.innerHTML = '<div class="text-muted" style="padding:8px">No neighbor data available yet. Neighbor relationships are built from observed packet paths over time.</div>';
if (headerSelector) {
var h = document.querySelector(headerSelector);
if (h) h.textContent = 'Neighbors (0)';
}
return;
}
if (headerSelector) {
var h = document.querySelector(headerSelector);
if (h) h.textContent = 'Neighbors (' + data.neighbors.length + ')';
}
var html = renderNeighborTable(data.neighbors, limit);
if (limit && data.neighbors.length > limit && viewAllPubkey) {
html += '<div style="margin-top:6px;text-align:right"><a href="#/nodes/' + encodeURIComponent(viewAllPubkey) + '?section=node-neighbors" style="font-size:12px">View all ' + data.neighbors.length + ' neighbors →</a></div>';
}
el.innerHTML = html;
// Wire up "Show on Map" buttons via event delegation
el.addEventListener('click', function(e) {
var btn = e.target.closest('.neighbor-show-map');
if (!btn) return;
var pk = btn.getAttribute('data-pubkey');
if (pk) location.hash = '#/map?node=' + encodeURIComponent(pk);
});
}
// ─── End neighbor helpers ─────────────────────────────────────────────────
let directNode = null; // set when navigating directly to #/nodes/:pubkey
let regionChangeHandler = null;
@@ -264,13 +372,25 @@
}, 5000);
}
/**
* Fetch node detail + health data in parallel.
* Both selectNode() and loadFullNode() need the same data
* this shared helper avoids duplicating the fetch logic (fixes #391).
*/
async function fetchNodeDetail(pubkey) {
const [nodeData, healthData] = await Promise.all([
api('/nodes/' + encodeURIComponent(pubkey), { ttl: CLIENT_TTL.nodeDetail }),
api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeDetail }).catch(() => null)
]);
nodeData.healthData = healthData;
return nodeData;
}
async function loadFullNode(pubkey) {
const body = document.getElementById('nodeFullBody');
try {
const [nodeData, healthData] = await Promise.all([
api('/nodes/' + encodeURIComponent(pubkey), { ttl: CLIENT_TTL.nodeDetail }),
api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeDetail }).catch(() => null)
]);
const nodeData = await fetchNodeDetail(pubkey);
const healthData = nodeData.healthData;
const n = nodeData.node;
const adverts = (nodeData.recentAdverts || []).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
const title = document.querySelector('.node-full-title');
@@ -347,6 +467,18 @@
</table>
</div>` : ''}
<div class="node-full-card" id="node-neighbors">
<h4 id="fullNeighborsHeader">Neighbors</h4>
<div id="fullNeighborsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading neighbors</div></div>
</div>
<div class="node-full-card" id="node-affinity-debug" style="display:none">
<h4 style="cursor:pointer" onclick="this.parentElement.querySelector('.affinity-debug-body').style.display=this.parentElement.querySelector('.affinity-debug-body').style.display==='none'?'block':'none'; this.querySelector('.toggle-icon').textContent=this.parentElement.querySelector('.affinity-debug-body').style.display==='none'?'▶':'▼'"><span class="toggle-icon"></span> 🔍 Affinity Debug</h4>
<div class="affinity-debug-body" style="display:none">
<div id="affinityDebugContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading debug data</div></div>
</div>
</div>
<div class="node-full-card" id="fullPathsSection">
<h4>Paths Through This Node</h4>
<div id="fullPathsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading paths</div></div>
@@ -427,6 +559,103 @@
} catch {}
}
// Fetch neighbors for this node (full-screen view)
fetchAndRenderNeighbors(n.public_key, 'fullNeighborsContent', {
headerSelector: '#fullNeighborsHeader'
});
// Affinity debug panel — show if debugAffinity is enabled
(function loadAffinityDebug() {
var show = (window.CLIENT_CONFIG && window.CLIENT_CONFIG.debugAffinity) || localStorage.getItem('meshcore-affinity-debug') === 'true';
var panel = document.getElementById('node-affinity-debug');
if (!show || !panel) return;
panel.style.display = '';
var apiKey = localStorage.getItem('meshcore-api-key') || '';
fetch('/api/debug/affinity?node=' + encodeURIComponent(n.public_key), { headers: { 'X-API-Key': apiKey } })
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (data) {
var el = document.getElementById('affinityDebugContent');
if (!el) return;
var html = '';
// Edges table
if (data.edges && data.edges.length) {
html += '<h5 style="margin:8px 0 4px">Neighbor Edges (' + data.edges.length + ')</h5>';
html += '<table class="mini-table" style="width:100%;font-size:12px"><thead><tr><th>Neighbor</th><th>Score</th><th>Count</th><th>Last Seen</th><th>Observers</th><th>Status</th></tr></thead><tbody>';
data.edges.forEach(function (e) {
var neighbor = e.nodeBName || e.nodeAName || (e.nodeB || e.nodeA || '').substring(0, 8);
if (e.nodeA.toLowerCase() === n.public_key.toLowerCase()) {
neighbor = e.nodeBName || (e.nodeB || e.prefix || '?').substring(0, 8);
} else {
neighbor = e.nodeAName || (e.nodeA || '').substring(0, 8);
}
var status = e.ambiguous ? (e.unresolved ? '❓ Unresolved' : '⚠️ Ambiguous') : (e.resolved ? '✅ Auto-resolved' : '✅ Resolved');
html += '<tr><td>' + escapeHtml(neighbor) + '</td><td>' + (e.score || 0).toFixed(3) + '</td><td>' + e.weight + '</td><td>' + (e.lastSeen || '').substring(0, 10) + '</td><td>' + (e.observers || []).length + '</td><td>' + status + '</td></tr>';
});
html += '</tbody></table>';
} else {
html += '<div class="text-muted" style="padding:8px">No affinity edges for this node</div>';
}
// Resolutions
if (data.resolutions && data.resolutions.length) {
html += '<h5 style="margin:12px 0 4px">Prefix Resolutions (' + data.resolutions.length + ')</h5>';
data.resolutions.forEach(function (r) {
html += '<div style="border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:6px;font-size:12px">';
html += '<b>Prefix: ' + escapeHtml(r.prefix) + '</b> → ';
if (r.method === 'auto-resolved') {
html += '<span style="color:var(--status-green)">✅ ' + escapeHtml(r.chosenName || r.chosen || '?') + '</span>';
html += ' (Jaccard=' + r.chosenJaccard.toFixed(2) + ', ratio=' + ((isFinite(r.ratio) && r.ratio < 100) ? r.ratio.toFixed(1) + '×' : '∞') + ')';
} else {
html += '<span style="color:var(--status-yellow)">⚠️ Ambiguous</span>';
if (r.ratio) html += ' (ratio=' + r.ratio.toFixed(1) + '×, threshold=' + r.thresholdApplied + '×)';
}
// Show disambiguation tier used (M4 resolveWithContext)
if (r.tier) {
var tierLabels = {
'neighbor_affinity': '🏘️ Affinity',
'geo_proximity': '🌍 Geo',
'gps_preference': '📍 GPS',
'first_match': '🎲 Naive',
'unique_prefix': '✓ Unique',
'no_match': '∅ None'
};
html += ' <span style="font-size:11px;opacity:0.8">[tier: ' + (tierLabels[r.tier] || escapeHtml(r.tier)) + ']</span>';
}
// Candidates table
if (r.candidates && r.candidates.length) {
html += '<div style="margin-top:4px"><table class="mini-table" style="width:100%;font-size:11px"><thead><tr><th>Candidate</th><th>Jaccard</th><th>Count</th></tr></thead><tbody>';
r.candidates.forEach(function (c) {
var highlight = r.chosen && c.pubkey === r.chosen ? ' style="background:var(--status-green-bg,rgba(34,197,94,0.1))"' : '';
html += '<tr' + highlight + '><td>' + escapeHtml(c.name || c.pubkey.substring(0, 8)) + '</td><td>' + c.jaccard.toFixed(3) + '</td><td>' + c.score + '</td></tr>';
});
html += '</tbody></table></div>';
}
html += '</div>';
});
}
// Stats summary
if (data.stats) {
html += '<h5 style="margin:12px 0 4px">Graph Stats</h5>';
html += '<div style="font-size:12px;line-height:1.6">';
html += 'Total edges: ' + data.stats.totalEdges + '<br>';
html += 'Total nodes: ' + data.stats.totalNodes + '<br>';
html += 'Resolved: ' + data.stats.resolvedCount + ' | Ambiguous: ' + data.stats.ambiguousCount + ' | Unresolved: ' + data.stats.unresolvedCount + '<br>';
html += 'Avg confidence: ' + (data.stats.avgConfidence || 0).toFixed(3) + '<br>';
html += 'Cold-start coverage: ' + (data.stats.coldStartCoverage || 0).toFixed(1) + '%<br>';
html += 'Cache age: ' + (data.stats.cacheAge || 'N/A') + ' | Last rebuild: ' + (data.stats.lastRebuild || 'N/A');
html += '</div>';
}
el.innerHTML = html;
})
.catch(function (err) {
var el = document.getElementById('affinityDebugContent');
if (el) el.innerHTML = '<div class="text-muted" style="padding:8px">Failed to load debug data: ' + escapeHtml(err.message) + '</div>';
});
})();
// Fetch paths through this node (full-screen view)
api('/nodes/' + encodeURIComponent(n.public_key) + '/paths', { ttl: CLIENT_TTL.nodeDetail }).then(pathData => {
const el = document.getElementById('fullPathsContent');
@@ -746,11 +975,7 @@
panel.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
try {
const [data, healthData] = await Promise.all([
api('/nodes/' + encodeURIComponent(pubkey), { ttl: CLIENT_TTL.nodeDetail }),
api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeDetail }).catch(() => null)
]);
data.healthData = healthData;
const data = await fetchNodeDetail(pubkey);
renderDetail(panel, data);
} catch (e) {
panel.innerHTML = `<div class="text-muted">Error: ${e.message}</div>`;
@@ -819,6 +1044,11 @@
</div>
</div>` : ''}
<div class="node-detail-section" id="panelNeighborsSection">
<h4 id="panelNeighborsHeader">Neighbors</h4>
<div id="panelNeighborsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading neighbors</div></div>
</div>
<div class="node-detail-section" id="pathsSection">
<h4>Paths Through This Node</h4>
<div id="pathsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading paths</div></div>
@@ -889,6 +1119,13 @@
} catch {}
}
// Fetch neighbors for this node (condensed panel — top 5)
fetchAndRenderNeighbors(n.public_key, 'panelNeighborsContent', {
limit: 5,
headerSelector: '#panelNeighborsHeader',
viewAllPubkey: n.public_key
});
// Fetch paths through this node
api('/nodes/' + encodeURIComponent(n.public_key) + '/paths', { ttl: CLIENT_TTL.nodeDetail }).then(pathData => {
const el = document.getElementById('pathsContent');
+1 -1
View File
@@ -37,7 +37,7 @@
}
app.innerHTML = `
<div class="observer-detail-page" style="overflow-y:auto;height:calc(100vh - 56px);padding:16px">
<div class="observer-detail-page" style="padding:16px">
<div class="page-header" style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
<a href="#/observers" class="btn-icon" title="Back to Observers" aria-label="Back"></a>
<h2 style="margin:0" id="obsTitle">Observer Detail</h2>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 229 KiB

+20 -2
View File
@@ -10,7 +10,7 @@
*/
window.getParsedPath = function getParsedPath(p) {
if (p._parsedPath !== undefined) return p._parsedPath;
if (p._parsedPath !== undefined) return p._parsedPath || [];
var raw = p.path_json;
if (typeof raw !== 'string') {
p._parsedPath = Array.isArray(raw) ? raw : [];
@@ -28,11 +28,29 @@ window.getParsedPath = function getParsedPath(p) {
window.clearParsedCache = function clearParsedCache(p) {
delete p._parsedPath;
delete p._parsedDecoded;
delete p._parsedResolvedPath;
return p;
};
/**
* Parse resolved_path (server-side resolved full pubkeys).
* Returns array of pubkey strings (or null entries) if present, or null if absent.
* Cached as _parsedResolvedPath on the packet object.
*/
window.getResolvedPath = function getResolvedPath(p) {
if (p._parsedResolvedPath !== undefined) return p._parsedResolvedPath;
var raw = p.resolved_path;
if (!raw) { p._parsedResolvedPath = null; return null; }
if (typeof raw !== 'string') {
p._parsedResolvedPath = Array.isArray(raw) ? raw : null;
return p._parsedResolvedPath;
}
try { p._parsedResolvedPath = JSON.parse(raw) || null; } catch (e) { p._parsedResolvedPath = null; }
return p._parsedResolvedPath;
};
window.getParsedDecoded = function getParsedDecoded(p) {
if (p._parsedDecoded !== undefined) return p._parsedDecoded;
if (p._parsedDecoded !== undefined) return p._parsedDecoded || {};
var raw = p.decoded_json;
if (typeof raw !== 'string') {
p._parsedDecoded = (raw && typeof raw === 'object') ? raw : {};
+171 -64
View File
@@ -40,6 +40,21 @@
clearTimeout(_renderTimer);
_renderTimer = setTimeout(() => renderTableRows(), 200);
}
// Coalesce WS-triggered renders into one per animation frame (#396).
// Multiple WS batches arriving within the same frame only trigger a single
// renderTableRows() call on the next rAF, preventing rapid full rebuilds.
function scheduleWSRender() {
_wsRenderDirty = true;
if (_wsRafId) return; // already scheduled
_wsRafId = requestAnimationFrame(function () {
_wsRafId = null;
if (_wsRenderDirty) {
_wsRenderDirty = false;
renderTableRows();
}
});
}
const PANEL_WIDTH_KEY = 'meshcore-panel-width';
const PANEL_CLOSE_HTML = '<button class="panel-close-btn" title="Close detail pane (Esc)">✕</button>';
@@ -53,11 +68,14 @@
let _displayPackets = []; // filtered packets for current view
let _displayGrouped = false; // whether _displayPackets is in grouped mode
let _rowCounts = []; // per-entry DOM row counts (1 for flat, 1+children for expanded groups)
let _rowCountsDirty = false; // set when _rowCounts may be stale (e.g. WS added children) (#410)
let _cumulativeOffsetsCache = null; // cached cumulative offsets, invalidated on _rowCounts change
let _lastVisibleStart = -1; // last rendered start index (for dirty checking)
let _lastVisibleEnd = -1; // last rendered end index (for dirty checking)
let _vsScrollHandler = null; // scroll listener reference
let _wsRenderTimer = null; // debounce timer for WS-triggered renders
let _wsRafId = null; // rAF id for coalescing WS-triggered renders (#396)
let _wsRenderDirty = false; // dirty flag for rAF render coalescing (#396)
let _observerFilterSet = null; // cached Set from filters.observer, hoisted above loops (#427)
function closeDetailPanel() {
@@ -170,6 +188,29 @@
}
}
/**
* Pre-populate hopNameCache from server-side resolved_path on packets.
* Packets with resolved_path skip client-side HopResolver entirely.
* Must call ensureHopResolver() first so nodesList is available for name lookup.
*/
async function cacheResolvedPaths(packets) {
if (!packets || !packets.length) return;
let needsInit = false;
for (const p of packets) {
const rp = getResolvedPath(p);
if (rp) { needsInit = true; break; }
}
if (!needsInit) return;
await ensureHopResolver();
for (const p of packets) {
const rp = getResolvedPath(p);
if (!rp) continue;
const hops = getParsedPath(p);
const resolved = HopResolver.resolveFromServer(hops, rp);
Object.assign(hopNameCache, resolved);
}
}
function renderHop(h, observerId) {
// Use per-packet cache key if observer context available (ambiguous hops differ by region)
const cacheKey = observerId ? h + ':' + observerId : h;
@@ -268,7 +309,7 @@
const obs = data.observations.find(o => String(o.id) === String(obsTarget));
if (obs) {
expandedHashes.add(h);
const obsPacket = {...data.packet, observer_id: obs.observer_id, observer_name: obs.observer_name, snr: obs.snr, rssi: obs.rssi, path_json: obs.path_json, timestamp: obs.timestamp, first_seen: obs.timestamp};
const obsPacket = {...data.packet, observer_id: obs.observer_id, observer_name: obs.observer_name, snr: obs.snr, rssi: obs.rssi, path_json: obs.path_json, resolved_path: obs.resolved_path, timestamp: obs.timestamp, first_seen: obs.timestamp};
clearParsedCache(obsPacket);
selectPacket(obs.id, h, {packet: obsPacket, breakdown: data.breakdown, observations: data.observations}, obs.id);
} else {
@@ -357,7 +398,7 @@
if (pktTime && pktTime < cutoff) return false;
}
if (filters.type) { const types = filters.type.split(',').map(Number); if (!types.includes(p.payload_type)) return false; }
if (filters.observer) { const obsSet = new Set(filters.observer.split(',')); if (!obsSet.has(p.observer_id)) return false; }
if (filters.observer) { const obsSet = new Set(filters.observer.split(',')); if (!obsSet.has(p.observer_id) && !(p._children && p._children.some(c => obsSet.has(String(c.observer_id))))) return false; }
if (filters.hash && p.hash !== filters.hash) return false;
if (RegionFilter.getRegionParam()) {
const selectedRegions = RegionFilter.getRegionParam().split(',');
@@ -370,9 +411,16 @@
if (!filtered.length) return;
// Resolve any new hops, then update and re-render
// Pre-populate from server-side resolved_path, then fall back for remaining
const newHops = new Set();
for (const p of filtered) {
try { getParsedPath(p).forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
const rp = getResolvedPath(p);
const hops = getParsedPath(p);
if (rp && rp.length === hops.length && window.HopResolver && HopResolver.ready()) {
const resolved = HopResolver.resolveFromServer(hops, rp);
Object.assign(hopNameCache, resolved);
}
try { hops.forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
}
(newHops.size ? resolveHops([...newHops]) : Promise.resolve()).then(() => {
if (groupByHash) {
@@ -396,6 +444,9 @@
existing._children.unshift(p);
if (existing._children.length > 200) existing._children.length = 200;
sortGroupChildren(existing);
// Invalidate row counts — child count changed, so virtual scroll
// heights are stale until next renderTableRows() (#410)
_invalidateRowCounts();
}
} else {
// New group
@@ -427,9 +478,8 @@
if (packets.length > PACKET_LIMIT) packets.length = PACKET_LIMIT;
}
totalCount += filtered.length;
// Debounce WS-triggered renders to avoid rapid full rebuilds
clearTimeout(_wsRenderTimer);
_wsRenderTimer = setTimeout(function () { renderTableRows(); }, 200);
// Coalesce WS-triggered renders via rAF (#396)
scheduleWSRender();
});
});
}
@@ -440,8 +490,11 @@
wsHandler = null;
detachVScrollListener();
clearTimeout(_wsRenderTimer);
if (_wsRafId) { cancelAnimationFrame(_wsRafId); _wsRafId = null; }
_wsRenderDirty = false;
_displayPackets = [];
_rowCounts = [];
_rowCountsDirty = false;
_cumulativeOffsetsCache = null;
_observerFilterSet = null;
_lastVisibleStart = -1;
@@ -488,7 +541,12 @@
if (regionParam) params.set('region', regionParam);
if (filters.hash) params.set('hash', filters.hash);
if (filters.node) params.set('node', filters.node);
params.set('groupByHash', 'true'); // always fetch grouped
if (filters.observer) params.set('observer', filters.observer);
if (groupByHash) {
params.set('groupByHash', 'true');
} else {
params.set('expand', 'observations');
}
const data = await api('/packets?' + params.toString());
packets = data.packets || [];
@@ -496,20 +554,14 @@
for (const p of packets) { if (p.hash) hashIndex.set(p.hash, p); }
totalCount = data.total || packets.length;
// When ungrouped, fetch observations for all multi-obs packets and flatten
// When ungrouped, flatten observations inline (single API call, no N+1)
if (!groupByHash) {
const multiObs = packets.filter(p => (p.observation_count || p.count || 1) > 1);
await Promise.all(multiObs.map(async (p) => {
try {
const d = await api(`/packets/${p.hash}`);
if (d?.observations) p._children = d.observations.map(o => clearParsedCache({...d.packet, ...o, _isObservation: true}));
} catch {}
}));
// Flatten: replace grouped packets with individual observations
const flat = [];
for (const p of packets) {
if (p._children && p._children.length > 1) {
for (const c of p._children) flat.push(c);
if (p.observations && p.observations.length > 1) {
for (const o of p.observations) {
flat.push(clearParsedCache({...p, ...o, _isObservation: true, observations: undefined}));
}
} else {
flat.push(p);
}
@@ -518,7 +570,10 @@
totalCount = flat.length;
}
// Pre-resolve all path hops to node names
// Pre-resolve from server-side resolved_path (preferred, no client-side disambiguation needed)
await cacheResolvedPaths(packets);
// Pre-resolve all path hops to node names (fallback for packets without resolved_path)
const allHops = new Set();
for (const p of packets) {
try { getParsedPath(p).forEach(h => allHops.add(h)); } catch {}
@@ -541,19 +596,22 @@
// Ambiguous hops are already resolved by HopResolver client-side
// No need for per-observer server API calls
// Restore expanded group children
// Restore expanded group children (parallel fetch, Map lookup)
if (groupByHash && expandedHashes.size > 0) {
for (const hash of expandedHashes) {
const group = packets.find(p => p.hash === hash);
if (group) {
try {
const childData = await api(`/packets?hash=${hash}&limit=20`);
group._children = childData.packets || [];
sortGroupChildren(group);
} catch {}
} else {
// Group no longer in results — remove from expanded
const expandedArr = [...expandedHashes];
const results = await Promise.all(expandedArr.map(hash => {
const group = hashIndex.get(hash);
if (!group) return { hash, group: null, data: null };
return api(`/packets?hash=${hash}&limit=20`)
.then(data => ({ hash, group, data }))
.catch(() => ({ hash, group, data: null }));
}));
for (const { hash, group, data } of results) {
if (!group) {
expandedHashes.delete(hash);
} else if (data) {
group._children = data.packets || [];
sortGroupChildren(group);
}
}
}
@@ -831,18 +889,30 @@
obsSortSel.addEventListener('change', async function () {
obsSortMode = this.value;
localStorage.setItem('meshcore-obs-sort', obsSortMode);
// For non-observer sorts, fetch children for visible groups that don't have them yet
// For non-observer sorts, batch-fetch children for visible groups that don't have them yet
if (obsSortMode !== SORT_OBSERVER && groupByHash) {
const toFetch = packets.filter(p => p.hash && !p._children && (p.observation_count || 0) > 1);
await Promise.all(toFetch.map(async (p) => {
if (toFetch.length > 0) {
const hashes = toFetch.map(p => p.hash);
try {
const data = await api(`/packets/${p.hash}`);
if (data?.packet && data.observations) {
p._children = data.observations.map(o => clearParsedCache({...data.packet, ...o, _isObservation: true}));
p._fetchedData = data;
const resp = await fetch('/api/packets/observations', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({hashes})
});
if (resp.ok) {
const data = await resp.json();
const results = data.results || {};
for (const p of toFetch) {
const obs = results[p.hash];
if (obs && obs.length) {
p._children = obs.map(o => clearParsedCache({...p, ...o, _isObservation: true}));
p._fetchedData = {packet: p, observations: obs};
}
}
}
} catch {}
}));
}
}
// Re-sort all groups with children
for (const p of packets) {
@@ -1006,11 +1076,11 @@
}
else if (action === 'select-observation') {
const parentHash = row.dataset.parentHash;
const group = packets.find(p => p.hash === parentHash);
const group = hashIndex.get(parentHash);
const child = group?._children?.find(c => String(c.id) === String(value));
if (child) {
const parentData = group._fetchedData;
const obsPacket = parentData ? {...parentData.packet, observer_id: child.observer_id, observer_name: child.observer_name, snr: child.snr, rssi: child.rssi, path_json: child.path_json, timestamp: child.timestamp, first_seen: child.timestamp} : child;
const obsPacket = parentData ? {...parentData.packet, observer_id: child.observer_id, observer_name: child.observer_name, snr: child.snr, rssi: child.rssi, path_json: child.path_json, resolved_path: child.resolved_path, timestamp: child.timestamp, first_seen: child.timestamp} : child;
if (parentData) { clearParsedCache(obsPacket); }
selectPacket(child.id, parentHash, {packet: obsPacket, breakdown: parentData?.breakdown, observations: parentData?.observations}, child.id);
}
@@ -1099,8 +1169,8 @@
// Build HTML for a single flat (ungrouped) packet row
function buildFlatRowHtml(p) {
const decoded = getParsedDecoded(p);
const pathHops = getParsedPath(p);
const decoded = getParsedDecoded(p) || {};
const pathHops = getParsedPath(p) || [];
const region = p.observer_id ? (observerMap.get(p.observer_id)?.iata || '') : '';
const typeName = payloadTypeName(p.payload_type);
const typeClass = payloadTypeColor(p.payload_type);
@@ -1122,6 +1192,21 @@
</tr>`;
}
// Mark _rowCounts as stale so renderVisibleRows() recomputes them lazily.
// Called when expanded group children change outside renderTableRows() (#410).
function _invalidateRowCounts() {
_rowCountsDirty = true;
_cumulativeOffsetsCache = null;
}
// Recompute _rowCounts from _displayPackets if they've been invalidated.
function _refreshRowCountsIfDirty() {
if (!_rowCountsDirty || !_displayPackets.length) return;
_rowCounts = _displayPackets.map(function(p) { return _getRowCount(p); });
_cumulativeOffsetsCache = null;
_rowCountsDirty = false;
}
// Compute the number of DOM <tr> rows a single entry produces.
// Used by both row counting and renderVisibleRows to avoid divergence (#424).
function _getRowCount(p) {
@@ -1160,6 +1245,9 @@
const scrollContainer = document.getElementById('pktLeft');
if (!scrollContainer) return;
// Recompute row counts if they were invalidated (e.g. WS added children) (#410)
_refreshRowCountsIfDirty();
// Compute total DOM rows accounting for expanded groups
const offsets = _cumulativeRowOffsets();
const totalDomRows = offsets[offsets.length - 1];
@@ -1291,7 +1379,11 @@
}
if (filters.observer) {
const obsIds = new Set(filters.observer.split(','));
displayPackets = displayPackets.filter(p => obsIds.has(p.observer_id));
displayPackets = displayPackets.filter(p => {
if (obsIds.has(p.observer_id)) return true;
if (p._children) return p._children.some(c => obsIds.has(String(c.observer_id)));
return false;
});
}
// Packet Filter Language
@@ -1312,6 +1404,7 @@
if (!displayPackets.length) {
_displayPackets = [];
_rowCounts = [];
_rowCountsDirty = false;
_cumulativeOffsetsCache = null;
_observerFilterSet = null;
_lastVisibleStart = -1;
@@ -1331,6 +1424,7 @@
_displayGrouped = groupByHash;
_observerFilterSet = filters.observer ? new Set(filters.observer.split(',')) : null;
_rowCounts = displayPackets.map(p => _getRowCount(p));
_rowCountsDirty = false;
_cumulativeOffsetsCache = null;
attachVScrollListener();
@@ -1436,8 +1530,8 @@
const pkt = data.packet;
const breakdown = data.breakdown || {};
const ranges = breakdown.ranges || [];
const decoded = getParsedDecoded(pkt);
const pathHops = getParsedPath(pkt);
const decoded = getParsedDecoded(pkt) || {};
const pathHops = getParsedPath(pkt) || [];
// Resolve sender GPS — from packet directly, or from known node in DB
let senderLat = decoded.lat != null ? decoded.lat : (decoded.latitude || null);
@@ -1459,11 +1553,18 @@
} catch {}
}
// Re-resolve hops using client-side HopResolver with sender GPS context
// Resolve hops: prefer server-side resolved_path, fall back to client-side HopResolver
if (pathHops.length) {
try {
await ensureHopResolver();
const resolved = HopResolver.resolve(pathHops);
const serverResolved = getResolvedPath(pkt);
let resolved;
if (serverResolved && serverResolved.length === pathHops.length) {
await ensureHopResolver();
resolved = HopResolver.resolveFromServer(pathHops, serverResolved);
} else {
await ensureHopResolver();
resolved = HopResolver.resolve(pathHops);
}
if (resolved) {
for (const [k, v] of Object.entries(resolved)) {
hopNameCache[k] = v;
@@ -1640,22 +1741,25 @@
if (routeBtn && pathHops.length) {
routeBtn.addEventListener('click', async () => {
try {
// Anchor disambiguation from sender's location if known (e.g. ADVERT lat/lon)
const senderLat = decoded.lat || decoded.latitude;
const senderLon = decoded.lon || decoded.longitude;
// Resolve observer position for backward-pass anchor
let obsLat = null, obsLon = null;
const obsId = obsName(pkt.observer_id);
if (obsId && HopResolver.ready()) {
// Try to find observer in nodes list by name — best effort
// Prefer server-side resolved_path if available
const serverResolved = getResolvedPath(pkt);
let resolvedKeys;
if (serverResolved && serverResolved.length === pathHops.length) {
// Use server-resolved pubkeys, fall back to short prefix for null entries
resolvedKeys = pathHops.map((h, i) => serverResolved[i] || h);
} else {
// Fall back to client-side HopResolver
const senderLat = decoded.lat || decoded.latitude;
const senderLon = decoded.lon || decoded.longitude;
let obsLat = null, obsLon = null;
const obsId = obsName(pkt.observer_id);
await ensureHopResolver();
const data = { resolved: HopResolver.resolve(pathHops, senderLat || null, senderLon || null, obsLat, obsLon, pkt.observer_id) };
resolvedKeys = pathHops.map(h => {
const r = data.resolved?.[h];
return r?.pubkey || h;
});
}
await ensureHopResolver();
const data = { resolved: HopResolver.resolve(pathHops, senderLat || null, senderLon || null, obsLat, obsLon, pkt.observer_id) };
// Pass full pubkeys (client-disambiguated) to map, falling back to short prefix
const resolvedKeys = pathHops.map(h => {
const r = data.resolved?.[h];
return r?.pubkey || h;
});
// Build origin info for the sender node
const origin = {};
if (decoded.pubKey) origin.pubkey = decoded.pubKey;
@@ -1979,14 +2083,15 @@
const data = await api(`/packets/${hash}`);
const pkt = data.packet;
if (!pkt) return;
const group = packets.find(p => p.hash === hash);
const group = hashIndex.get(hash);
if (group && data.observations) {
group._children = data.observations.map(o => clearParsedCache({...pkt, ...o, _isObservation: true}));
group._fetchedData = data;
// Sort children based on current sort mode
sortGroupChildren(group);
}
// Resolve any new hops from children
// Resolve hops from children: prefer server-side resolved_path
await cacheResolvedPaths(group?._children || []);
const childHops = new Set();
for (const c of (group?._children || [])) {
try { getParsedPath(c).forEach(h => childHops.add(h)); } catch {}
@@ -2039,6 +2144,8 @@
renderPath,
_getRowCount,
_cumulativeRowOffsets,
_invalidateRowCounts,
_refreshRowCountsIfDirty,
buildGroupRowHtml,
buildFlatRowHtml,
};
+1 -1
View File
@@ -5,7 +5,7 @@
let interval = null;
async function render(app) {
app.innerHTML = '<div id="perfWrapper" style="height:100%;overflow-y:auto;padding:16px 24px;"><h2>⚡ Performance Dashboard</h2><div id="perfContent">Loading...</div></div>';
app.innerHTML = '<div id="perfWrapper" style="padding:16px 24px;"><h2>⚡ Performance Dashboard</h2><div id="perfContent">Loading...</div></div>';
await refresh();
}
+30 -5
View File
@@ -181,7 +181,12 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
}
/* === Layout === */
#app { height: calc(100vh - 52px); height: calc(100dvh - 52px); overflow: hidden; }
/* Default: body-scroll mode content pushes beyond viewport, iOS status-bar
tap-to-scroll works because <body> is the scroll container. Pages that need
a fixed-height container (maps, virtual-scroll, split-panels) add
.app-fixed via the router so their children can use height:100%. */
#app { min-height: calc(100vh - 52px); min-height: calc(100dvh - 52px); }
#app.app-fixed { height: calc(100vh - 52px); height: calc(100dvh - 52px); min-height: 0; overflow: hidden; }
.split-layout {
display: flex; height: 100%; overflow: hidden;
@@ -630,6 +635,15 @@ button.ch-item.selected { background: var(--selected-bg); }
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 8px; padding: 12px; margin-bottom: 8px;
}
/* Bug 7 fix: neighbor table text inherits accent color — force readable text */
.node-detail-section .data-table td,
.node-full-card .data-table td {
color: var(--text);
}
.node-detail-section .data-table td a,
.node-full-card .data-table td a {
color: var(--accent);
}
.node-detail-section h4 {
font-size: 12px; text-transform: uppercase; letter-spacing: .5px;
color: var(--text-muted); margin-bottom: 8px; padding-bottom: 4px;
@@ -665,7 +679,7 @@ button.ch-item.selected { background: var(--selected-bg); }
.advert-info { font-size: 12px; line-height: 1.5; }
/* === Traces Page === */
.traces-page { padding: 16px; max-width: var(--trace-max-width, 95vw); margin: 0 auto; overflow-y: auto; height: 100%; }
.traces-page { padding: 16px; max-width: var(--trace-max-width, 95vw); margin: 0 auto; }
.trace-search {
display: flex; gap: 8px; margin-bottom: 20px;
}
@@ -737,7 +751,7 @@ button.ch-item.selected { background: var(--selected-bg); }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* === Observers Page === */
.observers-page { padding: 20px; max-width: 1200px; margin: 0 auto; overflow-y: auto; height: calc(100vh - 56px); }
.observers-page { padding: 20px; max-width: 1200px; margin: 0 auto; }
.obs-summary { display: flex; gap: 20px; margin-bottom: 16px; flex-wrap: wrap; }
.obs-stat { display: flex; align-items: center; gap: 6px; font-size: 14px; color: var(--text-muted); }
.health-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
@@ -938,7 +952,9 @@ button.ch-item.selected { background: var(--selected-bg); }
.filter-bar { flex-direction: row; flex-wrap: wrap; gap: 4px; }
.filter-toggle-btn { display: inline-flex !important; }
.filter-bar > *:not(.filter-toggle-btn):not(.col-toggle-wrap) { display: none; }
.filter-bar.filters-expanded > * { display: inline-flex; }
/* Must match :not() specificity of the hide rule above, otherwise .filters-expanded loses
the specificity battle and filter children stay hidden (see issue #534). */
.filter-bar.filters-expanded > *:not(.filter-toggle-btn):not(.col-toggle-wrap) { display: inline-flex; }
.filter-bar.filters-expanded > .col-toggle-wrap { display: inline-block; }
.filter-bar.filters-expanded input { width: 100%; }
.filter-bar.filters-expanded select { width: 100%; }
@@ -1127,7 +1143,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
.node-activity-time { color: var(--text-muted); white-space: nowrap; min-width: 70px; font-size: 12px; }
/* Analytics page */
.analytics-page { padding: 16px 24px; max-width: 1600px; margin: 0 auto; overflow-y: auto; height: 100%; }
.analytics-page { padding: 16px 24px; max-width: 1600px; margin: 0 auto; }
.analytics-header { margin-bottom: 20px; }
.analytics-header h2 { margin: 0 0 4px; }
.analytics-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px; margin-bottom: 16px; }
@@ -1933,3 +1949,12 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
.compare-select { min-width: auto; width: 100%; }
.compare-summary { grid-template-columns: 1fr; }
}
/* Neighbor graph canvas focus indicator for keyboard navigation */
#ngCanvas:focus {
outline: 2px solid var(--link-color, #60a5fa);
outline-offset: 2px;
}
#ngCanvas:focus:not(:focus-visible) {
outline: none;
}
+109
View File
@@ -403,6 +403,115 @@ test('returns true when server has no default for overridden key', () => {
assert.strictEqual(api.isOverridden('theme', 'accent'), true);
});
// ── Bug #518 Fixes ──
test('phantom overrides cleaned on init — matching scalars removed', () => {
const { api, ls } = loadCustomizer();
const server = { theme: { accent: '#4a9eff', border: '#e2e5ea' }, typeColors: { ADVERT: '#22c55e' } };
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#4a9eff' }, typeColors: { ADVERT: '#22c55e' } }));
api.init(server);
const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}');
assert.ok(!delta.theme, 'phantom theme override should be cleaned');
assert.ok(!delta.typeColors, 'phantom typeColors override should be cleaned');
});
test('phantom overrides cleaned on init — matching arrays removed', () => {
const { api, ls } = loadCustomizer();
const server = { home: { steps: [{ emoji: '📡', title: 'Go', description: 'Do it' }] } };
ls.setItem('cs-theme-overrides', JSON.stringify({ home: { steps: [{ emoji: '📡', title: 'Go', description: 'Do it' }] } }));
api.init(server);
const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}');
assert.ok(!delta.home, 'phantom home array override should be cleaned');
});
test('real overrides preserved after init cleanup', () => {
const { api, ls } = loadCustomizer();
const server = { theme: { accent: '#4a9eff' } };
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ff0000' } }));
api.init(server);
const delta = JSON.parse(ls.getItem('cs-theme-overrides'));
assert.strictEqual(delta.theme.accent, '#ff0000');
});
test('isOverridden handles array comparison via JSON.stringify', () => {
const { api, ls } = loadCustomizer();
const server = { home: { steps: [{ emoji: '📡', title: 'Go', description: 'Do' }] } };
ls.setItem('cs-theme-overrides', JSON.stringify({ home: { steps: [{ emoji: '📡', title: 'Go', description: 'Do' }] } }));
api.init(server);
assert.strictEqual(api.isOverridden('home', 'steps'), false, 'matching array should not be overridden');
});
test('isOverridden returns true for differing arrays', () => {
const { api, ls } = loadCustomizer();
const server = { home: { steps: [{ emoji: '📡', title: 'Go', description: 'Do' }] } };
ls.setItem('cs-theme-overrides', JSON.stringify({ home: { steps: [{ emoji: '🚀', title: 'New', description: 'Changed' }] } }));
api.init(server);
assert.strictEqual(api.isOverridden('home', 'steps'), true, 'differing array should be overridden');
});
test('setOverride prunes value matching server default', () => {
const { api, ls } = loadCustomizer();
const server = { theme: { accent: '#4a9eff' } };
api.init(server);
api.setOverride('theme', 'accent', '#4a9eff');
// debounce fires synchronously in sandbox
const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}');
assert.ok(!delta.theme || !delta.theme.accent, 'matching value should be pruned after setOverride');
});
// ── Fix #2: _cleanPhantomOverrides when server has no section ──
test('phantom overrides cleaned when server has NO home section', () => {
const { api, ls } = loadCustomizer();
// Server has theme but NO home — the common deployment case
const server = { theme: { accent: '#4a9eff' } };
ls.setItem('cs-theme-overrides', JSON.stringify({ home: { checklist: [], steps: [] } }));
api.init(server);
const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}');
assert.ok(!delta.home, 'phantom home override should be removed when server has no home section');
});
test('phantom overrides cleaned when server section is undefined — empty arrays removed', () => {
const { api, ls } = loadCustomizer();
const server = { theme: { accent: '#4a9eff' }, nodeColors: { repeater: '#dc2626' } };
// timestamps has actual values (not phantom), home has empty arrays (phantom)
ls.setItem('cs-theme-overrides', JSON.stringify({
timestamps: { defaultMode: 'ago', timezone: 'local' },
home: { checklist: [], steps: [] }
}));
api.init(server);
const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}');
assert.ok(!delta.home, 'phantom home with empty arrays should be removed');
// timestamps has non-empty values — preserved even without server section
assert.ok(delta.timestamps, 'timestamps with actual values should be preserved');
assert.strictEqual(delta.timestamps.defaultMode, 'ago');
});
// ── Fix #4: setOverride with value matching server default is NOT stored ──
test('setOverride with value matching server default is not stored', () => {
const { api, ls } = loadCustomizer();
const server = { theme: { accent: '#4a9eff', border: '#e2e5ea' } };
api.init(server);
// Set override to same value as server default
api.setOverride('theme', 'accent', '#4a9eff');
const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}');
assert.ok(!delta.theme || !delta.theme.accent, 'value matching server default should not be stored');
});
test('existing user overrides are NOT pruned by setOverride on other keys', () => {
const { api, ls } = loadCustomizer();
const server = { theme: { accent: '#4a9eff', border: '#e2e5ea' } };
// User previously chose a custom accent (different from server default)
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ff0000' } }));
api.init(server);
// Now user changes border — accent should be preserved
api.setOverride('theme', 'border', '#00ff00');
const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}');
assert.strictEqual(delta.theme.accent, '#ff0000', 'pre-existing custom override should be preserved');
assert.strictEqual(delta.theme.border, '#00ff00', 'new non-matching override should be stored');
});
// ── Summary ──
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);
+216 -8
View File
@@ -550,7 +550,7 @@ async function run() {
await page.goto(`${BASE}/#/analytics`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#analyticsTabs');
const tabs = await page.$$('#analyticsTabs .tab-btn');
assert(tabs.length >= 8, `Expected >=8 analytics tabs, got ${tabs.length}`);
assert(tabs.length >= 10, `Expected >=10 analytics tabs, got ${tabs.length}`);
// Overview tab should be active by default and show stat cards
await page.waitForSelector('#analyticsContent .stat-card', { timeout: 8000 });
const cards = await page.$$('#analyticsContent .stat-card');
@@ -624,6 +624,53 @@ async function run() {
assert(content.length > 10, 'Distance tab should render content');
});
await test('Analytics Neighbor Graph tab renders canvas and stats', async () => {
await page.click('[data-tab="neighbor-graph"]');
await page.waitForSelector('#ngCanvas', { timeout: 8000 });
const hasCanvas = await page.$('#ngCanvas');
assert(hasCanvas, 'Neighbor Graph tab should have a canvas element');
const hasStats = await page.$$eval('#ngStats .stat-card', els => els.length);
assert(hasStats >= 3, `Neighbor Graph stats should have >=3 cards, got ${hasStats}`);
// Verify filters exist
const hasSlider = await page.$('#ngMinScore');
assert(hasSlider, 'Should have min score slider');
const hasConfidence = await page.$('#ngConfidence');
assert(hasConfidence, 'Should have confidence filter');
});
await test('Analytics Neighbor Graph filter changes update stats', async () => {
// Capture edge count before filter
const edgesBefore = await page.$eval('#ngStats', el => {
const cards = el.querySelectorAll('.stat-card');
for (const c of cards) {
if (c.textContent.toLowerCase().includes('edge')) {
const m = c.textContent.match(/\d+/);
if (m) return parseInt(m[0], 10);
}
}
return -1;
});
// Set min score slider to high value to reduce edges
await page.$eval('#ngMinScore', el => { el.value = 90; el.dispatchEvent(new Event('input')); });
await page.waitForTimeout(300);
const edgesAfter = await page.$eval('#ngStats', el => {
const cards = el.querySelectorAll('.stat-card');
for (const c of cards) {
if (c.textContent.toLowerCase().includes('edge')) {
const m = c.textContent.match(/\d+/);
if (m) return parseInt(m[0], 10);
}
}
return -1;
});
assert(edgesBefore >= 0, 'Should find edge count in stats before filter');
assert(edgesAfter >= 0, 'Should find edge count in stats after filter');
assert(edgesAfter <= edgesBefore, `Raising min score should reduce (or keep) edge count: ${edgesBefore}${edgesAfter}`);
// Reset slider
await page.$eval('#ngMinScore', el => { el.value = 0; el.dispatchEvent(new Event('input')); });
await page.waitForTimeout(200);
});
// --- Group: Compare page ---
await test('Compare page loads with observer dropdowns', async () => {
@@ -1020,20 +1067,29 @@ async function run() {
await test('Customizer v2: setOverride persists and applies CSS', async () => {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
// Force light mode — CI headless browsers may default to dark mode,
// and in dark mode themeDark.accent overwrites theme.accent in applyCSS
await page.evaluate(() => {
localStorage.setItem('meshcore-theme', 'light');
document.documentElement.setAttribute('data-theme', 'light');
});
// Clear any existing overrides
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
// Wait for init() to complete (server config fetch + full pipeline) before
// setting override, so _runPipeline from init doesn't overwrite our value.
await page.waitForFunction(() => {
return window._customizerV2 && window._customizerV2.initDone;
}, { timeout: 5000 });
// Set an override via the API
const result = await page.evaluate(() => {
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
window._customizerV2.setOverride('theme', 'accent', '#ff0000');
// Wait for debounce
// Wait for debounce (300ms) + buffer
return new Promise(resolve => setTimeout(() => {
const stored = JSON.parse(localStorage.getItem('cs-theme-overrides') || '{}');
const cssVal = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
resolve({ stored, cssVal });
}, 500));
});
assert(!result.error, result.error || '');
assert(result.stored.theme && result.stored.theme.accent === '#ff0000',
'Override not persisted to localStorage');
assert(result.cssVal === '#ff0000',
@@ -1045,9 +1101,17 @@ async function run() {
await test('Customizer v2: clearOverride resets to server default', async () => {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
// Force light mode for consistent CSS testing
await page.evaluate(() => {
localStorage.setItem('meshcore-theme', 'light');
document.documentElement.setAttribute('data-theme', 'light');
});
// Wait for init() to complete so _serverDefaults is populated
await page.waitForFunction(() => {
return window._customizerV2 && window._customizerV2.initDone;
}, { timeout: 5000 });
const result = await page.evaluate(() => {
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
// Get the server default accent
// Set the server default accent
window._customizerV2.setOverride('theme', 'accent', '#ff0000');
return new Promise(resolve => setTimeout(() => {
window._customizerV2.clearOverride('theme', 'accent');
@@ -1056,7 +1120,6 @@ async function run() {
resolve({ hasAccent });
}, 500));
});
assert(!result.error, result.error || '');
assert(!result.hasAccent, 'accent should be removed from overrides after clearOverride');
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
});
@@ -1261,7 +1324,6 @@ async function run() {
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
});
// --- Group: Show Neighbors (#484 fix) ---
await test('Show Neighbors populates neighborPubkeys from affinity API', async () => {
const testPubkey = 'aabbccdd11223344556677889900aabbccddeeff00112233445566778899001122';
@@ -1405,6 +1467,152 @@ async function run() {
await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`);
await page.unroute(`**/api/nodes/${testPubkey}/paths*`);
});
// ─── Neighbor section tests ───────────────────────────────────────────────
await test('Node detail: neighbors section exists with correct columns', async () => {
// Navigate to a node detail page (use the first node in the list)
await page.goto(BASE + '/#/nodes');
await page.waitForSelector('#nodesBody tr[data-key]', { timeout: 10000 });
// Get the first node's pubkey from the row's data-key attribute
const pubkey = await page.$eval('#nodesBody tr[data-key]', el => el.dataset.key);
await page.goto(BASE + '/#/nodes/' + pubkey);
await page.waitForSelector('#node-neighbors', { timeout: 10000 });
// Check the section exists
const header = await page.$eval('#fullNeighborsHeader', el => el.textContent);
assert(header.startsWith('Neighbors'), 'Header should start with "Neighbors", got: ' + header);
// Wait for content to load (either table or empty state)
await page.waitForFunction(() => {
const el = document.getElementById('fullNeighborsContent');
return el && !el.innerHTML.includes('spinner');
}, { timeout: 10000 });
const hasTable = await page.$('#fullNeighborsContent .data-table');
if (hasTable) {
// Check columns
const headers = await page.$$eval('#fullNeighborsContent thead th', ths => ths.map(t => t.textContent));
assert(headers.includes('Neighbor'), 'Should have Neighbor column');
assert(headers.includes('Role'), 'Should have Role column');
assert(headers.includes('Score'), 'Should have Score column');
assert(headers.includes('Obs'), 'Should have Obs column');
assert(headers.includes('Last Seen'), 'Should have Last Seen column');
assert(headers.includes('Conf'), 'Should have Conf column');
} else {
// Empty state
const text = await page.$eval('#fullNeighborsContent', el => el.textContent);
assert(text.includes('No neighbor data') || text.includes('Could not load'), 'Should show empty or error state');
}
});
// ─── End neighbor section tests ───────────────────────────────────────────
// ─── Affinity debug overlay tests ─────────────────────────────────────────
await test('Map: affinity debug checkbox exists in DOM', async () => {
await page.goto(BASE + '/#/map');
await page.waitForSelector('#mapControls', { timeout: 5000 });
const checkbox = await page.$('#mcAffinityDebug');
assert(checkbox !== null, 'Affinity debug checkbox should exist in DOM');
});
await test('Map: affinity debug checkbox toggles without crash', async () => {
await page.goto(BASE + '/#/map');
await page.waitForSelector('#mapControls', { timeout: 5000 });
// Make the checkbox visible by setting localStorage
await page.evaluate(() => localStorage.setItem('meshcore-affinity-debug', 'true'));
await page.reload();
await page.waitForSelector('#mapControls', { timeout: 5000 });
const label = await page.$('#mcAffinityDebugLabel');
if (label) {
const display = await label.evaluate(el => getComputedStyle(el).display);
// When debugAffinity or localStorage is set, label should be visible
// Just verify toggling doesn't crash
const cb = await page.$('#mcAffinityDebug');
if (cb) {
await cb.click();
// Wait a bit for fetch to complete (or fail gracefully)
await page.waitForTimeout(500);
await cb.click();
await page.waitForTimeout(200);
}
}
// Clean up
await page.evaluate(() => localStorage.removeItem('meshcore-affinity-debug'));
assert(true, 'Toggle did not crash');
});
await test('Node detail: affinity debug section expandable', async () => {
await page.goto(BASE + '/#/nodes');
await page.waitForSelector('#nodesBody tr[data-key]', { timeout: 10000 });
// Enable debug mode
await page.evaluate(() => localStorage.setItem('meshcore-affinity-debug', 'true'));
// Click first node to go to detail
const nodeLink = await page.$('a[href*="/nodes/"]');
if (nodeLink) {
await nodeLink.click();
await page.waitForTimeout(1000);
const debugPanel = await page.$('#node-affinity-debug');
if (debugPanel) {
const display = await debugPanel.evaluate(el => el.style.display);
// Panel should be visible when debug is enabled
const header = await debugPanel.$('h4');
if (header) {
// Click to expand
await header.click();
await page.waitForTimeout(300);
const body = await debugPanel.$('.affinity-debug-body');
if (body) {
const bodyDisplay = await body.evaluate(el => el.style.display);
assert(bodyDisplay !== 'none', 'Debug body should be expanded after click');
}
}
}
}
await page.evaluate(() => localStorage.removeItem('meshcore-affinity-debug'));
assert(true, 'Debug panel expansion works');
});
// ─── End affinity debug tests ─────────────────────────────────────────────
// ─── Mobile filter dropdown tests (#534) ──────────────────────────────────
await test('Mobile: filter toggle expands filter bar on packets page (#534)', async () => {
// Use a mobile viewport
await page.setViewportSize({ width: 480, height: 800 });
await page.goto(`${BASE}/#/packets`);
await page.waitForTimeout(500);
const filterBar = await page.$('.filter-bar');
assert(filterBar, 'Filter bar should exist on packets page');
// Before clicking toggle, filter inputs should be hidden
const toggleBtn = await page.$('.filter-toggle-btn');
assert(toggleBtn, 'Filter toggle button should exist on mobile');
await toggleBtn.click();
await page.waitForTimeout(300);
// After clicking, .filters-expanded should be on the filter bar
const expanded = await filterBar.evaluate(el => el.classList.contains('filters-expanded'));
assert(expanded, 'Filter bar should have filters-expanded class after toggle');
// Filter inputs should now be visible
const filterInput = await page.$('.filter-bar input');
if (filterInput) {
const display = await filterInput.evaluate(el => getComputedStyle(el).display);
assert(display !== 'none', `Filter input should be visible when expanded, got display: ${display}`);
}
const filterSelect = await page.$('.filter-bar select');
if (filterSelect) {
const display = await filterSelect.evaluate(el => getComputedStyle(el).display);
assert(display !== 'none', `Filter select should be visible when expanded, got display: ${display}`);
}
// Reset viewport
await page.setViewportSize({ width: 1280, height: 720 });
});
// ─── End mobile filter tests ──────────────────────────────────────────────
// Extract frontend coverage if instrumented server is running
try {
+243 -11
View File
@@ -564,6 +564,93 @@ console.log('\n=== hop-resolver.js ===');
});
}
// ===== resolveFromServer (hop-resolver.js, M4 #555) =====
console.log('\n=== resolveFromServer (hop-resolver.js) ===');
{
const ctx = makeSandbox();
ctx.IATA_COORDS_GEO = {};
loadInCtx(ctx, 'public/hop-resolver.js');
const HR = ctx.window.HopResolver;
test('resolveFromServer works without init (uses pubkey prefix as name)', () => {
const pk = 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890';
const result = HR.resolveFromServer(['AB'], [pk]);
assert.strictEqual(result['AB'].name, pk.slice(0, 8));
assert.strictEqual(result['AB'].pubkey, pk);
});
test('resolveFromServer with matching node', () => {
const pubkey = 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890';
HR.init([{ public_key: pubkey, name: 'NodeA', lat: 37.3, lon: -122.0 }]);
const result = HR.resolveFromServer(['AB'], [pubkey]);
assert.strictEqual(result['AB'].name, 'NodeA');
assert.strictEqual(result['AB'].pubkey, pubkey);
assert.ok(!result['AB'].ambiguous);
});
test('resolveFromServer with null entry skips it', () => {
const pubkey = 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890';
HR.init([{ public_key: pubkey, name: 'NodeA', lat: 37.3, lon: -122.0 }]);
const result = HR.resolveFromServer(['AB', 'CD'], [pubkey, null]);
assert.strictEqual(result['AB'].name, 'NodeA');
assert.ok(!('CD' in result)); // null entries are skipped
});
test('resolveFromServer with unknown pubkey uses prefix', () => {
HR.init([{ public_key: 'aaaa0000', name: 'Other' }]);
const unknownPk = '1111111111111111111111111111111111111111111111111111111111111111';
const result = HR.resolveFromServer(['AB'], [unknownPk]);
assert.strictEqual(result['AB'].name, unknownPk.slice(0, 8));
assert.strictEqual(result['AB'].pubkey, unknownPk);
});
test('resolveFromServer mismatched lengths returns empty', () => {
HR.init([{ public_key: 'abcdef1234567890', name: 'NodeA' }]);
const result = HR.resolveFromServer(['AB', 'CD'], ['abcdef1234567890']);
assert.strictEqual(Object.keys(result).length, 0);
});
}
// ===== getResolvedPath (packet-helpers.js, M4 #555) =====
console.log('\n=== getResolvedPath (packet-helpers.js) ===');
{
const ctx = makeSandbox();
loadInCtx(ctx, 'public/packet-helpers.js');
const getResolvedPath = ctx.window.getResolvedPath;
test('getResolvedPath returns null when absent', () => {
assert.strictEqual(getResolvedPath({}), null);
});
test('getResolvedPath parses JSON string', () => {
const pkt = { resolved_path: '["aabb","ccdd",null]' };
const result = getResolvedPath(pkt);
assert.deepStrictEqual(result, ['aabb', 'ccdd', null]);
});
test('getResolvedPath returns array as-is', () => {
const arr = ['aabb', null];
const pkt = { resolved_path: arr };
assert.strictEqual(getResolvedPath(pkt), arr);
});
test('getResolvedPath caches result', () => {
const pkt = { resolved_path: '["aabb"]' };
const r1 = getResolvedPath(pkt);
const r2 = getResolvedPath(pkt);
assert.strictEqual(r1, r2); // same reference
});
test('clearParsedCache clears resolved path cache', () => {
const clearParsedCache = ctx.window.clearParsedCache;
const pkt = { resolved_path: '["aabb"]' };
getResolvedPath(pkt);
assert.ok(pkt._parsedResolvedPath !== undefined);
clearParsedCache(pkt);
assert.strictEqual(pkt._parsedResolvedPath, undefined);
});
}
// ===== haversineKm exposed from HopResolver (issue #433) =====
console.log('\n=== haversineKm (hop-resolver.js) ===');
{
@@ -998,6 +1085,56 @@ console.log('\n=== live.js: pruneStaleNodes ===');
assert.ok(markers['apiNode'], 'API stale node should NOT be removed');
assert.ok(data['apiNode'], 'API stale node data should NOT be removed');
});
test('pruneStaleNodes cleans up nodeActivity for removed nodes', () => {
const { ctx } = makeLiveSandbox();
const prune = ctx.window._livePruneStaleNodes;
const markers = ctx.window._liveNodeMarkers();
const data = ctx.window._liveNodeData();
const activity = ctx.window._liveNodeActivity();
// WS-only stale node
markers['staleNode'] = { _glowMarker: null };
data['staleNode'] = { public_key: 'staleNode', role: 'companion', _liveSeen: Date.now() - 48 * 3600000 };
activity['staleNode'] = 5;
// Active node
markers['activeNode'] = { setStyle: function() {}, _glowMarker: null };
data['activeNode'] = { public_key: 'activeNode', role: 'companion', _liveSeen: Date.now() };
activity['activeNode'] = 3;
prune();
assert.ok(!markers['staleNode'], 'stale node marker removed');
assert.ok(!data['staleNode'], 'stale node data removed');
assert.ok(!activity['staleNode'], 'stale node activity removed');
assert.ok(markers['activeNode'], 'active node marker preserved');
assert.ok(data['activeNode'], 'active node data preserved');
assert.strictEqual(activity['activeNode'], 3, 'active node activity preserved');
});
test('pruneStaleNodes removes orphaned nodeActivity entries', () => {
const { ctx } = makeLiveSandbox();
const prune = ctx.window._livePruneStaleNodes;
const markers = ctx.window._liveNodeMarkers();
const data = ctx.window._liveNodeData();
const activity = ctx.window._liveNodeActivity();
// Add an active node
markers['existingNode'] = { setStyle: function() {}, _glowMarker: null };
data['existingNode'] = { public_key: 'existingNode', role: 'companion', _liveSeen: Date.now() };
activity['existingNode'] = 2;
// Add orphaned activity (no corresponding nodeData)
activity['ghostNode'] = 10;
prune();
assert.ok(markers['existingNode'], 'existing node preserved');
assert.ok(data['existingNode'], 'existing node data preserved');
assert.strictEqual(activity['existingNode'], 2, 'existing node activity preserved');
assert.ok(!activity['ghostNode'], 'orphaned activity entry removed');
});
}
// ===== live.js: vcrFormatTime respects UTC/local setting =====
@@ -1980,6 +2117,30 @@ console.log('\n=== customize-v2.js: core behavior ===');
assert.strictEqual(effective.theme.navBg, '#222222');
});
test('computeEffective provides home defaults when server home is null', () => {
const ctx = makeSandbox();
ctx.CustomEvent = function (type) { this.type = type; };
const v2 = loadCustomizeV2(ctx);
const server = { theme: { accent: '#111111' }, home: null };
const effective = v2.computeEffective(server, {});
assert.ok(effective.home, 'home should not be null');
assert.strictEqual(effective.home.heroTitle, 'CoreScope');
assert.ok(Array.isArray(effective.home.steps), 'steps should be an array');
assert.ok(effective.home.steps.length > 0, 'steps should not be empty');
assert.ok(Array.isArray(effective.home.footerLinks), 'footerLinks should be an array');
});
test('computeEffective merges user home overrides with defaults', () => {
const ctx = makeSandbox();
ctx.CustomEvent = function (type) { this.type = type; };
const v2 = loadCustomizeV2(ctx);
const server = { home: null };
const overrides = { home: { heroTitle: 'MyMesh' } };
const effective = v2.computeEffective(server, overrides);
assert.strictEqual(effective.home.heroTitle, 'MyMesh');
assert.ok(Array.isArray(effective.home.steps), 'steps should survive user override of heroTitle');
});
test('isValidColor accepts hex, rgb, hsl, and named colors', () => {
const ctx = makeSandbox();
ctx.CustomEvent = function (type) { this.type = type; };
@@ -2671,6 +2832,63 @@ console.log('\n=== packets.js: savedTimeWindowMin defaults ===');
'buildGroupRowHtml should use hoisted _observerFilterSet');
});
test('observer filter in grouped mode includes packet when child matches (#537)', () => {
// The display filter should keep a grouped packet whose primary observer_id
// does NOT match, but one of its _children does.
const obsIds = new Set(['OBS_B']);
const packets = [
{ observer_id: 'OBS_A', _children: [{ observer_id: 'OBS_A' }, { observer_id: 'OBS_B' }] },
{ observer_id: 'OBS_C', _children: [{ observer_id: 'OBS_C' }] },
];
const result = packets.filter(p => {
if (obsIds.has(p.observer_id)) return true;
if (p._children) return p._children.some(c => obsIds.has(String(c.observer_id)));
return false;
});
assert.strictEqual(result.length, 1, 'should keep packet with matching child observer');
assert.strictEqual(result[0].observer_id, 'OBS_A');
});
test('observer filter in grouped mode hides packet with no matching observations (#537)', () => {
const obsIds = new Set(['OBS_X']);
const packets = [
{ observer_id: 'OBS_A', _children: [{ observer_id: 'OBS_A' }, { observer_id: 'OBS_B' }] },
];
const result = packets.filter(p => {
if (obsIds.has(p.observer_id)) return true;
if (p._children) return p._children.some(c => obsIds.has(String(c.observer_id)));
return false;
});
assert.strictEqual(result.length, 0, 'should hide packet with no matching observers');
});
test('WS observer filter checks children for grouped packets (#537)', () => {
const filters = { observer: 'OBS_B' };
const obsSet = new Set(filters.observer.split(','));
const p = { observer_id: 'OBS_A', _children: [{ observer_id: 'OBS_B' }] };
const passes = obsSet.has(p.observer_id) || (p._children && p._children.some(c => obsSet.has(String(c.observer_id))));
assert.ok(passes, 'WS filter should pass grouped packet when child matches');
const p2 = { observer_id: 'OBS_C', _children: [{ observer_id: 'OBS_D' }] };
const passes2 = obsSet.has(p2.observer_id) || (p2._children && p2._children.some(c => obsSet.has(String(c.observer_id))));
assert.ok(!passes2, 'WS filter should reject grouped packet with no matching observers');
});
test('packets.js display filter checks _children for observer match (#537)', () => {
// Verify the actual source code has the children check
assert.ok(
packetsSource.includes('p._children) return p._children.some(c => obsIds.has(String(c.observer_id))'),
'display filter should check _children for observer match'
);
});
test('packets.js WS filter checks _children for observer match (#537)', () => {
assert.ok(
packetsSource.includes('p._children && p._children.some(c => obsSet.has(String(c.observer_id)))'),
'WS filter should check _children for observer match'
);
});
test('buildFlatRowHtml has null-safe decoded_json', () => {
const flatBuilderMatch = packetsSource.match(/function buildFlatRowHtml[\s\S]*?(?=\n function )/);
assert.ok(flatBuilderMatch, 'buildFlatRowHtml should exist');
@@ -2975,20 +3193,24 @@ console.log('\n=== channels.js: formatHashHex (issue #465) ===');
'destroy must reset observerMap to empty Map');
});
test('WS handler debounces render via _wsRenderTimer', () => {
test('WS handler coalesces render via rAF (#396)', () => {
const wsBlock = src.slice(src.indexOf('wsHandler = debouncedOnWS'), src.indexOf('function destroy()'));
assert.ok(wsBlock.includes('_wsRenderTimer'),
'WS handler must debounce renders via _wsRenderTimer');
assert.ok(wsBlock.includes('clearTimeout(_wsRenderTimer)'),
'WS handler must clear pending timer before scheduling new render');
assert.ok(/setTimeout\(function \(\) \{ renderTableRows\(\); \}/.test(wsBlock),
'WS handler must schedule renderTableRows via setTimeout');
assert.ok(wsBlock.includes('scheduleWSRender()'),
'WS handler must coalesce renders via scheduleWSRender()');
// Verify scheduleWSRender uses requestAnimationFrame
const schedFn = src.slice(src.indexOf('function scheduleWSRender()'), src.indexOf('function scheduleWSRender()') + 300);
assert.ok(schedFn.includes('requestAnimationFrame'),
'scheduleWSRender must use requestAnimationFrame for coalescing');
assert.ok(schedFn.includes('_wsRenderDirty'),
'scheduleWSRender must use dirty flag pattern');
});
test('destroy clears _wsRenderTimer', () => {
const destroyBlock = src.slice(src.indexOf('function destroy()'), src.indexOf('function destroy()') + 500);
assert.ok(destroyBlock.includes('clearTimeout(_wsRenderTimer)'),
'destroy must clear _wsRenderTimer to prevent stale renders after navigation');
test('destroy clears rAF and dirty flag (#396)', () => {
const destroyBlock = src.slice(src.indexOf('function destroy()'), src.indexOf('function destroy()') + 600);
assert.ok(destroyBlock.includes('cancelAnimationFrame(_wsRafId)'),
'destroy must cancel pending rAF to prevent stale renders after navigation');
assert.ok(destroyBlock.includes('_wsRenderDirty = false'),
'destroy must reset dirty flag');
});
}
// ===== NODES.JS: shared sandbox factory =====
@@ -4102,7 +4324,17 @@ console.log('\n=== app.js: routeTypeName/payloadTypeName edge cases ===');
assertJsonEqual(getParsedPath(p), []);
});
test('getParsedPath: cached null _parsedPath returns empty array (#538)', () => {
const p = { path_json: '["a"]', _parsedPath: null };
assertJsonEqual(getParsedPath(p), []);
});
// --- getParsedDecoded ---
test('getParsedDecoded: cached null _parsedDecoded returns empty object (#538)', () => {
const p = { decoded_json: '{"x":1}', _parsedDecoded: null };
assertJsonEqual(getParsedDecoded(p), {});
});
test('getParsedDecoded: valid JSON object', () => {
const p = { decoded_json: '{"type":"GRP_TXT","text":"hello"}' };
const result = getParsedDecoded(p);
+99
View File
@@ -0,0 +1,99 @@
/**
* Unit tests for HopResolver affinity-aware hop resolution.
*/
'use strict';
const fs = require('fs');
const vm = require('vm');
// Load hop-resolver.js in a sandboxed context
const code = fs.readFileSync(__dirname + '/public/hop-resolver.js', 'utf8');
const sandbox = { window: {}, console, Math, Object, Array, Number, Date, Map, Set, parseInt, parseFloat, encodeURIComponent };
vm.createContext(sandbox);
vm.runInContext(code, sandbox);
const HopResolver = sandbox.window.HopResolver;
let passed = 0;
let failed = 0;
function assert(condition, msg) {
if (condition) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
// ── Test nodes ──
// Two nodes share the same 1-byte prefix "ab"
const nodeA = { public_key: 'ab1111', name: 'NodeA', lat: 37.0, lon: -122.0 };
const nodeB = { public_key: 'ab2222', name: 'NodeB', lat: 38.0, lon: -123.0 };
const nodeC = { public_key: 'cd3333', name: 'NodeC', lat: 37.5, lon: -122.5 };
console.log('\n=== HopResolver Affinity Tests ===\n');
// Test 1: Affinity prefers neighbor candidate over geo-closest
console.log('Test 1: Affinity prefers neighbor over geo-closest');
HopResolver.init([nodeA, nodeB, nodeC]);
HopResolver.setAffinity({
edges: [
{ source: 'cd3333', target: 'ab2222', score: 0.8 }
// NodeC is a neighbor of NodeB but NOT NodeA
]
});
// Resolve hop "ab" after NodeC was resolved — should pick NodeB (neighbor) not NodeA (geo-closer)
// Origin at NodeC's position so forward pass runs with NodeC as anchor
const result1 = HopResolver.resolve(['cd33', 'ab'], nodeC.lat, nodeC.lon, null, null, null);
assert(result1['ab'].name === 'NodeB', 'Should pick NodeB (affinity neighbor of NodeC) — got: ' + result1['ab'].name);
// Test 2: Without affinity, falls back to geo-closest
console.log('\nTest 2: Cold start (no affinity) falls back to geo-closest');
HopResolver.init([nodeA, nodeB, nodeC]);
HopResolver.setAffinity({}); // No edges
// With anchor at NodeC's position, NodeA is closer to NodeC than NodeB
const result2 = HopResolver.resolve(['cd33', 'ab'], nodeC.lat, nodeC.lon, null, null, null);
// NodeA (37, -122) is closer to NodeC (37.5, -122.5) than NodeB (38, -123)
assert(result2['ab'].name === 'NodeA', 'Should pick NodeA (geo-closest) — got: ' + result2['ab'].name);
// Test 3: setAffinity with null/undefined doesn't crash
console.log('\nTest 3: setAffinity with null/undefined is safe');
HopResolver.setAffinity(null);
HopResolver.setAffinity(undefined);
HopResolver.setAffinity({});
assert(true, 'No crash on null/undefined/empty affinity');
// Test 4: getAffinity returns correct scores
console.log('\nTest 4: getAffinity returns correct scores');
HopResolver.setAffinity({
edges: [
{ source: 'aaa', target: 'bbb', score: 0.95 },
{ source: 'ccc', target: 'ddd', weight: 5 }
]
});
assert(HopResolver.getAffinity('aaa', 'bbb') === 0.95, 'aaa→bbb = 0.95');
assert(HopResolver.getAffinity('bbb', 'aaa') === 0.95, 'bbb→aaa = 0.95 (bidirectional)');
assert(HopResolver.getAffinity('ccc', 'ddd') === 5, 'ccc→ddd = 5 (weight fallback)');
assert(HopResolver.getAffinity('aaa', 'zzz') === 0, 'unknown pair = 0');
assert(HopResolver.getAffinity(null, 'bbb') === 0, 'null pubkey = 0');
// Test 5: Affinity with multiple neighbors — highest score wins
console.log('\nTest 5: Highest affinity score wins among neighbors');
HopResolver.init([nodeA, nodeB, nodeC]);
HopResolver.setAffinity({
edges: [
{ source: 'cd3333', target: 'ab1111', score: 0.3 },
{ source: 'cd3333', target: 'ab2222', score: 0.9 }
]
});
const result5 = HopResolver.resolve(['cd33', 'ab'], nodeC.lat, nodeC.lon, null, null, null);
assert(result5['ab'].name === 'NodeB', 'Should pick NodeB (highest affinity 0.9) — got: ' + result5['ab'].name);
// Test 6: Unambiguous hops are not affected by affinity
console.log('\nTest 6: Unambiguous hops unaffected by affinity');
const nodeD = { public_key: 'ee4444', name: 'NodeD', lat: 36.0, lon: -121.0 };
HopResolver.init([nodeA, nodeB, nodeC, nodeD]);
HopResolver.setAffinity({ edges: [] });
const result6 = HopResolver.resolve(['ee44'], null, null, null, null, null);
assert(result6['ee44'].name === 'NodeD', 'Unique prefix resolves directly — got: ' + result6['ee44'].name);
assert(!result6['ee44'].ambiguous, 'Should not be marked ambiguous');
console.log('\n' + (passed + failed) + ' tests, ' + passed + ' passed, ' + failed + ' failed\n');
process.exit(failed > 0 ? 1 : 0);
+53
View File
@@ -272,6 +272,48 @@ console.log('\n=== live.js: expandToBufferEntries ===');
});
}
// ===== expandToBufferEntriesAsync (chunked, non-blocking) =====
console.log('\n=== live.js: expandToBufferEntriesAsync ===');
{
// Build a sandbox with packet-helpers loaded so expandToBufferEntries can call dbPacketToLive
const ctx = makeSandbox();
addLiveGlobals(ctx);
loadInCtx(ctx, 'public/roles.js');
loadInCtx(ctx, 'public/packet-helpers.js');
try { loadInCtx(ctx, 'public/live.js'); } catch (e) {
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
}
const expandSync = ctx.window._liveExpandToBufferEntries;
const expandAsync = ctx.window._liveExpandToBufferEntriesAsync;
assert.ok(expandAsync, '_liveExpandToBufferEntriesAsync must be exposed');
const pkts = [];
for (let i = 0; i < 500; i++) {
pkts.push({
id: i, hash: 'h' + i, timestamp: new Date(1700000000000 + i * 1000).toISOString(),
decoded_json: '{"type":"GRP_TXT"}', path_json: '[]',
observations: [
{ timestamp: new Date(1700000000000 + i * 1000 + 100).toISOString(), snr: 5, observer_name: 'O1' },
{ timestamp: new Date(1700000000000 + i * 1000 + 200).toISOString(), snr: 8, observer_name: 'O2' },
],
});
}
test('sync expand handles 500 packets (1000 entries) correctly', () => {
const result = expandSync(pkts);
assert.strictEqual(result.length, 1000, '500 packets * 2 observations = 1000 entries');
assert.strictEqual(result[0].pkt.hash, 'h0');
assert.strictEqual(result[999].pkt.hash, 'h499');
});
test('VCR_CHUNK_SIZE is defined and async function yields via setTimeout', () => {
const src = fs.readFileSync(__dirname + '/public/live.js', 'utf8');
assert.ok(src.includes('VCR_CHUNK_SIZE'), 'VCR_CHUNK_SIZE constant must exist');
assert.ok(src.includes('expandToBufferEntriesAsync'), 'async version must exist');
assert.ok(src.includes('setTimeout(processChunk, 0)'), 'must yield via setTimeout between chunks');
});
}
// ===== SEG_MAP (7-segment display) =====
console.log('\n=== live.js: SEG_MAP ===');
{
@@ -839,6 +881,17 @@ console.log('\n=== live.js: source-level safety checks ===');
assert.ok(src.includes('const existingIds = new Set(VCR.buffer.map(b => b.pkt.id)'),
'vcrRewind should dedup by packet ID');
});
test('feed items include transport badge', () => {
const count = (src.match(/transportBadge\(pkt\.route_type\)/g) || []).length;
assert.ok(count >= 3,
`feed rendering should call transportBadge(pkt.route_type) in at least 3 places (found ${count})`);
});
test('node detail recent packets include transport badge', () => {
assert.ok(src.includes('transportBadge(p.route_type)'),
'node detail recent packets should call transportBadge(p.route_type)');
});
}
// ===== SUMMARY =====
+27
View File
@@ -757,6 +757,33 @@ console.log('\n=== packets.js: page registration ===');
});
}
console.log('\n=== packets.js: _invalidateRowCounts / _refreshRowCountsIfDirty (#410) ===');
{
const ctx = loadPacketsSandbox();
const api = ctx._packetsTestAPI;
test('_invalidateRowCounts and _refreshRowCountsIfDirty are exported', () => {
assert(typeof api._invalidateRowCounts === 'function');
assert(typeof api._refreshRowCountsIfDirty === 'function');
});
test('_invalidateRowCounts does not throw', () => {
api._invalidateRowCounts();
});
test('_refreshRowCountsIfDirty does not throw when no display packets', () => {
api._invalidateRowCounts();
api._refreshRowCountsIfDirty();
});
test('_cumulativeRowOffsets returns valid offsets after invalidation cycle', () => {
// Even with no display packets, should return valid array
const offsets = api._cumulativeRowOffsets();
assert(Array.isArray(offsets));
assert(offsets[0] === 0);
});
}
// ===== SUMMARY =====
console.log(`\n${'='.repeat(40)}`);
console.log(`packets.js tests: ${passed} passed, ${failed} failed`);