Compare commits

..

30 Commits

Author SHA1 Message Date
efiten 8c82172feb fix: null-guard animLayer and liveAnimCount in nextHop after destroy
Async timers (setInterval/setTimeout) started by animateHop() can fire
after destroy() has nulled animLayer and removed DOM elements. This
caused three console errors on the Live page when navigating away mid-
animation. Guards added at each async callback site.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 01:22:08 +00:00
Kpa-clawbot 75f1295a06 fix: always refresh staging config from prod (#467)
## Summary

Fixes #466 — staging config was not refreshed from prod due to a stale
`-nt` timestamp guard.

## Root Cause

`prepare_staging_config()` only copied prod config when staging was
missing or prod was newer by mtime. However, the `sed -i` that applies
the STAGING siteName updated staging's mtime, making it appear newer
than prod. Subsequent runs skipped the copy entirely.

## Changes

- **`manage.sh`**: Removed the `-nt` timestamp conditional in
`prepare_staging_config()`. Staging config is now always copied fresh
from prod with the STAGING siteName applied.

Note: `prepare_staging_db()` already copies unconditionally — no change
needed there.

Co-authored-by: you <you@example.com>
2026-04-01 18:19:10 -07:00
Kpa-clawbot b1b76acb77 feat: manage.sh update supports pinning to release tags (#456)
## Summary

`manage.sh update` now supports pinning to specific release tags instead
of always pulling tip of master.

Fixes #455

## Changes

### `cmd_update` — accepts optional version argument
- **No argument**: fetches tags, checks out latest release tag (`git tag
-l 'v*' --sort=-v:refname | head -1`)
- **`latest`**: explicit opt-in to tip of master (bleeding edge)
- **Specific tag** (e.g. `v3.1.0`): checks out that exact tag, with
error message + available tags if not found

### `cmd_setup` — defaults to latest tag
- After Docker check, fetches tags and pins to latest release tag
- Skips if already on the latest tag
- Uses state tracking (`version_pin`) so re-runs don't repeat

### `cmd_status` — shows version
- Displays current version (exact tag name or short commit hash) at the
top of status output

### Help text
- Updated to reflect new `update [version]` syntax

## Usage

```bash
./manage.sh update          # checkout latest release tag (e.g. v3.2.0)
./manage.sh update v3.1.0   # pin to specific version
./manage.sh update latest   # explicit tip of master (bleeding edge)
./manage.sh status          # now shows "Version: v3.2.0"
```

## Testing

- `bash -n manage.sh` passes (syntax valid)
- Logic follows existing patterns (git fetch, checkout, rebuild,
restart)

---------

Co-authored-by: you <you@example.com>
2026-04-01 12:20:28 -07:00
Kpa-clawbot f87eb3601c fix: graceful container shutdown for reliable deployments (#453)
## Summary

Fixes #450 — staging deployment flaky due to container not shutting down
cleanly.

## Root Causes

1. **Server never closed DB on shutdown** — SQLite WAL lock held
indefinitely, blocking new container startup
2. **`httpServer.Close()` instead of `Shutdown()`** — abruptly kills
connections instead of draining them
3. **No `stop_grace_period` in compose configs** — Docker sends SIGTERM
then immediately SIGKILL (default 10s is often not enough for WAL
checkpoint)
4. **Supervisor didn't forward SIGTERM** — missing
`stopsignal`/`stopwaitsecs` meant Go processes got SIGKILL instead of
graceful shutdown
5. **Deploy scripts used default `docker stop` timeout** — only 10s
grace period

## Changes

### Go Server (`cmd/server/`)
- **Graceful HTTP shutdown**: `httpServer.Shutdown(ctx)` with 15s
context timeout — drains in-flight requests before closing
- **WebSocket cleanup**: New `Hub.Close()` method sends `CloseGoingAway`
frames to all connected clients
- **DB close on shutdown**: Explicitly closes DB after HTTP server stops
(was never closed before)
- **WAL checkpoint**: `PRAGMA wal_checkpoint(TRUNCATE)` before DB close
— flushes WAL to main DB file and removes WAL/SHM lock files

### Go Ingestor (`cmd/ingestor/`)
- **WAL checkpoint on shutdown**: New `Store.Checkpoint()` method,
called before `Close()`
- **Longer MQTT disconnect timeout**: 5s (was 1s) to allow in-flight
messages to drain

### Docker Compose (all 4 variants)
- Added `stop_grace_period: 30s` and `stop_signal: SIGTERM`

### Supervisor Configs (both variants)
- Added `stopsignal=TERM` and `stopwaitsecs=20` to server and ingestor
programs

### Deploy Scripts
- `deploy-staging.sh`: `docker stop -t 30` with explicit grace period
- `deploy-live.sh`: `docker stop -t 30` with explicit grace period

## Shutdown Sequence (after fix)

1. Docker sends SIGTERM to supervisord (PID 1)
2. Supervisord forwards SIGTERM to server + ingestor (waits up to 20s
each)
3. Server: stops poller → drains HTTP (15s) → closes WS clients →
checkpoints WAL → closes DB
4. Ingestor: stops tickers → disconnects MQTT (5s) → checkpoints WAL →
closes DB
5. Docker waits up to 30s total before SIGKILL

## Tests

All existing tests pass:
- `cd cmd/server && go test ./...` 
- `cd cmd/ingestor && go test ./...` 

---------

Co-authored-by: you <you@example.com>
Co-authored-by: Kpa-clawbot <kpabap+clawdbot@gmail.com>
2026-04-01 12:19:20 -07:00
Kpa-clawbot ec4dd58cb6 fix: null-guard pathHops to prevent detail pane crash (#451) (#454)
## Summary

Fixes #451 — packet detail pane crash on direct routed packets where
`pathHops` is `null`.

## Root Cause

`JSON.parse(pkt.path_json)` can return literal `null` when the DB stores
`"null"` for direct routed packets. The existing code only had a catch
block for parse errors, but `null` is valid JSON — so the parse succeeds
and `pathHops` ends up `null` instead of `[]`.

## Changes

- **`public/packets.js`**: Added `|| []` after `JSON.parse(...)` in both
`buildFlatRowHtml` (table rows) and the detail pane (`selectPacket`),
ensuring `pathHops` is always an array.
- **`test-frontend-helpers.js`**: Added 2 regression tests verifying the
null guards exist in both code paths.
- **`public/index.html`**: Cache buster bump.

## Testing

- All 229 frontend helper tests pass
- All 62 packet filter tests pass
- All 29 aging tests pass

Co-authored-by: you <you@example.com>
2026-04-01 10:48:08 -07:00
Kpa-clawbot 044a5387af perf(packets): virtual scroll + debounced WS renders for packets table (#402)
## Summary

Fixes the critical performance issue where `renderTableRows()` rebuilt
the **entire** table innerHTML (up to 50K rows) on every update —
WebSocket arrivals, filter changes, group expand/collapse, and theme
refreshes.

## Changes

### Lazy Row Generation (`renderVisibleRows`) — fixes #422
- Row HTML strings are **only generated for the visible slice + 30-row
buffer** on each render
- `_displayPackets` stores the filtered data array;
`renderVisibleRows()` calls `buildGroupRowHtml`/`buildFlatRowHtml`
lazily for ~60-90 visible entries
- Previously, `displayPackets.map(buildGroupRowHtml)` built HTML for ALL
30K+ packets on every render — the expensive work (JSON.parse, observer
lookups, template literals) ran for every packet regardless of
visibility

### Unified Row Count via `_getRowCount()` — fixes #424
- Single function `_getRowCount(p)` computes DOM row count for any entry
(1 for flat/collapsed, 1+children for expanded groups)
- Used by BOTH `_rowCounts` computation AND `renderVisibleRows` —
eliminates divergence risk between row counting and row building

### Hoisted Observer Filter Set — fixes #427
- `_observerFilterSet` created once in `renderTableRows()`, reused
across `buildGroupRowHtml`, `_getRowCount`, and child filtering
- Previously, `new Set(filters.observer.split(','))` was created inside
`buildGroupRowHtml` for every packet AND again in the row count callback

### Dynamic Colspan — fixes #426
- `_getColCount()` reads column count from the thead instead of
hardcoded `colspan="11"`
- Spacers and empty-state messages use the actual column count

### Null-Safety in `buildFlatRowHtml` — fixes #430
- `p.decoded_json || '{}'` fallback added, matching
`buildGroupRowHtml`'s existing null-safety
- Prevents TypeError on null/undefined `decoded_json` in flat
(ungrouped) mode

### Behavioral Tests — fixes #428
- Replaced 5 source-grep tests with behavioral unit tests for
`_getRowCount`:
  - Flat mode always returns 1
  - Collapsed group returns 1
  - Expanded group returns 1 + child count
  - Observer filter correctly reduces child count
  - Null `_children` handled gracefully
- Retained source-level assertions only where behavioral testing isn't
practical (e.g., verifying lazy generation pattern exists)

### Other Improvements
- Cumulative row offsets cached in `_cumulativeOffsetsCache`,
invalidated on row count changes
- Debounced WebSocket renders (200ms) coalesce rapid packet arrivals
- `destroy()` properly cleans up all virtual scroll state

## Performance Benchmarks — fixes #423

**Methodology:** Row building cost measured by counting
`buildGroupRowHtml` calls per render cycle on 30K grouped packets.

| Scenario | Before (eager) | After (lazy) | Improvement |
|----------|----------------|--------------|-------------|
| Initial render (30K packets) | 30,000 `buildGroupRowHtml` calls | ~90
calls (60 visible + 30 buffer) | **333× fewer calls** |
| Scroll event | 0 calls (pre-built) | ~90 calls (rebuild visible slice)
| Trades O(1) scroll for O(n) initial savings |
| WS packet arrival | 30,000 calls (full rebuild) | ~90 calls (debounced
+ lazy) | **333× fewer calls** |
| Filter change | 30,000 calls | ~90 calls | **333× fewer calls** |
| Memory (row HTML cache) | ~2MB string array for 30K packets | 0 (no
cache, build on demand) | **~2MB saved** |

**Per-call cost of `buildGroupRowHtml`:** Each call performs JSON.parse
of `decoded_json`, `path_json`, `observers.find()` lookup, and template
literal construction. At 30K packets, the eager approach spent
~400-500ms on row building alone (measured via `performance.now()` on
staging data). The lazy approach builds ~90 rows in ~1-2ms.

**Net effect:** `renderTableRows()` goes from O(n) string building +
O(1) DOM insertion to O(1) data assignment + O(visible) string building
+ O(visible) DOM insertion. For n=30K and visible≈60, this is ~333× less
work per render cycle.

**Trade-off:** Scrolling now rebuilds ~90 rows per RAF frame instead of
slicing pre-built strings. This costs ~1-2ms per scroll event, well
within the 16ms frame budget. The trade-off is overwhelmingly positive
since renders happen far more frequently than full-table scrolls.

## Tests

- 247 frontend helper tests pass (including 18 virtual scroll tests)
- 62 packet filter tests pass
- 29 aging tests pass
- Go backend tests pass

## Remaining Debt (tracked in issues)

- #425: Hardcoded `VSCROLL_ROW_HEIGHT=36` and `theadHeight=40` — should
be measured from DOM
- #429: 200ms WS debounce delay — value works well in practice but lacks
formal justification
- #431: No scroll position preservation on filter change or group
expand/collapse

Fixes #380

---------

Co-authored-by: you <you@example.com>
Co-authored-by: Kpa-clawbot <kpabap+clawdbot@gmail.com>
2026-04-01 09:34:04 -07:00
Kpa-clawbot 01ca843309 perf: move collision analysis to server-side endpoint (fixes #386) (#415)
## Summary

Moves the hash collision analysis from the frontend to a new server-side
endpoint, eliminating a major performance bottleneck on the analytics
collision tab.

Fixes #386

## Problem

The collision tab was:
1. **Downloading all nodes** (`/nodes?limit=2000`) — ~500KB+ of data
2. **Running O(n²) pairwise distance calculations** on the browser main
thread (~2M comparisons with 2000 nodes)
3. **Building prefix maps client-side** (`buildOneBytePrefixMap`,
`buildTwoBytePrefixInfo`, `buildCollisionHops`) iterating all nodes
multiple times

## Solution

### New endpoint: `GET /api/analytics/hash-collisions`

Returns pre-computed collision analysis with:
- `inconsistent_nodes` — nodes with varying hash sizes
- `by_size` — per-byte-size (1, 2, 3) collision data:
  - `stats` — node counts, space usage, collision counts
- `collisions` — pre-computed collisions with pairwise distances and
classifications (local/regional/distant/incomplete)
  - `one_byte_cells` — 256-cell prefix map for 1-byte matrix rendering
- `two_byte_cells` — first-byte-grouped data for 2-byte matrix rendering

### Caching

Uses the existing `cachedResult` pattern with a new `collisionCache`
map. Invalidated on `hasNewTransmissions` (same trigger as the
hash-sizes cache) and on eviction.

### Frontend changes

- `renderCollisionTab` now accepts pre-fetched `collisionData` from the
parallel API load
- New `renderHashMatrixFromServer` and `renderCollisionsFromServer`
functions consume server-computed data directly
- No more `/nodes?limit=2000` fetch from the collision tab
- Old client-side functions (`buildOneBytePrefixMap`, etc.) preserved
for test helper exports

## Test results

- `go test ./...` (server):  pass
- `go test ./...` (ingestor):  pass
- `test-packet-filter.js`:  62 passed
- `test-aging.js`:  29 passed
- `test-frontend-helpers.js`:  227 passed

## Performance impact

| Metric | Before | After |
|--------|--------|-------|
| Data transferred | ~500KB (all nodes) | ~50KB (collision data only) |
| Client computation | O(n²) distance calc | None (server-cached) |
| Main thread blocking | Yes (2000 nodes × pairwise) | No |
| Server caching | N/A | 15s TTL, invalidated on new transmissions |

---------

Co-authored-by: you <you@example.com>
Co-authored-by: Kpa-clawbot <kpabap+clawdbot@gmail.com>
2026-04-01 09:21:23 -07:00
Kpa-clawbot 5f50e80931 perf: replace server round-trip with client-side filter for My Nodes toggle (#401)
## Summary

Fixes #381 — The "My Nodes" filter in `packets.js` was making a **server
API call inside `renderTableRows()`** on every render cycle. With
WebSocket updates arriving every few seconds while the toggle was
active, this created continuous unnecessary server load.

## What Changed

**`public/packets.js`** — Replaced the `api('/packets?nodes=...')`
server call with a pure client-side filter:

```js
// Before: server round-trip on every render
const myData = await api('/packets?nodes=' + allKeys.join(',') + '&limit=500');
displayPackets = myData.packets || [];

// After: filter already-loaded packets client-side
displayPackets = displayPackets.filter(p => {
  const dj = p.decoded_json || '';
  return allKeys.some(k => dj.includes(k));
});
```

This uses the exact same matching logic as the server's
`QueryMultiNodePackets()` — a string contains check on `decoded_json`
for each pubkey — but without the network round-trip.

**`test-frontend-helpers.js`** — Added 5 unit tests for the filter
logic:
- Single and multiple pubkey matching
- No matches / empty keys edge case
- Null/empty `decoded_json` handled gracefully

**`public/index.html`** — Cache busters bumped.

## Test Results

- Frontend helpers: **232 passed, 0 failed** (including 5 new tests)
- Packet filter: **62 passed, 0 failed**
- Aging: **29 passed, 0 failed**

Co-authored-by: you <you@example.com>
2026-04-01 08:27:06 -07:00
you 8f3d12eca5 docs: perf claims require proof — benchmarks, timings, or test assertions 2026-04-01 15:08:06 +00:00
you 357f7952f7 docs: add Rule 0 — performance-first mindset in AGENTS.md 2026-04-01 14:55:35 +00:00
Kpa-clawbot 47d081c705 perf: targeted analytics cache invalidation (fixes #375) (#379)
## Problem

Every time new data is ingested (`IngestNewFromDB`,
`IngestNewObservations`, `EvictStale`), **all 6 analytics caches** are
wiped by creating new empty maps — regardless of what kind of data
actually changed. With the poller running every 1 second, this means the
15s cache TTL is effectively bypassed because caches are cleared far
more frequently than they expire.

## Fix

Introduces a `cacheInvalidation` flags struct and
`invalidateCachesFor()` method that selectively clears only the caches
affected by the ingested data:

| Flag | Caches Cleared |
|------|----------------|
| `hasNewObservations` | RF (SNR/RSSI data changed) |
| `hasNewPaths` | Topology, Distance, Subpaths |
| `hasNewTransmissions` | Hash sizes |
| `hasChannelData` | Channels (GRP_TXT payload_type 5) + channels list
cache |
| `eviction` | All (data removed, everything potentially stale) |

### Impact

For a typical ingest cycle with ADVERT/ACK/TXT_MSG packets (no GRP_TXT):
- **Before:** All 6 caches cleared every cycle
- **After:** Channel cache preserved (most common case), hash cache
preserved on observation-only ingestion

For observation-only ingestion (`IngestNewObservations`):
- **Before:** All 6 caches cleared
- **After:** Only RF cache cleared (+ topo/dist/subpath if paths
actually changed)

## Tests

7 new unit tests in `cache_invalidation_test.go` covering:
- Eviction clears all caches
- Observation-only ingest preserves non-RF caches
- Transmission-only ingest clears only hash cache
- Channel data clears only channel cache
- Path changes clear topo/dist/subpath
- Combined flags work correctly
- No flags = no invalidation

All existing tests pass.

### Post-rebase fix

Restored `channelsCacheRes` invalidation that was accidentally dropped
during the refactor. The old code cleared this separate channels list
cache on every ingest, but `invalidateCachesFor()` didn't include it.
Now cleared on `hasChannelData` and `eviction`.

Fixes #375

---------

Co-authored-by: you <you@example.com>
2026-04-01 07:37:39 -07:00
Kpa-clawbot be313f60cb fix: extract score/direction from MQTT, strip units, fix type safety issues (#371)
## Summary

Fixes #353 — addresses all 5 findings from the CoreScope code analysis.

## Changes

### Finding 1 (Major): `score` field never extracted from MQTT
- Added `Score *float64` field to `PacketData` and `MQTTPacketMessage`
structs
- Extract `msg["score"]` with `msg["Score"]` case fallback via
`toFloat64` in all three MQTT handlers (raw packet, channel message,
direct message)
- Pass through to DB observation insert instead of hardcoded `nil`

### Finding 2 (Major): `direction` field never extracted from MQTT
- Added `Direction *string` field to `PacketData` and
`MQTTPacketMessage` structs
- Extract `msg["direction"]` with `msg["Direction"]` case fallback as
string in all three MQTT handlers
- Pass through to DB observation insert instead of hardcoded `nil`

### Finding 3 (Minor): `toFloat64` doesn't strip units
- Added `stripUnitSuffix()` that removes common RF/signal unit suffixes
(dBm, dB, mW, km, mi, m) case-insensitively before `ParseFloat`
- Values like `"-110dBm"` or `"5.5dB"` now parse correctly

### Finding 4 (Minor): Bare type assertions in store.go
- Changed `firstSeen` and `lastSeen` from `interface{}` to typed
`string` variables at `store.go:5020`
- Removed unsafe `.(string)` type assertions in comparisons

### Finding 5 (Minor): `distHopRecord.SNR` typed as `interface{}`
- Changed `distHopRecord.SNR` from `interface{}` to `*float64`
- Updated assignment (removed intermediate `snrVal` variable, pass
`tx.SNR` directly)
- Updated output serialization to use `floatPtrOrNil(h.SNR)` for
consistent JSON output

## Tests Added

- `TestBuildPacketDataScoreAndDirection` — verifies Score/Direction flow
through BuildPacketData
- `TestBuildPacketDataNilScoreDirection` — verifies nil handling when
fields absent
- `TestInsertTransmissionWithScoreAndDirection` — end-to-end: inserts
with score/direction, verifies DB values
- `TestStripUnitSuffix` — covers all supported suffixes, case
insensitivity, and passthrough
- `TestToFloat64WithUnits` — verifies unit-bearing strings parse
correctly

All existing tests pass.

Co-authored-by: you <you@example.com>
2026-04-01 07:26:23 -07:00
efiten 8a0862523d fix: add migration for missing observations.timestamp index (#332)
## Problem

On installations where the database predates the
`idx_observations_timestamp` index, `/api/stats` takes 30s+ because
`GetStoreStats()` runs two full table scans:

```sql
SELECT COUNT(*) FROM observations WHERE timestamp > ?  -- last hour
SELECT COUNT(*) FROM observations WHERE timestamp > ?  -- last 24h
```

The index is only created in the `if !obsExists` block, so any database
where the `observations` table already existed before that code was
added never gets it.

## Fix

Adds a one-time migration (`obs_timestamp_index_v1`) that runs at
ingestor startup:

```sql
CREATE INDEX IF NOT EXISTS idx_observations_timestamp ON observations(timestamp)
```

On large installations this index creation may take a few seconds on
first startup after the upgrade, but subsequent stats queries become
instant.

## Test plan
- [ ] Restart ingestor on an older database and confirm `[migration]
observations timestamp index created` appears in logs
- [ ] Confirm `/api/stats` response time drops from 30s+ to <100ms

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 07:06:54 -07:00
efiten 7e8b30aa1f perf: fix slow /api/packets and /api/channels on large stores (#328)
## Problem

Two endpoints were slow on larger installations:

**`/packets?limit=50000&groupByHash=true` — 16s+**
`QueryGroupedPackets` did two expensive things on every request:
1. O(n × observations) scan per packet to find `latest` timestamp
2. Held `s.mu.RLock()` during the O(n log n) sort, blocking all
concurrent reads

**`/channels` — 13s+**
`GetChannels` iterated all payload-type-5 packets and JSON-unmarshaled
each one while holding `s.mu.RLock()`, blocking all concurrent reads for
the full duration.

## Fix

**Packets (`QueryGroupedPackets`):**
- Add `LatestSeen string` to `StoreTx`, maintained incrementally in all
three observation write paths. Eliminates the per-packet observation
scan at query time.
- Build output maps under the read lock, sort the local copy after
releasing it.
- Cache the full sorted result for 3 seconds keyed by filter params.

**Channels (`GetChannels`):**
- Copy only the fields needed (firstSeen, decodedJSON, region match)
under the read lock, then release before JSON unmarshaling.
- Cache the result for 15 seconds keyed by region param.
- Invalidate cache on new packet ingestion.

## Test plan
- [ ] Open packets page on a large store — load time should drop from
16s to <1s
- [ ] Open channels page — should load in <100ms instead of 13s+
- [ ] `[SLOW API]` warnings gone for both endpoints
- [ ] Packet/channel data is correct (hashes, counts, observer counts)
- [ ] Filters (region, type, since/until) still work correctly

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 07:06:21 -07:00
Kpa-clawbot b2279b230b fix: handle string, uint, and uint64 types in toFloat64 (#352)
## Summary

Fixes #350 — `toFloat64()` silently drops SNR/RSSI values when bridges
send strings instead of numbers.

## Problem

Some MQTT bridges serialize numeric fields (SNR, RSSI, battery_mv, etc.)
as JSON strings like `"-7.5"` instead of numbers. The existing
`toFloat64()` switch only handled `float64`, `float32`, `int`, `int64`,
and `json.Number`, so string values fell through to the default case
returning `(0, false)` — silently dropping the data.

## Changes

- **`cmd/ingestor/main.go`**: Added `string`, `uint`, and `uint64` cases
to `toFloat64()`
- `string`: uses `strconv.ParseFloat(strings.TrimSpace(n), 64)` to
handle whitespace-padded numeric strings
  - `uint` / `uint64`: straightforward numeric conversion
  - Added `strconv` import

- **`cmd/ingestor/main_test.go`**: Updated `TestToFloat64` with new
cases:
- Valid string (`"3.14"`), string with spaces (`" -7.5 "`), string
integer (`"42"`)
  - Invalid string (`"hello"`), empty string
  - `uint(10)`, `uint64(999)`

## Testing

All ingestor tests pass (`go test ./...`).

Co-authored-by: you <you@example.com>
2026-04-01 06:58:27 -07:00
Kpa-clawbot d1cb84b596 feat: Priority+ nav pattern for tablet viewports (768-1023px) (#345)
## Priority+ Navigation Pattern for Tablet Viewports

Phase 2 of responsive nav improvements for #322.

### What this does

On **tablet viewports (768-1023px)**, implements the [Priority+
navigation
pattern](https://css-tricks.com/the-priority-plus-navigation-pattern/):

- **5 high-priority tabs** shown inline: Home, Nodes, Packets, Map, Live
- **6 low-priority tabs** collapse into a "More ▾" dropdown: Channels,
Traces, Observers, Analytics, Perf, Lab
- The "More" button highlights when a low-priority page is active

**Desktop (>=1024px)** and **mobile (<768px)** behavior is unchanged.

### Changes

| File | Change |
|------|--------|
| `public/index.html` | Added `data-priority="high"` to 5 primary nav
links; added More button + dropdown menu |
| `public/style.css` | Split ≤1023px hamburger query into tablet
Priority+ (768-1023px) and mobile hamburger (<768px); added More
dropdown styles |
| `public/app.js` | Added `closeMoreMenu()`, More button toggle,
outside-click/Escape close, active state on More button |
| Cache busters | Bumped in same commit |

### Accessibility

- `aria-haspopup="true"` and `aria-expanded` on More button
- `role="menu"` / `role="menuitem"` on dropdown
- Focus moves to first item on open
- Escape key closes dropdown

### Testing

- All 308 existing tests pass (217 frontend-helpers + 62 packet-filter +
29 aging)
- No new dependencies added
- No build step changes

### Breakpoint summary

| Viewport | Behavior |
|----------|----------|
| >= 1024px | Full horizontal nav (unchanged) |
| 768-1023px | Priority+ pattern: 5 tabs + More dropdown **← NEW** |
| < 768px | Hamburger drawer with all items (unchanged) |

---------

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 23:38:54 -07:00
Kpa-clawbot 711889c823 chore: bump version to 3.2.0
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 23:36:25 -07:00
Kpa-clawbot 738d5fef39 fix: poller uses store max IDs to prevent replaying entire DB
When GetMaxTransmissionID() fails silently (e.g., corrupted DB returns 0
from COALESCE), the poller starts from ID 0 and replays the entire
database over WebSocket — broadcasting thousands of old packets per second.

Fix: after querying the DB, use the in-memory store's MaxTransmissionID
and MaxObservationID as a floor. Since Load() already read the full DB
successfully, the store has the correct max IDs.

Root cause discovered on staging: DB corruption caused MAX(id) query to
fail, returning 0. Poller log showed 'starting from transmission ID 0'
followed by 1000-2000 broadcasts per tick walking through 76K rows.

Also adds MaxObservationID() to PacketStore for observation cursor safety.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 23:28:56 -07:00
Kpa-clawbot 8e6fc9602f fix: stabilize Playwright packets test with explicit time window (#348)
## Summary

Fixes the Playwright CI regression on master where the "Packets page
loads with filter" test times out after 15 seconds waiting for able
tbody tr to appear.

## Root Cause

Three packets tests used an bout:blank round-trip pattern to force a
full page reload:

`
page.goto(BASE) → set localStorage → page.goto('about:blank') →
page.goto(BASE/#/packets)
`

This cross-origin round-trip through bout:blank causes the SPA's config
fetch and router to not fire reliably in CI's headless Chromium, leaving
the page uninitialized past the 15-second timeout.

## Fix

Replace the bout:blank pattern with page.reload() in all three affected
tests:

`
page.goto(BASE/#/packets)  →  set localStorage  →  page.reload()
`

This stays on the same origin throughout. Playwright handles same-origin
reloads predictably — the page fully re-initializes, the IIFE re-reads
localStorage, and loadPackets() uses the correct time window.

## Tests affected

| Test | Change |
|------|--------|
| Packets page loads with filter | bout:blank → page.reload() |
| Packets initial fetch honors persisted time window | bout:blank →
page.reload() |
| Packets groupByHash toggle works | bout:blank → page.reload() |

## Validation

- All 318 unit tests pass (packet-filter: 62, aging: 29, frontend: 227)
- No public/ files changed — no cache buster needed
- Single file changed: est-e2e-playwright.js (9 insertions, 15
deletions)

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 23:27:51 -07:00
Kpa-clawbot e2556eaaff fix: filter WebSocket packets by time window on packets page
WS broadcast pushes all packets regardless of the selected time
window filter. This caused old packets to appear in the table even
when the API correctly returned zero results for the time range.

Add time window check to the WS packet filter — drops packets
with timestamps older than the selected window cutoff.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 23:09:42 -07:00
Kpa-clawbot e7232c0d29 fix: rename shadowed isMobile in renderLeft to avoid TDZ crash
The outer IIFE declares const isMobile (line 27, from #340) and
renderLeft() declares its own const isMobile (line 821, pre-existing).
JavaScript hoists const declarations within the function scope, so
referencing isMobile at line 574 (inside renderLeft but before line 821)
throws 'Cannot access isMobile before initialization'.

Rename the inner declaration to isNarrow since it uses a different
breakpoint (640px for column hiding vs 1024px for packet limit).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 22:58:34 -07:00
Kpa-clawbot f7c182c5f7 fix: packets page crash on mobile — time filter default and limit cap (#340)
## Summary

Fixes #326 — the packets page crashes mobile browsers (iOS Safari, Edge)
by loading 50K+ packets when no time filter is persisted in
localStorage.

## Root Cause

Two problems in public/packets.js:

### Bug 1: savedTimeWindowMin defaults to 0 instead of 15

localStorage.getItem('meshcore-time-window') returns 
ull when never set. Number(null) = 0. The guard checked < 0 but not <=
0, so savedTimeWindowMin = 0 meant "All time" — fetching all 50K+
packets.

**Fix:** Changed < 0 to <= 0 in both the initialization guard (line 30)
and the change handler (line 758).

### Bug 2: No mobile protection against large packet loads

Even with valid large time windows, mobile browsers crash under the
weight of thousands of DOM rows and packet data (~1.4 GB WebKit memory
limit).

**Fix:**
- Detect mobile viewport: window.innerWidth <= 768
- Cap limit at 1000 on mobile (vs 50000 on desktop)
- Disable 6h/12h/24h options and hide "All time" on mobile
- Reset persisted windows >3h to 15 min on mobile

## Testing

Added 9 unit tests in 	est-frontend-helpers.js covering:
- savedTimeWindowMin defaults to 15 when localStorage returns null
- savedTimeWindowMin defaults to 15 when localStorage returns "0"
- Valid values (60) are preserved
- Negative and NaN values default to 15
- PACKET_LIMIT is 1000 on mobile, 50000 on desktop
- Mobile caps large time windows (1440 → 15) but allows 180

All 218 frontend helper tests pass. Packet filter (62) and aging (29)
tests also pass.

## Changes

| File | Change |
|------|--------|
| public/packets.js | Fix <= 0 guard, add mobile detection, cap limit,
restrict time options |
| public/index.html | Cache buster bump |
| est-frontend-helpers.js | 9 new regression tests for time window
defaults and mobile caps |

---------

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 05:39:16 +00:00
Kpa-clawbot 2d8203ae17 fix: extend responsive nav hamburger breakpoint to 1024px (#343)
## Summary

Extends the hamburger menu activation breakpoint from max-width: 640px
to max-width: 1023px, making all 11 nav items accessible on tablets and
small laptops where they were previously clipped/invisible.

Fixes #322

## Changes

### public/style.css
- New @media (max-width: 1023px) block activates the hamburger menu and
vertical drawer
- Drawer has max-height: calc(100dvh - 52px) with overflow-y: auto for
scrollability
- z-index set to 1100 (consistent with nav layer)
- ody.nav-open locks background scroll when drawer is open
- Mobile-only rules (brand-text hidden, tighter nav-right gap) remain at
640px

### public/app.js
- Extracted closeNav() helper for consistent drawer close behavior
- Hamburger toggle now adds/removes ody.nav-open class
- Drawer closes on: nav link click, Escape key, and route change (SPA
navigation)

### public/index.html
- Cache busters bumped for all CSS/JS assets

## What's NOT changed
- Desktop layout (>=1024px) is completely untouched
- No Priority+ pattern (Phase 2)
- No map layout changes (Phase 3)
- No new dependencies

## Testing
- All 308 frontend tests pass ( est-frontend-helpers.js,
est-packet-filter.js, est-aging.js)
- Visual verification: hamburger activates at <=1023px, full bar at
>=1024px

---------

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 22:34:32 -07:00
Kpa-clawbot 4cdc554b40 fix: use latest advert for node hash size instead of historical mode (#341)
## Summary

Fixes #303 — Repeater hash stats now reflect the **latest advert**
instead of the historical mode (most frequent).

When a node is reconfigured (e.g. from 1-byte to 2-byte hash size), the
analytics and node detail pages now show the updated value immediately
after the next advert is received.

## Changes

### cmd/server/store.go

1. **computeNodeHashSizeInfo** — Changed hash size determination from
statistical mode to latest advert. The most recent advert in
chronological order now determines hash_size. The hash_sizes_seen and
hash_size_inconsistent tracking is preserved for multi-byte analytics.

2. **computeAnalyticsHashSizes** — Two fixes:
- **yNode keyed by pubKey** instead of name, so same-name nodes with
different public keys are counted separately in distributionByRepeaters.
- **Zero-hop adverts included** — advert originator tracking now happens
before the hops check, so zero-hop adverts contribute to per-node stats.

### cmd/server/routes_test.go

Added 4 new tests:
- TestGetNodeHashSizeInfoLatestWins — 4 historical 1-byte adverts + 1
recent 2-byte advert → hash size should be 2 (not 1 from mode)
- TestGetNodeHashSizeInfoNoAdverts — node with no ADVERT packets →
graceful nil, no crash
- TestAnalyticsHashSizeSameNameDifferentPubkey — two nodes named
"SameName" with different pubkeys → counted as 2 separate entries
- Updated TestGetNodeHashSizeInfoDominant comment to reflect new
behavior

## Context

Community report from contributor @kizniche: after reconfiguring a
repeater from 1-byte to 2-byte hash and sending a flood advert, the
analytics page still showed 1-byte. Root cause was the mode-based
computation which required many new adverts to shift the majority. The
upstream firmware bug causing stale path bytes
(meshcore-dev/MeshCore#2154) has been fixed, making the latest advert
reliable.

## Testing

- `go vet ./...` — clean
- `go test ./... -count=1` — all tests pass (including 4 new ones)
- `cmd/ingestor` tests — pass

---------

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 22:26:32 -07:00
Kpa-clawbot 81bf3b4b12 fix: improve side pane contrast in dark mode (#334) (#342)
## Summary

Fixes the poor contrast in the node side pane's "Paths through this
node" section in dark mode.

## Root Cause

.node-detail-section (side pane) had no background or border — it
inherited the lighter --detail-bg (#232340) from .panel-right. The same
content on the full detail page sits inside .node-full-card which uses
the darker --card-bg (#1a1a2e) + a visible border, giving it proper
contrast.

| Context | Container | Background | Contrast |
|---------|-----------|------------|----------|
| Full detail page | .node-full-card | --card-bg (darker) |  Good |
| Side pane | .node-detail-section | inherited --detail-bg (lighter) | 
Poor |

## Fix

Give .node-detail-section the same card treatment as .node-full-card:

`css
.node-detail-section {
  background: var(--card-bg);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 12px;
  margin-bottom: 8px;
}
`

- All colors use CSS variables — no hardcoded hex values
- Both light and dark themes benefit from the card treatment
- No JS changes needed — CSS-only fix
- Cache busters bumped in the same commit

Fixes #334

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 22:16:20 -07:00
Kpa-clawbot ce6e8d5237 feat: show transport code (T_FLOOD) in packets view (#337)
## Summary

Surfaces transport route types in the packets view by adding a **"T"
badge** next to the payload type badge for packets with
`TRANSPORT_FLOOD` (route type 0) or `TRANSPORT_DIRECT` (route type 3)
routes.

This helps mesh analysis — communities can quickly identify transported
packets and gain insights into scope usage adoption.

Closes #241

## What Changed

### Frontend (`public/`)
- **app.js**: Added `isTransportRoute(rt)` and `transportBadge(rt)`
helper functions that render a `<span class="badge
badge-transport">T</span>` badge with the full route type name as a
tooltip
- **packets.js**: Applied `transportBadge()` in all three packet row
render paths:
  - Flat (ungrouped) packet rows
  - Grouped packet header rows
  - Grouped packet child rows
- **style.css**: Added `.badge-transport` class with amber styling and
CSS variable support (`--transport-badge-bg`, `--transport-badge-fg`)
for theme customization

### Backend (`cmd/server/`)
- **decoder_test.go**: Added 6 new tests covering:
- `TestDecodeHeader_TransportFlood` — verifies route type 0 decodes as
TRANSPORT_FLOOD
- `TestDecodeHeader_TransportDirect` — verifies route type 3 decodes as
TRANSPORT_DIRECT
- `TestDecodeHeader_Flood` — verifies route type 1 (non-transport)
decodes correctly
- `TestIsTransportRoute` — verifies the helper identifies transport vs
non-transport routes
- `TestDecodePacket_TransportFloodHasCodes` — verifies transport codes
are extracted from T_FLOOD packets
- `TestDecodePacket_FloodHasNoCodes` — verifies FLOOD packets have no
transport codes

## Visual

In the packets table Type column, transport packets now show:
```
[Channel Msg] [T]    ← transport packet
[Channel Msg]        ← normal flood packet
```

The "T" badge has an amber color scheme and shows the full route type
name on hover.

## Tests

- All Go tests pass (`cmd/server` and `cmd/ingestor`)
- All frontend tests pass (`test-packet-filter.js`, `test-aging.js`,
`test-frontend-helpers.js`)
- Cache busters bumped in `index.html`

---------

Co-authored-by: you <you@example.com>
Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 18:39:38 -07:00
Kpa-clawbot 4898541bce fix(ingestor): observer metadata nested stats + SNR/RSSI case fallback (#336)
## Problem

Two data integrity bugs in the Go ingestor cause observer metadata and
signal quality data to be missing for all Go-backend users.

### #320 — Observer metadata never populated

`extractObserverMeta()` reads `battery_mv`, `uptime_secs`, and
`noise_floor` from the **top level** of the MQTT status message.
However, the actual MQTT payload nests these under a `stats` object:

```json
{
  "status": "online",
  "origin": "ObserverName",
  "model": "Heltec V3",
  "firmware_version": "v1.14.0-9f1a3ea",
  "stats": {
    "battery_mv": 4174,
    "uptime_secs": 80277,
    "noise_floor": -110
  }
}
```

Result: battery, uptime, and noise floor are always NULL in the
database.

### #321 — SNR and RSSI always missing on raw packets

The raw packet handler reads `msg["SNR"]` and `msg["RSSI"]` (uppercase
only). Some MQTT bridges send these as lowercase `snr`/`rssi`. The
companion BLE handler already has a case-insensitive fallback — the raw
packet path did not.

Result: SNR/RSSI are NULL for all raw packet observations from bridges
that use lowercase keys.

## Fix

### #320 — Nested stats with top-level fallback

- Added `nestedOrTopLevel()` helper that checks `msg["stats"][key]`
first, then `msg[key]`
- `extractObserverMeta` now uses this helper for `battery_mv`,
`uptime_secs`, `noise_floor`
- Top-level fallback preserved for backward compatibility with bridges
that flatten the structure
- Safe type assertion: `stats, _ :=
msg["stats"].(map[string]interface{})` — no crash if stats is missing or
wrong type

### #321 — Lowercase SNR/RSSI fallback

- Raw packet handler now uses `else if` to check lowercase `snr`/`rssi`
when uppercase keys are absent
- Matches the pattern already used in the companion channel and direct
message handlers

## Tests

10 new test cases added:

| Test | What it verifies |
|------|-----------------|
| `TestExtractObserverMetaNestedStats` | All 5 fields populated from
nested stats object |
| `TestExtractObserverMetaNestedStatsPrecedence` | Nested stats wins
over top-level when both present |
| `TestExtractObserverMetaFlatFallback` | Flat structure still works
(backward compat) |
| `TestExtractObserverMetaEmptyStats` | Empty stats object — no crash,
model still works |
| `TestExtractObserverMetaStatsNotAMap` | stats is a string — no crash,
falls back to top-level |
| `TestExtractObserverMetaNoiseFloorFloat` | Float precision preserved
(noise_floor REAL migration) |
| `TestHandleMessageWithLowercaseSNRRSSI` | Lowercase snr/rssi both
stored correctly |
| `TestHandleMessageSNRRSSIUppercaseWins` | When both cases present,
uppercase takes precedence |
| `TestHandleMessageNoSNRRSSI` | Neither key present — nil, no crash |
| Existing `TestExtractObserverMeta` | Still passes (flat structure
backward compat) |

All tests pass: `go test ./... -count=1` and `go vet ./...` clean.

Closes #320
Closes #321

---------

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 17:53:04 -07:00
Kpa-clawbot 38e5f02a00 ci: add Docker image cleanup to prevent runner disk exhaustion (#333)
## Problem

The self-hosted runner (`meshcore-runner-2`) filled its 29GB disk to
100%, blocking all CI runs:

```
Filesystem  Size  Used Avail Use%
/dev/root    29G   29G  2.3M 100%

Docker Images: 67 total, 2 active, 18.83GB reclaimable (99%)
```

Root cause: no Docker image cleanup after builds. Each CI run builds a
new image but never prunes old ones.

## Fix

### 1. Docker image cleanup after deploy (`deploy` job)
- Runs with `if: always()` so it executes even if deploy fails
- `docker image prune -af --filter "until=24h"` — removes images older
than 24h (safe: current build is minutes old)
- `docker builder prune -f --keep-storage=1GB` — caps build cache
- Logs before/after `docker system df` for visibility

### 2. Runner log cleanup at start of E2E job
- Prunes runner diagnostic logs older than 3 days (was 53MB and growing)
- Reports `df -h` for disk visibility in CI output

## Impact

After manual cleanup today, disk went from 100% → 35% (19GB free). This
PR prevents recurrence.

## Test plan
- [x] Manual cleanup verified on runner via `az vm run-command`
- [ ] Next CI run should show cleanup step output in deploy job logs

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 16:00:31 -07:00
TC² dc9d6ba8df Fix directory name in README for cloning (#327) 2026-03-31 08:37:26 -07:00
efiten fe314be3a8 feat: geo_filter enforcement, DB pruning, geofilter-builder tool, HB column (#215)
## Summary

Several features and fixes from a live deployment of the Go v3.0.0
backend.

### geo_filter — full enforcement

- **Go backend config** (`cmd/server/config.go`,
`cmd/ingestor/config.go`): added `GeoFilterConfig` struct so
`geo_filter.polygon` and `bufferKm` from `config.json` are parsed by
both the server and ingestor
- **Ingestor** (`cmd/ingestor/geo_filter.go`, `cmd/ingestor/main.go`):
ADVERT packets from nodes outside the configured polygon + buffer are
dropped *before* any DB write — no transmission, node, or observation
data is stored
- **Server API** (`cmd/server/geo_filter.go`, `cmd/server/routes.go`):
`GET /api/config/geo-filter` endpoint returns the polygon + bufferKm to
the frontend; `/api/nodes` responses filter out any out-of-area nodes
already in the DB
- **Frontend** (`public/map.js`, `public/live.js`): blue polygon overlay
(solid inner + dashed buffer zone) on Map and Live pages, toggled via
"Mesh live area" checkbox, state shared via localStorage

### Automatic DB pruning

- Add `retention.packetDays` to `config.json` to delete transmissions +
observations older than N days on a daily schedule (1 min after startup,
then every 24h). Nodes and observers are never pruned.
- `POST /api/admin/prune?days=N` for manual runs (requires `X-API-Key`
header if `apiKey` is set)

```json
"retention": {
  "nodeDays": 7,
  "packetDays": 30
}
```

### tools/geofilter-builder.html

Standalone HTML tool (no server needed) — open in browser, click to
place polygon points on a Leaflet map, set `bufferKm`, copy the
generated `geo_filter` JSON block into `config.json`.

### scripts/prune-nodes-outside-geo-filter.py

Utility script to clean existing out-of-area nodes from the database
(dry-run + confirm). Useful after first enabling geo_filter on a
populated DB.

### HB column in packets table

Shows the hop hash size in bytes (1–4) decoded from the path byte of
each packet's raw hex. Displayed as **HB** between Size and Type
columns, hidden on small screens.

## Test plan

- [x] ADVERT from node outside polygon is not stored (no new row in
nodes or transmissions)
- [x] `GET /api/config/geo-filter` returns polygon + bufferKm when
configured, `{polygon: null, bufferKm: 0}` when not
- [x] `/api/nodes` excludes nodes outside polygon even if present in DB
- [x] Map and Live pages show blue polygon overlay when configured;
checkbox toggles it
- [x] `retention.packetDays: 30` deletes old transmissions/observations
on startup and daily
- [x] `POST /api/admin/prune?days=30` returns `{deleted: N, days: 30}`
- [x] `tools/geofilter-builder.html` opens standalone, draws polygon,
copies valid JSON
- [x] HB column shows 1–4 for all packets in grouped and flat view

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 01:10:56 -07:00
47 changed files with 4281 additions and 930 deletions
+18
View File
@@ -129,6 +129,13 @@ jobs:
with:
fetch-depth: 0
- name: Free disk space
run: |
# Prune old runner diagnostic logs (can accumulate 50MB+)
find ~/actions-runner/_diag/ -name '*.log' -mtime +3 -delete 2>/dev/null || true
# Show available disk space
df -h / | tail -1
- name: Set up Node.js 22
uses: actions/setup-node@v5
with:
@@ -314,6 +321,17 @@ jobs:
exit 1
fi
- name: Clean up old Docker images
if: always()
run: |
# Remove dangling images and images older than 24h (keeps current build)
echo "--- Docker disk usage before cleanup ---"
docker system df
docker image prune -af --filter "until=24h" 2>/dev/null || true
docker builder prune -f --keep-storage=1GB 2>/dev/null || true
echo "--- Docker disk usage after cleanup ---"
docker system df
# ───────────────────────────────────────────────────────────────
# 5. Publish Badges & Summary (master only)
# ───────────────────────────────────────────────────────────────
+1
View File
@@ -30,3 +30,4 @@ cmd/ingestor/ingestor.exe
# CI trigger
!test-fixtures/e2e-fixture.db
corescope-server
cmd/server/server
+27
View File
@@ -51,6 +51,33 @@ The following were part of the old Node.js backend and have been removed:
## Rules — Read These First
### 0. Performance is a feature — not an afterthought
Every change must consider performance impact BEFORE implementation. This codebase handles 30K+ packets, 2K+ nodes, and real-time WebSocket updates. A single O(n²) loop or per-item API call can freeze the UI or stall the server.
**Before writing code, ask:**
- What's the worst-case data size this code will process?
- Am I adding work inside a hot loop (render, ingest, WS broadcast)?
- Am I fetching from the server what I could compute client-side?
- Am I recomputing something that could be cached/incremental?
- Does my change invalidate caches more broadly than necessary?
**Hard rules:**
- **No per-item API calls.** Fetch bulk, filter client-side.
- **No O(n²) in hot paths.** Use Maps/Sets for lookups, not nested array scans.
- **No full DOM rebuilds.** Diff or virtualize — never innerHTML entire tables.
- **No unbounded data structures.** Every map/slice/array must have eviction or size limits.
- **No expensive work under locks.** Copy data under lock, process outside.
- **Cache expensive computations.** Invalidate surgically, not globally.
- **Debounce/coalesce rapid events.** WebSocket messages, scroll, resize — never fire raw.
**If your change touches a hot path (packet rendering, ingest, analytics), include a perf justification in the PR description:** what the complexity is, what the expected scale is, and why it won't degrade.
**Perf claims require proof.** "This is faster" without data is not acceptable. Every PR claiming to fix or improve performance MUST include one of:
- A benchmark test (before/after timings with realistic data sizes)
- Profile output or timing measurements (e.g. "renderTableRows: 450ms → 12ms on 30K packets")
- A test assertion that enforces the perf characteristic (e.g. "filters 30K packets in <50ms")
No proof = no merge.
### 1. No commit without tests
Every change that touches logic MUST have tests. For Go backend: `cd cmd/server && go test ./...` and `cd cmd/ingestor && go test ./...`. For frontend: `node test-packet-filter.js && node test-aging.js && node test-frontend-helpers.js`. If you add new logic, add tests. No exceptions.
+1
View File
@@ -9,6 +9,7 @@ ARG BUILD_TIME=unknown
# Build server
WORKDIR /build/server
COPY cmd/server/go.mod cmd/server/go.sum ./
COPY internal/geofilter/ ../../internal/geofilter/
RUN go mod download
COPY cmd/server/ ./
RUN go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME}" -o /corescope-server .
+1 -1
View File
@@ -80,7 +80,7 @@ No Go installation needed — everything builds inside the container.
```bash
git clone https://github.com/Kpa-clawbot/CoreScope.git
cd corescope
cd CoreScope
./manage.sh setup
```
+6
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"os"
"strings"
"github.com/meshcore-analyzer/geofilter"
)
// MQTTSource represents a single MQTT broker connection.
@@ -34,8 +36,12 @@ type Config struct {
ChannelKeys map[string]string `json:"channelKeys,omitempty"`
HashChannels []string `json:"hashChannels,omitempty"`
Retention *RetentionConfig `json:"retention,omitempty"`
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
}
// GeoFilterConfig is an alias for the shared geofilter.Config type.
type GeoFilterConfig = geofilter.Config
// RetentionConfig controls how long stale nodes are kept before being moved to inactive_nodes.
type RetentionConfig struct {
NodeDays int `json:"nodeDays"`
+35 -7
View File
@@ -280,6 +280,17 @@ func applySchema(db *sql.DB) error {
log.Println("[migration] node telemetry columns added")
}
// One-time migration: add timestamp index on observations for fast stats queries.
// Older databases created before this index was added suffer from full table scans
// on COUNT(*) WHERE timestamp > ?, causing /api/stats to take 30s+.
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'obs_timestamp_index_v1'")
if row.Scan(&migDone) != nil {
log.Println("[migration] Adding timestamp index on observations...")
db.Exec(`CREATE INDEX IF NOT EXISTS idx_observations_timestamp ON observations(timestamp)`)
db.Exec(`INSERT INTO _migrations (name) VALUES ('obs_timestamp_index_v1')`)
log.Println("[migration] observations timestamp index created")
}
return nil
}
@@ -434,8 +445,8 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) {
}
_, err = s.stmtInsertObservation.Exec(
txID, observerIdx, nil, // direction
data.SNR, data.RSSI, nil, // score
txID, observerIdx, data.Direction,
data.SNR, data.RSSI, data.Score,
data.PathJSON, epochTs,
)
if err != nil {
@@ -542,11 +553,22 @@ func (s *Store) UpsertObserver(id, name, iata string, meta *ObserverMeta) error
return err
}
// Close closes the database.
// Close checkpoints the WAL and closes the database.
func (s *Store) Close() error {
s.Checkpoint()
return s.db.Close()
}
// Checkpoint forces a WAL checkpoint to release the WAL lock file,
// preventing lock contention with a new process starting up.
func (s *Store) Checkpoint() {
if _, err := s.db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
log.Printf("[db] WAL checkpoint error: %v", err)
} else {
log.Println("[db] WAL checkpoint complete")
}
}
// LogStats logs current operational metrics.
func (s *Store) LogStats() {
log.Printf("[stats] tx_inserted=%d tx_dupes=%d obs_inserted=%d node_upserts=%d observer_upserts=%d write_errors=%d",
@@ -595,6 +617,8 @@ type PacketData struct {
ObserverName string
SNR *float64
RSSI *float64
Score *float64
Direction *string
Hash string
RouteType int
PayloadType int
@@ -605,10 +629,12 @@ type PacketData struct {
// MQTTPacketMessage is the JSON payload from an MQTT raw packet message.
type MQTTPacketMessage struct {
Raw string `json:"raw"`
SNR *float64 `json:"SNR"`
RSSI *float64 `json:"RSSI"`
Origin string `json:"origin"`
Raw string `json:"raw"`
SNR *float64 `json:"SNR"`
RSSI *float64 `json:"RSSI"`
Score *float64 `json:"score"`
Direction *string `json:"direction"`
Origin string `json:"origin"`
}
// BuildPacketData constructs a PacketData from a decoded packet and MQTT message.
@@ -627,6 +653,8 @@ func BuildPacketData(msg *MQTTPacketMessage, decoded *DecodedPacket, observerID,
ObserverName: msg.Origin,
SNR: msg.SNR,
RSSI: msg.RSSI,
Score: msg.Score,
Direction: msg.Direction,
Hash: ComputeContentHash(msg.Raw),
RouteType: decoded.Header.RouteType,
PayloadType: decoded.Header.PayloadType,
+340
View File
@@ -1313,3 +1313,343 @@ func TestTelemetryMigrationAddsColumns(t *testing.T) {
t.Errorf("migration node_telemetry_v1 should be recorded, count=%d", count)
}
}
// --- Bug #320: Observer metadata nested stats ---
func TestExtractObserverMetaNestedStats(t *testing.T) {
// Real-world MQTT status payload: stats fields nested under "stats"
msg := map[string]interface{}{
"status": "online",
"origin": "ObserverName",
"model": "Heltec V3",
"firmware_version": "v1.14.0-9f1a3ea",
"stats": map[string]interface{}{
"battery_mv": 4174.0,
"uptime_secs": 80277.0,
"noise_floor": -110.0,
},
}
meta := extractObserverMeta(msg)
if meta == nil {
t.Fatal("expected non-nil meta")
}
if meta.Model == nil || *meta.Model != "Heltec V3" {
t.Errorf("Model=%v, want Heltec V3", meta.Model)
}
if meta.Firmware == nil || *meta.Firmware != "v1.14.0-9f1a3ea" {
t.Errorf("Firmware=%v, want v1.14.0-9f1a3ea", meta.Firmware)
}
if meta.BatteryMv == nil || *meta.BatteryMv != 4174 {
t.Errorf("BatteryMv=%v, want 4174", meta.BatteryMv)
}
if meta.UptimeSecs == nil || *meta.UptimeSecs != 80277 {
t.Errorf("UptimeSecs=%v, want 80277", meta.UptimeSecs)
}
if meta.NoiseFloor == nil || *meta.NoiseFloor != -110.0 {
t.Errorf("NoiseFloor=%v, want -110", meta.NoiseFloor)
}
}
func TestExtractObserverMetaNestedStatsPrecedence(t *testing.T) {
// If stats has a value AND top-level has a value, nested wins
msg := map[string]interface{}{
"battery_mv": 9999.0, // top-level (stale/wrong)
"noise_floor": -120.0, // top-level (stale/wrong)
"stats": map[string]interface{}{
"battery_mv": 4174.0, // nested (correct)
"noise_floor": -110.5, // nested (correct)
},
}
meta := extractObserverMeta(msg)
if meta == nil {
t.Fatal("expected non-nil meta")
}
if meta.BatteryMv == nil || *meta.BatteryMv != 4174 {
t.Errorf("BatteryMv=%v, want 4174 (nested should win over top-level)", meta.BatteryMv)
}
if meta.NoiseFloor == nil || *meta.NoiseFloor != -110.5 {
t.Errorf("NoiseFloor=%v, want -110.5 (nested should win over top-level)", meta.NoiseFloor)
}
}
func TestExtractObserverMetaFlatFallback(t *testing.T) {
// Backward compatibility: flat structure (no stats object) still works
msg := map[string]interface{}{
"battery_mv": 3500.0,
"uptime_secs": 86400.0,
"noise_floor": -115.5,
}
meta := extractObserverMeta(msg)
if meta == nil {
t.Fatal("expected non-nil meta for flat structure")
}
if meta.BatteryMv == nil || *meta.BatteryMv != 3500 {
t.Errorf("BatteryMv=%v, want 3500", meta.BatteryMv)
}
if meta.UptimeSecs == nil || *meta.UptimeSecs != 86400 {
t.Errorf("UptimeSecs=%v, want 86400", meta.UptimeSecs)
}
if meta.NoiseFloor == nil || *meta.NoiseFloor != -115.5 {
t.Errorf("NoiseFloor=%v, want -115.5", meta.NoiseFloor)
}
}
func TestExtractObserverMetaEmptyStats(t *testing.T) {
// Empty stats object should not crash, top-level fallback still applies
msg := map[string]interface{}{
"model": "T-Beam",
"stats": map[string]interface{}{},
}
meta := extractObserverMeta(msg)
if meta == nil {
t.Fatal("expected non-nil meta (model is present)")
}
if meta.Model == nil || *meta.Model != "T-Beam" {
t.Errorf("Model=%v, want T-Beam", meta.Model)
}
if meta.BatteryMv != nil {
t.Errorf("BatteryMv should be nil, got %v", *meta.BatteryMv)
}
}
func TestExtractObserverMetaStatsNotAMap(t *testing.T) {
// stats field is not a map (e.g., string) — should not crash, fall back to top-level
msg := map[string]interface{}{
"stats": "invalid",
"battery_mv": 3700.0,
}
meta := extractObserverMeta(msg)
if meta == nil {
t.Fatal("expected non-nil meta")
}
if meta.BatteryMv == nil || *meta.BatteryMv != 3700 {
t.Errorf("BatteryMv=%v, want 3700 (top-level fallback when stats is not a map)", meta.BatteryMv)
}
}
func TestExtractObserverMetaNoiseFloorFloat(t *testing.T) {
// noise_floor migrated to REAL — verify float precision preserved
msg := map[string]interface{}{
"stats": map[string]interface{}{
"noise_floor": -108.75,
},
}
meta := extractObserverMeta(msg)
if meta == nil {
t.Fatal("expected non-nil meta")
}
if meta.NoiseFloor == nil || *meta.NoiseFloor != -108.75 {
t.Errorf("NoiseFloor=%v, want -108.75", meta.NoiseFloor)
}
}
func TestExtractObserverMetaNestedNilSkipsTopLevel(t *testing.T) {
// JSON {"stats": {"battery_mv": null}} decodes to nil value in the map.
// Nested nil should suppress top-level fallback (nested wins semantics).
msg := map[string]interface{}{
"battery_mv": 3700.0,
"stats": map[string]interface{}{
"battery_mv": nil,
},
}
meta := extractObserverMeta(msg)
if meta != nil && meta.BatteryMv != nil {
t.Error("nested nil should suppress top-level fallback")
}
}
func TestObsTimestampIndexMigration(t *testing.T) {
// Case 1: new DB — OpenStore should create idx_observations_timestamp as part
// of the observations table schema.
t.Run("NewDB", func(t *testing.T) {
s, err := OpenStore(tempDBPath(t))
if err != nil {
t.Fatal(err)
}
defer s.Close()
var count int
err = s.db.QueryRow(
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_observations_timestamp'",
).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 1 {
t.Error("idx_observations_timestamp should exist on a new DB")
}
var migCount int
err = s.db.QueryRow(
"SELECT COUNT(*) FROM _migrations WHERE name='obs_timestamp_index_v1'",
).Scan(&migCount)
if err != nil {
t.Fatal(err)
}
// On a new DB the index is created inline (not via migration), so the
// migration row may or may not be recorded — just verify the index exists.
_ = migCount
})
// Case 2: existing DB that has the observations table but lacks the index
// and lacks the _migrations entry — simulates an older installation.
t.Run("MigrationPath", func(t *testing.T) {
path := tempDBPath(t)
// Build a bare-bones DB that mimics an old installation:
// observations table exists but idx_observations_timestamp does NOT.
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY);
CREATE TABLE IF NOT EXISTS transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_hex TEXT NOT NULL,
hash TEXT NOT NULL UNIQUE,
first_seen TEXT NOT NULL,
route_type INTEGER,
payload_type INTEGER,
payload_version INTEGER,
decoded_json TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
observer_idx INTEGER,
direction TEXT,
snr REAL,
rssi REAL,
score INTEGER,
path_json TEXT,
timestamp INTEGER NOT NULL
);
`)
if err != nil {
db.Close()
t.Fatal(err)
}
// Confirm the index is absent before OpenStore runs.
var preCount int
db.QueryRow(
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_observations_timestamp'",
).Scan(&preCount)
db.Close()
if preCount != 0 {
t.Fatalf("pre-condition failed: idx_observations_timestamp should not exist yet, got count=%d", preCount)
}
// Now open via OpenStore — the migration should add the index.
s, err := OpenStore(path)
if err != nil {
t.Fatal(err)
}
defer s.Close()
var idxCount int
err = s.db.QueryRow(
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_observations_timestamp'",
).Scan(&idxCount)
if err != nil {
t.Fatal(err)
}
if idxCount != 1 {
t.Error("idx_observations_timestamp should exist after migration on old DB")
}
var migCount int
err = s.db.QueryRow(
"SELECT COUNT(*) FROM _migrations WHERE name='obs_timestamp_index_v1'",
).Scan(&migCount)
if err != nil {
t.Fatal(err)
}
if migCount != 1 {
t.Errorf("migration obs_timestamp_index_v1 should be recorded, got count=%d", migCount)
}
})
}
func TestBuildPacketDataScoreAndDirection(t *testing.T) {
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
decoded, err := DecodePacket(rawHex, nil)
if err != nil {
t.Fatal(err)
}
score := 42.0
dir := "incoming"
msg := &MQTTPacketMessage{
Raw: rawHex,
Score: &score,
Direction: &dir,
}
pkt := BuildPacketData(msg, decoded, "obs1", "SJC")
if pkt.Score == nil || *pkt.Score != 42.0 {
t.Errorf("Score=%v, want 42.0", pkt.Score)
}
if pkt.Direction == nil || *pkt.Direction != "incoming" {
t.Errorf("Direction=%v, want incoming", pkt.Direction)
}
}
func TestBuildPacketDataNilScoreDirection(t *testing.T) {
decoded, _ := DecodePacket("0A00"+strings.Repeat("00", 10), nil)
msg := &MQTTPacketMessage{Raw: "0A00" + strings.Repeat("00", 10)}
pkt := BuildPacketData(msg, decoded, "", "")
if pkt.Score != nil {
t.Errorf("Score should be nil, got %v", *pkt.Score)
}
if pkt.Direction != nil {
t.Errorf("Direction should be nil, got %v", *pkt.Direction)
}
}
func TestInsertTransmissionWithScoreAndDirection(t *testing.T) {
s, err := OpenStore(tempDBPath(t))
if err != nil {
t.Fatal(err)
}
defer s.Close()
score := 7.5
dir := "outgoing"
data := &PacketData{
RawHex: "AABB",
Timestamp: "2025-01-01T00:00:00Z",
SNR: ptrFloat(5.0),
RSSI: ptrFloat(-90.0),
Score: &score,
Direction: &dir,
Hash: "abc123",
PathJSON: "[]",
}
isNew, err := s.InsertTransmission(data)
if err != nil {
t.Fatal(err)
}
if !isNew {
t.Error("expected new transmission")
}
// Verify the observation was stored with score and direction
var gotDir sql.NullString
var gotScore sql.NullFloat64
err = s.db.QueryRow("SELECT direction, score FROM observations LIMIT 1").Scan(&gotDir, &gotScore)
if err != nil {
t.Fatal(err)
}
if !gotDir.Valid || gotDir.String != "outgoing" {
t.Errorf("direction=%v, want outgoing", gotDir)
}
if !gotScore.Valid || gotScore.Float64 != 7.5 {
t.Errorf("score=%v, want 7.5", gotScore)
}
}
func ptrFloat(f float64) *float64 { return &f }
+15
View File
@@ -0,0 +1,15 @@
package main
import "github.com/meshcore-analyzer/geofilter"
// NodePassesGeoFilter returns true if the node should be kept.
// Nodes with no GPS coordinates are always allowed.
func NodePassesGeoFilter(lat, lon *float64, gf *GeoFilterConfig) bool {
if gf == nil {
return true
}
if lat == nil || lon == nil {
return true
}
return geofilter.PassesFilter(*lat, *lon, gf)
}
+3
View File
@@ -4,9 +4,12 @@ go 1.22
require (
github.com/eclipse/paho.mqtt.golang v1.5.0
github.com/meshcore-analyzer/geofilter v0.0.0
modernc.org/sqlite v1.34.5
)
replace github.com/meshcore-analyzer/geofilter => ../../internal/geofilter
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
+140 -32
View File
@@ -14,6 +14,7 @@ import (
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
@@ -136,7 +137,7 @@ func main() {
// Capture source for closure
src := source
opts.SetDefaultPublishHandler(func(c mqtt.Client, m mqtt.Message) {
handleMessage(store, tag, src, m, channelKeys)
handleMessage(store, tag, src, m, channelKeys, cfg.GeoFilter)
})
client := mqtt.NewClient(opts)
@@ -165,12 +166,12 @@ func main() {
statsTicker.Stop()
store.LogStats() // final stats on shutdown
for _, c := range clients {
c.Disconnect(1000)
c.Disconnect(5000) // 5s to allow in-flight messages to drain
}
log.Println("Done.")
}
func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message, channelKeys map[string]string) {
func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message, channelKeys map[string]string, geoFilter *GeoFilterConfig) {
defer func() {
if r := recover(); r != nil {
log.Printf("MQTT [%s] panic in handler: %v", tag, r)
@@ -241,43 +242,75 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
if f, ok := toFloat64(v); ok {
mqttMsg.SNR = &f
}
} else if v, ok := msg["snr"]; ok {
if f, ok := toFloat64(v); ok {
mqttMsg.SNR = &f
}
}
if v, ok := msg["RSSI"]; ok {
if f, ok := toFloat64(v); ok {
mqttMsg.RSSI = &f
}
} else if v, ok := msg["rssi"]; ok {
if f, ok := toFloat64(v); ok {
mqttMsg.RSSI = &f
}
}
if v, ok := msg["score"]; ok {
if f, ok := toFloat64(v); ok {
mqttMsg.Score = &f
}
} else if v, ok := msg["Score"]; ok {
if f, ok := toFloat64(v); ok {
mqttMsg.Score = &f
}
}
if v, ok := msg["direction"].(string); ok {
mqttMsg.Direction = &v
} else if v, ok := msg["Direction"].(string); ok {
mqttMsg.Direction = &v
}
if v, ok := msg["origin"].(string); ok {
mqttMsg.Origin = v
}
pktData := BuildPacketData(mqttMsg, decoded, observerID, region)
isNew, err := store.InsertTransmission(pktData)
if err != nil {
log.Printf("MQTT [%s] db insert error: %v", tag, err)
}
// Process ADVERT → upsert node
// For ADVERT packets with known coordinates, enforce geo_filter before
// storing anything — drop the entire message if outside the area.
if decoded.Header.PayloadTypeName == "ADVERT" && decoded.Payload.PubKey != "" {
ok, reason := ValidateAdvert(&decoded.Payload)
if ok {
role := advertRole(decoded.Payload.Flags)
if err := store.UpsertNode(decoded.Payload.PubKey, decoded.Payload.Name, role, decoded.Payload.Lat, decoded.Payload.Lon, pktData.Timestamp); err != nil {
log.Printf("MQTT [%s] node upsert error: %v", tag, err)
}
if isNew {
if err := store.IncrementAdvertCount(decoded.Payload.PubKey); err != nil {
log.Printf("MQTT [%s] advert count error: %v", tag, err)
}
}
// Update telemetry if present in advert
if decoded.Payload.BatteryMv != nil || decoded.Payload.TemperatureC != nil {
if err := store.UpdateNodeTelemetry(decoded.Payload.PubKey, decoded.Payload.BatteryMv, decoded.Payload.TemperatureC); err != nil {
log.Printf("MQTT [%s] node telemetry update error: %v", tag, err)
}
}
} else {
if !ok {
log.Printf("MQTT [%s] skipping corrupted ADVERT: %s", tag, reason)
return
}
if !NodePassesGeoFilter(decoded.Payload.Lat, decoded.Payload.Lon, geoFilter) {
return
}
pktData := BuildPacketData(mqttMsg, decoded, observerID, region)
isNew, err := store.InsertTransmission(pktData)
if err != nil {
log.Printf("MQTT [%s] db insert error: %v", tag, err)
}
role := advertRole(decoded.Payload.Flags)
if err := store.UpsertNode(decoded.Payload.PubKey, decoded.Payload.Name, role, decoded.Payload.Lat, decoded.Payload.Lon, pktData.Timestamp); err != nil {
log.Printf("MQTT [%s] node upsert error: %v", tag, err)
}
if isNew {
if err := store.IncrementAdvertCount(decoded.Payload.PubKey); err != nil {
log.Printf("MQTT [%s] advert count error: %v", tag, err)
}
}
// Update telemetry if present in advert
if decoded.Payload.BatteryMv != nil || decoded.Payload.TemperatureC != nil {
if err := store.UpdateNodeTelemetry(decoded.Payload.PubKey, decoded.Payload.BatteryMv, decoded.Payload.TemperatureC); err != nil {
log.Printf("MQTT [%s] node telemetry update error: %v", tag, err)
}
}
} else {
// Non-ADVERT packets: store normally (routing/channel messages from
// in-area observers are relevant regardless of relay hop origin).
pktData := BuildPacketData(mqttMsg, decoded, observerID, region)
if _, err := store.InsertTransmission(pktData); err != nil {
log.Printf("MQTT [%s] db insert error: %v", tag, err)
}
}
@@ -333,7 +366,8 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
h := sha256.Sum256([]byte(hashInput))
hash := hex.EncodeToString(h[:])[:16]
var snr, rssi *float64
var snr, rssi, score *float64
var direction *string
if v, ok := msg["SNR"]; ok {
if f, ok := toFloat64(v); ok {
snr = &f
@@ -352,6 +386,20 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
rssi = &f
}
}
if v, ok := msg["score"]; ok {
if f, ok := toFloat64(v); ok {
score = &f
}
} else if v, ok := msg["Score"]; ok {
if f, ok := toFloat64(v); ok {
score = &f
}
}
if v, ok := msg["direction"].(string); ok {
direction = &v
} else if v, ok := msg["Direction"].(string); ok {
direction = &v
}
pktData := &PacketData{
Timestamp: now,
@@ -359,6 +407,8 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
ObserverName: "L1 Pro (BLE)",
SNR: snr,
RSSI: rssi,
Score: score,
Direction: direction,
Hash: hash,
RouteType: 1, // FLOOD
PayloadType: 5, // GRP_TXT
@@ -410,7 +460,8 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
h := sha256.Sum256([]byte(hashInput))
hash := hex.EncodeToString(h[:])[:16]
var snr, rssi *float64
var snr, rssi, score *float64
var direction *string
if v, ok := msg["SNR"]; ok {
if f, ok := toFloat64(v); ok {
snr = &f
@@ -429,6 +480,20 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
rssi = &f
}
}
if v, ok := msg["score"]; ok {
if f, ok := toFloat64(v); ok {
score = &f
}
} else if v, ok := msg["Score"]; ok {
if f, ok := toFloat64(v); ok {
score = &f
}
}
if v, ok := msg["direction"].(string); ok {
direction = &v
} else if v, ok := msg["Direction"].(string); ok {
direction = &v
}
pktData := &PacketData{
Timestamp: now,
@@ -436,6 +501,8 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
ObserverName: "L1 Pro (BLE)",
SNR: snr,
RSSI: rssi,
Score: score,
Direction: direction,
Hash: hash,
RouteType: 1, // FLOOD
PayloadType: 2, // TXT_MSG
@@ -465,11 +532,35 @@ func toFloat64(v interface{}) (float64, bool) {
case json.Number:
f, err := n.Float64()
return f, err == nil
case string:
s := strings.TrimSpace(n)
s = stripUnitSuffix(s)
f, err := strconv.ParseFloat(s, 64)
return f, err == nil
case uint:
return float64(n), true
case uint64:
return float64(n), true
default:
return 0, false
}
}
// unitSuffixes lists common RF/signal unit suffixes to strip before parsing.
var unitSuffixes = []string{"dBm", "dB", "mW", "km", "mi", "m"}
// stripUnitSuffix removes a trailing unit suffix (case-insensitive) from a
// numeric string so that values like "-110dBm" can be parsed as float64.
func stripUnitSuffix(s string) string {
lower := strings.ToLower(s)
for _, suffix := range unitSuffixes {
if strings.HasSuffix(lower, strings.ToLower(suffix)) {
return strings.TrimSpace(s[:len(s)-len(suffix)])
}
}
return s
}
// extractObserverMeta extracts hardware metadata from an MQTT status message.
// Casts battery_mv and uptime_secs to integers (they're always whole numbers).
func extractObserverMeta(msg map[string]interface{}) *ObserverMeta {
@@ -501,21 +592,25 @@ func extractObserverMeta(msg map[string]interface{}) *ObserverMeta {
hasData = true
}
if v, ok := msg["battery_mv"]; ok {
// Stats fields may be nested under a "stats" object or at top level.
// Try nested first, fall back to top-level for backward compatibility.
stats, _ := msg["stats"].(map[string]interface{})
if v := nestedOrTopLevel(stats, msg, "battery_mv"); v != nil {
if f, ok := toFloat64(v); ok {
iv := int(math.Round(f))
meta.BatteryMv = &iv
hasData = true
}
}
if v, ok := msg["uptime_secs"]; ok {
if v := nestedOrTopLevel(stats, msg, "uptime_secs"); v != nil {
if f, ok := toFloat64(v); ok {
iv := int64(math.Round(f))
meta.UptimeSecs = &iv
hasData = true
}
}
if v, ok := msg["noise_floor"]; ok {
if v := nestedOrTopLevel(stats, msg, "noise_floor"); v != nil {
if f, ok := toFloat64(v); ok {
meta.NoiseFloor = &f
hasData = true
@@ -528,6 +623,19 @@ func extractObserverMeta(msg map[string]interface{}) *ObserverMeta {
return meta
}
// nestedOrTopLevel looks up a key in the nested map first, then the top-level map.
func nestedOrTopLevel(nested, toplevel map[string]interface{}, key string) interface{} {
if nested != nil {
if v, ok := nested[key]; ok {
return v
}
}
if v, ok := toplevel[key]; ok {
return v
}
return nil
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
+139 -56
View File
@@ -22,7 +22,13 @@ func TestToFloat64(t *testing.T) {
{"int64", int64(100), 100.0, true},
{"json.Number valid", json.Number("9.5"), 9.5, true},
{"json.Number invalid", json.Number("not_a_number"), 0, false},
{"string unsupported", "hello", 0, false},
{"string valid", "3.14", 3.14, true},
{"string with spaces", " -7.5 ", -7.5, true},
{"string integer", "42", 42.0, true},
{"string invalid", "hello", 0, false},
{"string empty", "", 0, false},
{"uint", uint(10), 10.0, true},
{"uint64", uint64(999), 999.0, true},
{"bool unsupported", true, 0, false},
{"nil unsupported", nil, 0, false},
{"slice unsupported", []int{1}, 0, false},
@@ -124,7 +130,7 @@ func TestHandleMessageRawPacket(t *testing.T) {
payload := []byte(`{"raw":"` + rawHex + `","SNR":5.5,"RSSI":-100.0,"origin":"myobs"}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -141,7 +147,7 @@ func TestHandleMessageRawPacketAdvert(t *testing.T) {
payload := []byte(`{"raw":"` + rawHex + `"}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
// Should create a node from the ADVERT
var count int
@@ -163,7 +169,7 @@ func TestHandleMessageInvalidJSON(t *testing.T) {
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: []byte(`not json`)}
// Should not panic
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -177,13 +183,13 @@ func TestHandleMessageStatusTopic(t *testing.T) {
source := MQTTSource{Name: "test"}
msg := &mockMessage{
topic: "meshcore/SJC/obs1/status",
payload: []byte(`{"origin":"MyObserver","model":"L1","firmware_version":"v1.2.3","client_version":"2.4.1","radio":"SX1262"}`),
payload: []byte(`{"origin":"MyObserver"}`),
}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var name, iata, model, firmware, clientVersion, radio string
err := store.db.QueryRow("SELECT name, iata, model, firmware, client_version, radio FROM observers WHERE id = 'obs1'").Scan(&name, &iata, &model, &firmware, &clientVersion, &radio)
var name, iata string
err := store.db.QueryRow("SELECT name, iata FROM observers WHERE id = 'obs1'").Scan(&name, &iata)
if err != nil {
t.Fatal(err)
}
@@ -193,39 +199,6 @@ func TestHandleMessageStatusTopic(t *testing.T) {
if iata != "SJC" {
t.Errorf("iata=%s, want SJC", iata)
}
if model != "L1" {
t.Errorf("model=%s, want L1", model)
}
if firmware != "v1.2.3" {
t.Errorf("firmware=%s, want v1.2.3", firmware)
}
if clientVersion != "2.4.1" {
t.Errorf("client_version=%s, want 2.4.1", clientVersion)
}
if radio != "SX1262" {
t.Errorf("radio=%s, want SX1262", radio)
}
}
func TestHandleMessageStatusTopicMissingIdentityFields(t *testing.T) {
store := newTestStore(t)
source := MQTTSource{Name: "test"}
msg := &mockMessage{
topic: "meshcore/SJC/obs1/status",
payload: []byte(`{"origin":"MyObserver","battery_mv":3500}`),
}
handleMessage(store, "test", source, msg, nil)
var model, firmware, clientVersion, radio interface{}
err := store.db.QueryRow("SELECT model, firmware, client_version, radio FROM observers WHERE id = 'obs1'").
Scan(&model, &firmware, &clientVersion, &radio)
if err != nil {
t.Fatal(err)
}
if model != nil || firmware != nil || clientVersion != nil || radio != nil {
t.Errorf("identity fields should remain NULL when absent: model=%v firmware=%v client_version=%v radio=%v", model, firmware, clientVersion, radio)
}
}
func TestHandleMessageSkipStatusTopics(t *testing.T) {
@@ -234,11 +207,11 @@ func TestHandleMessageSkipStatusTopics(t *testing.T) {
// meshcore/status should be skipped
msg1 := &mockMessage{topic: "meshcore/status", payload: []byte(`{"raw":"0A00"}`)}
handleMessage(store, "test", source, msg1, nil)
handleMessage(store, "test", source, msg1, nil, nil)
// meshcore/events/connection should be skipped
msg2 := &mockMessage{topic: "meshcore/events/connection", payload: []byte(`{"raw":"0A00"}`)}
handleMessage(store, "test", source, msg2, nil)
handleMessage(store, "test", source, msg2, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -257,7 +230,7 @@ func TestHandleMessageIATAFilter(t *testing.T) {
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -270,7 +243,7 @@ func TestHandleMessageIATAFilter(t *testing.T) {
topic: "meshcore/LAX/obs2/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg2, nil)
handleMessage(store, "test", source, msg2, nil, nil)
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
if count != 1 {
@@ -288,7 +261,7 @@ func TestHandleMessageIATAFilterNoRegion(t *testing.T) {
topic: "meshcore",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
// No region part → filter doesn't apply, message goes through
// Actually the code checks len(parts) > 1 for IATA filter
@@ -304,7 +277,7 @@ func TestHandleMessageNoRawHex(t *testing.T) {
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"type":"companion","data":"something"}`),
}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -322,7 +295,7 @@ func TestHandleMessageBadRawHex(t *testing.T) {
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"ZZZZ"}`),
}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -339,7 +312,7 @@ func TestHandleMessageWithSNRRSSIAsNumbers(t *testing.T) {
payload := []byte(`{"raw":"` + rawHex + `","SNR":7.2,"RSSI":-95}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var snr, rssi *float64
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
@@ -358,7 +331,7 @@ func TestHandleMessageMinimalTopic(t *testing.T) {
topic: "meshcore/SJC",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -379,7 +352,7 @@ func TestHandleMessageCorruptedAdvert(t *testing.T) {
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
// Transmission should be inserted (even if advert is invalid)
var count int
@@ -405,7 +378,7 @@ func TestHandleMessageNoObserverID(t *testing.T) {
topic: "packets",
payload: []byte(`{"raw":"` + rawHex + `","origin":"obs1"}`),
}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -427,7 +400,7 @@ func TestHandleMessageSNRNotFloat(t *testing.T) {
// SNR as a string value — should not parse as float
payload := []byte(`{"raw":"` + rawHex + `","SNR":"bad","RSSI":"bad"}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -443,7 +416,7 @@ func TestHandleMessageOriginExtraction(t *testing.T) {
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
payload := []byte(`{"raw":"` + rawHex + `","origin":"MyOrigin"}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
// Verify origin was extracted to observer name
var name string
@@ -466,7 +439,7 @@ func TestHandleMessagePanicRecovery(t *testing.T) {
}
// Should not panic — the defer/recover should catch it
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
}
func TestHandleMessageStatusOriginFallback(t *testing.T) {
@@ -478,7 +451,7 @@ func TestHandleMessageStatusOriginFallback(t *testing.T) {
topic: "meshcore/SJC/obs1/status",
payload: []byte(`{"type":"status"}`),
}
handleMessage(store, "test", source, msg, nil)
handleMessage(store, "test", source, msg, nil, nil)
var name string
err := store.db.QueryRow("SELECT name FROM observers WHERE id = 'obs1'").Scan(&name)
@@ -656,3 +629,113 @@ func TestLoadChannelKeysSkipExplicit(t *testing.T) {
t.Errorf("#General = %q, want my_explicit_key", keys["#General"])
}
}
// --- Bug #321: SNR/RSSI case-insensitive fallback ---
func TestHandleMessageWithLowercaseSNRRSSI(t *testing.T) {
store := newTestStore(t)
source := MQTTSource{Name: "test"}
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
payload := []byte(`{"raw":"` + rawHex + `","snr":5.5,"rssi":-102}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil, nil)
var snr, rssi *float64
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
if snr == nil || *snr != 5.5 {
t.Errorf("snr=%v, want 5.5 (lowercase key)", snr)
}
if rssi == nil || *rssi != -102 {
t.Errorf("rssi=%v, want -102 (lowercase key)", rssi)
}
}
func TestHandleMessageSNRRSSIUppercaseWins(t *testing.T) {
store := newTestStore(t)
source := MQTTSource{Name: "test"}
// Both uppercase and lowercase present — uppercase should take precedence
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
payload := []byte(`{"raw":"` + rawHex + `","SNR":7.2,"snr":1.0,"RSSI":-95,"rssi":-50}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil, nil)
var snr, rssi *float64
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
if snr == nil || *snr != 7.2 {
t.Errorf("snr=%v, want 7.2 (uppercase should take precedence)", snr)
}
if rssi == nil || *rssi != -95 {
t.Errorf("rssi=%v, want -95 (uppercase should take precedence)", rssi)
}
}
func TestHandleMessageNoSNRRSSI(t *testing.T) {
store := newTestStore(t)
source := MQTTSource{Name: "test"}
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
payload := []byte(`{"raw":"` + rawHex + `"}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil, nil)
var snr, rssi *float64
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
if snr != nil {
t.Errorf("snr should be nil when not present, got %v", *snr)
}
if rssi != nil {
t.Errorf("rssi should be nil when not present, got %v", *rssi)
}
}
func TestStripUnitSuffix(t *testing.T) {
tests := []struct {
input, want string
}{
{"-110dBm", "-110"},
{"-110DBM", "-110"},
{"5.5dB", "5.5"},
{"100mW", "100"},
{"1.5km", "1.5"},
{"500m", "500"},
{"10mi", "10"},
{"42", "42"},
{"", ""},
{"hello", "hello"},
}
for _, tt := range tests {
got := stripUnitSuffix(tt.input)
if got != tt.want {
t.Errorf("stripUnitSuffix(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestToFloat64WithUnits(t *testing.T) {
tests := []struct {
input interface{}
want float64
ok bool
}{
{"-110dBm", -110.0, true},
{"5.5dB", 5.5, true},
{"100mW", 100.0, true},
{"-85.3dBm", -85.3, true},
{"42", 42.0, true},
{"not_a_number", 0, false},
}
for _, tt := range tests {
got, ok := toFloat64(tt.input)
if ok != tt.ok {
t.Errorf("toFloat64(%v) ok=%v, want %v", tt.input, ok, tt.ok)
}
if ok && got != tt.want {
t.Errorf("toFloat64(%v) = %v, want %v", tt.input, got, tt.want)
}
}
}
+171
View File
@@ -0,0 +1,171 @@
package main
import (
"testing"
"time"
)
// newTestStore creates a minimal PacketStore for cache invalidation testing.
func newTestStore(t *testing.T) *PacketStore {
t.Helper()
return &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,
}
}
// populateAllCaches fills every analytics cache with a dummy entry so tests
// can verify which caches are cleared and which are preserved.
func populateAllCaches(s *PacketStore) {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
dummy := &cachedResult{data: map[string]interface{}{"test": true}, expiresAt: time.Now().Add(time.Hour)}
s.rfCache["global"] = dummy
s.topoCache["global"] = dummy
s.hashCache["global"] = dummy
s.chanCache["global"] = dummy
s.distCache["global"] = dummy
s.subpathCache["global"] = dummy
}
// cachePopulated returns which caches still have their "global" entry.
func cachePopulated(s *PacketStore) map[string]bool {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
return map[string]bool{
"rf": len(s.rfCache) > 0,
"topo": len(s.topoCache) > 0,
"hash": len(s.hashCache) > 0,
"chan": len(s.chanCache) > 0,
"dist": len(s.distCache) > 0,
"subpath": len(s.subpathCache) > 0,
}
}
func TestInvalidateCachesFor_Eviction(t *testing.T) {
s := newTestStore(t)
populateAllCaches(s)
s.invalidateCachesFor(cacheInvalidation{eviction: true})
pop := cachePopulated(s)
for name, has := range pop {
if has {
t.Errorf("eviction should clear %s cache", name)
}
}
}
func TestInvalidateCachesFor_NewObservationsOnly(t *testing.T) {
s := newTestStore(t)
populateAllCaches(s)
s.invalidateCachesFor(cacheInvalidation{hasNewObservations: true})
pop := cachePopulated(s)
if pop["rf"] {
t.Error("rf cache should be cleared on new observations")
}
// These should be preserved
for _, name := range []string{"topo", "hash", "chan", "dist", "subpath"} {
if !pop[name] {
t.Errorf("%s cache should NOT be cleared on observation-only ingest", name)
}
}
}
func TestInvalidateCachesFor_NewTransmissionsOnly(t *testing.T) {
s := newTestStore(t)
populateAllCaches(s)
s.invalidateCachesFor(cacheInvalidation{hasNewTransmissions: true})
pop := cachePopulated(s)
if pop["hash"] {
t.Error("hash cache should be cleared on new transmissions")
}
for _, name := range []string{"rf", "topo", "chan", "dist", "subpath"} {
if !pop[name] {
t.Errorf("%s cache should NOT be cleared on transmission-only ingest", name)
}
}
}
func TestInvalidateCachesFor_ChannelDataOnly(t *testing.T) {
s := newTestStore(t)
populateAllCaches(s)
s.invalidateCachesFor(cacheInvalidation{hasChannelData: true})
pop := cachePopulated(s)
if pop["chan"] {
t.Error("chan cache should be cleared on channel data")
}
for _, name := range []string{"rf", "topo", "hash", "dist", "subpath"} {
if !pop[name] {
t.Errorf("%s cache should NOT be cleared on channel-data-only ingest", name)
}
}
}
func TestInvalidateCachesFor_NewPaths(t *testing.T) {
s := newTestStore(t)
populateAllCaches(s)
s.invalidateCachesFor(cacheInvalidation{hasNewPaths: true})
pop := cachePopulated(s)
for _, name := range []string{"topo", "dist", "subpath"} {
if pop[name] {
t.Errorf("%s cache should be cleared on new paths", name)
}
}
for _, name := range []string{"rf", "hash", "chan"} {
if !pop[name] {
t.Errorf("%s cache should NOT be cleared on path-only ingest", name)
}
}
}
func TestInvalidateCachesFor_CombinedFlags(t *testing.T) {
s := newTestStore(t)
populateAllCaches(s)
// Simulate a typical ingest: new transmissions with observations but no GRP_TXT
s.invalidateCachesFor(cacheInvalidation{
hasNewObservations: true,
hasNewTransmissions: true,
hasNewPaths: true,
})
pop := cachePopulated(s)
// rf, topo, hash, dist, subpath should all be cleared
for _, name := range []string{"rf", "topo", "hash", "dist", "subpath"} {
if pop[name] {
t.Errorf("%s cache should be cleared with combined flags", name)
}
}
// chan should be preserved (no GRP_TXT)
if !pop["chan"] {
t.Error("chan cache should NOT be cleared without hasChannelData flag")
}
}
func TestInvalidateCachesFor_NoFlags(t *testing.T) {
s := newTestStore(t)
populateAllCaches(s)
s.invalidateCachesFor(cacheInvalidation{})
pop := cachePopulated(s)
for name, has := range pop {
if !has {
t.Errorf("%s cache should be preserved when no flags are set", name)
}
}
}
+16 -18
View File
@@ -6,6 +6,8 @@ import (
"os"
"path/filepath"
"strings"
"github.com/meshcore-analyzer/geofilter"
)
// Config mirrors the Node.js config.json structure (read-only fields).
@@ -61,15 +63,15 @@ type PacketStoreConfig struct {
MaxMemoryMB int `json:"maxMemoryMB"` // hard memory ceiling in MB (0 = unlimited)
}
type GeoFilterConfig struct {
Polygon [][2]float64 `json:"polygon,omitempty"`
BufferKm float64 `json:"bufferKm,omitempty"`
LatMin *float64 `json:"latMin,omitempty"`
LatMax *float64 `json:"latMax,omitempty"`
LonMin *float64 `json:"lonMin,omitempty"`
LonMax *float64 `json:"lonMax,omitempty"`
// GeoFilterConfig is an alias for the shared geofilter.Config type.
type GeoFilterConfig = geofilter.Config
type RetentionConfig struct {
NodeDays int `json:"nodeDays"`
PacketDays int `json:"packetDays"`
}
type TimestampConfig struct {
DefaultMode string `json:"defaultMode"` // "ago" | "absolute"
Timezone string `json:"timezone"` // "local" | "utc"
@@ -78,10 +80,6 @@ type TimestampConfig struct {
AllowCustomFormat bool `json:"allowCustomFormat"` // admin gate
}
type RetentionConfig struct {
NodeDays int `json:"nodeDays"`
}
func defaultTimestampConfig() TimestampConfig {
return TimestampConfig{
DefaultMode: "ago",
@@ -221,17 +219,11 @@ func (c *Config) ResolveDBPath(baseDir string) string {
return filepath.Join(baseDir, "data", "meshcore.db")
}
func (c *Config) PropagationBufferMs() int {
if c.LiveMap.PropagationBufferMs > 0 {
return c.LiveMap.PropagationBufferMs
}
return 5000
}
func (c *Config) NormalizeTimestampConfig() {
defaults := defaultTimestampConfig()
if c.Timestamps == nil {
log.Printf("[config] timestamps not configured using defaults (ago/local/iso)")
log.Printf("[config] timestamps not configured - using defaults (ago/local/iso)")
c.Timestamps = &defaults
return
}
@@ -273,3 +265,9 @@ func (c *Config) GetTimestampConfig() TimestampConfig {
}
return *c.Timestamps
}
func (c *Config) PropagationBufferMs() int {
if c.LiveMap.PropagationBufferMs > 0 {
return c.LiveMap.PropagationBufferMs
}
return 5000
}
+43
View File
@@ -4,6 +4,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"log"
"math"
"os"
"strings"
@@ -38,6 +39,12 @@ func OpenDB(path string) (*DB, error) {
}
func (db *DB) Close() error {
// Checkpoint WAL before closing to release lock cleanly for new processes
if _, err := db.conn.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
log.Printf("[db] WAL checkpoint error: %v", err)
} else {
log.Println("[db] WAL checkpoint complete")
}
return db.conn.Close()
}
@@ -1621,3 +1628,39 @@ func nullInt(ni sql.NullInt64) interface{} {
}
return nil
}
// PruneOldPackets deletes transmissions and their observations older than the
// given number of days. Nodes and observers are never touched.
// Returns the number of transmissions deleted.
// Opens a separate read-write connection since the main connection is read-only.
func (db *DB) PruneOldPackets(days int) (int64, error) {
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_busy_timeout=10000", db.path)
rw, err := sql.Open("sqlite", dsn)
if err != nil {
return 0, err
}
rw.SetMaxOpenConns(1)
defer rw.Close()
cutoff := time.Now().UTC().AddDate(0, 0, -days).Format(time.RFC3339)
tx, err := rw.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
// Delete observations linked to old transmissions first (no CASCADE in SQLite)
_, err = tx.Exec(`DELETE FROM observations WHERE transmission_id IN (
SELECT id FROM transmissions WHERE first_seen < ?
)`, cutoff)
if err != nil {
return 0, err
}
res, err := tx.Exec(`DELETE FROM transmissions WHERE first_seen < ?`, cutoff)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return n, tx.Commit()
}
+95
View File
@@ -0,0 +1,95 @@
package main
import (
"testing"
)
func TestDecodeHeader_TransportFlood(t *testing.T) {
// Route type 0 = TRANSPORT_FLOOD, payload type 5 = GRP_TXT, version 0
// Header byte: (0 << 6) | (5 << 2) | 0 = 0x14
h := decodeHeader(0x14)
if h.RouteType != RouteTransportFlood {
t.Errorf("expected RouteTransportFlood (0), got %d", h.RouteType)
}
if h.RouteTypeName != "TRANSPORT_FLOOD" {
t.Errorf("expected TRANSPORT_FLOOD, got %s", h.RouteTypeName)
}
if h.PayloadType != PayloadGRP_TXT {
t.Errorf("expected PayloadGRP_TXT (5), got %d", h.PayloadType)
}
}
func TestDecodeHeader_TransportDirect(t *testing.T) {
// Route type 3 = TRANSPORT_DIRECT, payload type 2 = TXT_MSG, version 0
// Header byte: (0 << 6) | (2 << 2) | 3 = 0x0B
h := decodeHeader(0x0B)
if h.RouteType != RouteTransportDirect {
t.Errorf("expected RouteTransportDirect (3), got %d", h.RouteType)
}
if h.RouteTypeName != "TRANSPORT_DIRECT" {
t.Errorf("expected TRANSPORT_DIRECT, got %s", h.RouteTypeName)
}
}
func TestDecodeHeader_Flood(t *testing.T) {
// Route type 1 = FLOOD, payload type 4 = ADVERT
// Header byte: (0 << 6) | (4 << 2) | 1 = 0x11
h := decodeHeader(0x11)
if h.RouteType != RouteFlood {
t.Errorf("expected RouteFlood (1), got %d", h.RouteType)
}
if h.RouteTypeName != "FLOOD" {
t.Errorf("expected FLOOD, got %s", h.RouteTypeName)
}
}
func TestIsTransportRoute(t *testing.T) {
if !isTransportRoute(RouteTransportFlood) {
t.Error("expected RouteTransportFlood to be transport")
}
if !isTransportRoute(RouteTransportDirect) {
t.Error("expected RouteTransportDirect to be transport")
}
if isTransportRoute(RouteFlood) {
t.Error("expected RouteFlood to NOT be transport")
}
if isTransportRoute(RouteDirect) {
t.Error("expected RouteDirect to NOT be transport")
}
}
func TestDecodePacket_TransportFloodHasCodes(t *testing.T) {
// Build a minimal TRANSPORT_FLOOD packet:
// Header 0x14 (route=0/T_FLOOD, payload=5/GRP_TXT)
// Transport codes: AABB CCDD (4 bytes)
// Path byte: 0x00 (hashSize=1, hashCount=0)
// Payload: at least some bytes for GRP_TXT
hex := "14AABBCCDD00112233445566778899"
pkt, err := DecodePacket(hex)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pkt.TransportCodes == nil {
t.Fatal("expected transport codes to be present")
}
if pkt.TransportCodes.Code1 != "AABB" {
t.Errorf("expected Code1=AABB, got %s", pkt.TransportCodes.Code1)
}
if pkt.TransportCodes.Code2 != "CCDD" {
t.Errorf("expected Code2=CCDD, got %s", pkt.TransportCodes.Code2)
}
}
func TestDecodePacket_FloodHasNoCodes(t *testing.T) {
// Header 0x11 (route=1/FLOOD, payload=4/ADVERT)
// Path byte: 0x00 (no hops)
// Some payload bytes
hex := "110011223344556677889900AABBCCDD"
pkt, err := DecodePacket(hex)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pkt.TransportCodes != nil {
t.Error("expected no transport codes for FLOOD route")
}
}
+34
View File
@@ -0,0 +1,34 @@
package main
import "github.com/meshcore-analyzer/geofilter"
// NodePassesGeoFilter returns true if the node should be included in responses.
// Nodes with no GPS coordinates are always allowed.
// lat and lon are interface{} because they come from DB row maps.
func NodePassesGeoFilter(lat, lon interface{}, gf *GeoFilterConfig) bool {
if gf == nil {
return true
}
latF, ok1 := toFloat64(lat)
lonF, ok2 := toFloat64(lon)
if !ok1 || !ok2 {
return true
}
return geofilter.PassesFilter(latF, lonF, gf)
}
func toFloat64(v interface{}) (float64, bool) {
switch x := v.(type) {
case float64:
return x, true
case float32:
return float64(x), true
case int:
return float64(x), true
case int64:
return float64(x), true
case nil:
return 0, false
}
return 0, false
}
+3
View File
@@ -5,9 +5,12 @@ go 1.22
require (
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
github.com/meshcore-analyzer/geofilter v0.0.0
modernc.org/sqlite v1.34.5
)
replace github.com/meshcore-analyzer/geofilter => ../../internal/geofilter
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
+50 -4
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"database/sql"
"flag"
"fmt"
@@ -10,6 +11,7 @@ import (
"os"
"os/exec"
"os/signal"
"sync"
"path/filepath"
"strings"
"syscall"
@@ -113,7 +115,13 @@ func main() {
if err != nil {
log.Fatalf("[db] failed to open %s: %v", resolvedDB, err)
}
defer database.Close()
var dbCloseOnce sync.Once
dbClose := func() error {
var err error
dbCloseOnce.Do(func() { err = database.Close() })
return err
}
defer dbClose()
// Verify DB has expected tables
var tableName string
@@ -171,6 +179,27 @@ func main() {
stopEviction := store.StartEvictionTicker()
defer stopEviction()
// Auto-prune old packets if retention.packetDays is configured
if cfg.Retention != nil && cfg.Retention.PacketDays > 0 {
days := cfg.Retention.PacketDays
go func() {
time.Sleep(1 * time.Minute)
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 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)
}
}
}()
log.Printf("[prune] auto-prune enabled: packets older than %d days will be removed daily", days)
}
// Graceful shutdown
httpServer := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
@@ -183,10 +212,27 @@ func main() {
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Println("[server] shutting down...")
sig := <-sigCh
log.Printf("[server] received %v, shutting down...", sig)
// 1. Stop accepting new WebSocket/poll data
poller.Stop()
httpServer.Close()
// 2. Gracefully drain HTTP connections (up to 15s)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := httpServer.Shutdown(ctx); err != nil {
log.Printf("[server] HTTP shutdown error: %v", err)
}
// 3. Close WebSocket hub
hub.Close()
// 4. Close database (release SQLite WAL lock)
if err := dbClose(); err != nil {
log.Printf("[server] DB close error: %v", err)
}
log.Println("[server] shutdown complete")
}()
log.Printf("[server] CoreScope (Go) listening on http://localhost:%d", cfg.Port)
+44
View File
@@ -109,6 +109,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/stats", s.handleStats).Methods("GET")
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")
// Packet endpoints
r.HandleFunc("/api/packets/timestamps", s.handlePacketTimestamps).Methods("GET")
@@ -135,6 +136,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/analytics/channels", s.handleAnalyticsChannels).Methods("GET")
r.HandleFunc("/api/analytics/distance", s.handleAnalyticsDistance).Methods("GET")
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/subpath-detail", s.handleAnalyticsSubpathDetail).Methods("GET")
@@ -855,6 +857,16 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
}
}
}
if s.cfg.GeoFilter != nil {
filtered := nodes[:0]
for _, node := range nodes {
if NodePassesGeoFilter(node["lat"], node["lon"], s.cfg.GeoFilter) {
filtered = append(filtered, node)
}
}
total = len(filtered)
nodes = filtered
}
writeJSON(w, NodeListResponse{Nodes: nodes, Total: total, Counts: counts})
}
@@ -1190,6 +1202,17 @@ func (s *Server) handleAnalyticsHashSizes(w http.ResponseWriter, r *http.Request
})
}
func (s *Server) handleAnalyticsHashCollisions(w http.ResponseWriter, r *http.Request) {
if s.store != nil {
writeJSON(w, s.store.GetAnalyticsHashCollisions())
return
}
writeJSON(w, map[string]interface{}{
"inconsistent_nodes": []interface{}{},
"by_size": map[string]interface{}{},
})
}
func (s *Server) handleAnalyticsSubpaths(w http.ResponseWriter, r *http.Request) {
if s.store != nil {
region := r.URL.Query().Get("region")
@@ -1842,3 +1865,24 @@ func nullFloatVal(n sql.NullFloat64) float64 {
}
return 0
}
func (s *Server) handleAdminPrune(w http.ResponseWriter, r *http.Request) {
days := 0
if d := r.URL.Query().Get("days"); d != "" {
fmt.Sscanf(d, "%d", &days)
}
if days <= 0 && s.cfg.Retention != nil {
days = s.cfg.Retention.PacketDays
}
if days <= 0 {
writeError(w, 400, "days parameter required (or set retention.packetDays in config)")
return
}
n, err := s.db.PruneOldPackets(days)
if err != nil {
writeError(w, 500, err.Error())
return
}
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
writeJSON(w, map[string]interface{}{"deleted": n, "days": days})
}
+813 -4
View File
@@ -2086,8 +2086,8 @@ t.Error("expected inconsistent flag to be true for flip-flop pattern")
}
func TestGetNodeHashSizeInfoDominant(t *testing.T) {
// A node that sends mostly 2-byte adverts but occasionally 1-byte (pathByte=0x00
// on direct sends) should report HashSize=2, not 1.
// A node with mostly 2-byte adverts and an occasional 1-byte advert; the
// latest advert (2-byte) determines the reported hash size.
db := setupTestDB(t)
seedTestData(t, db)
store := NewPacketStore(db, nil)
@@ -2103,7 +2103,7 @@ raw1byte := "04" + "00" + "aabb" // pathByte=0x00 → hashSize=1 (direct send, n
raw2byte := "04" + "40" + "aabb" // pathByte=0x40 → hashSize=2
payloadType := 4
// 1 packet with hashSize=1, 4 packets with hashSize=2
// 1 packet with hashSize=1, 4 packets with hashSize=2 (latest is 2-byte)
raws := []string{raw1byte, raw2byte, raw2byte, raw2byte, raw2byte}
for i, raw := range raws {
tx := &StoreTx{
@@ -2124,10 +2124,194 @@ if ni == nil {
t.Fatal("expected hash info for test node")
}
if ni.HashSize != 2 {
t.Errorf("HashSize=%d, want 2 (dominant size should win over occasional 1-byte)", ni.HashSize)
t.Errorf("HashSize=%d, want 2 (latest advert should determine hash size)", ni.HashSize)
}
}
func TestGetNodeHashSizeInfoLatestWins(t *testing.T) {
// A node reconfigured from 1-byte to 2-byte hash should show 2-byte
// even when it has many more historical 1-byte adverts (issue #303).
db := setupTestDB(t)
seedTestData(t, db)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
pk := "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'LatestWins', 'repeater')", pk)
decoded := `{"name":"LatestWins","pubKey":"` + pk + `"}`
raw1byte := "04" + "00" + "aabb" // pathByte=0x00 → hashSize=1
raw2byte := "04" + "40" + "aabb" // pathByte=0x40 → hashSize=2
payloadType := 4
// 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}
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",
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 {
t.Fatal("expected hash info for test node")
}
if ni.HashSize != 2 {
t.Errorf("HashSize=%d, want 2 (latest advert should win over historical mode)", ni.HashSize)
}
if len(ni.AllSizes) != 2 {
t.Errorf("AllSizes count=%d, want 2", len(ni.AllSizes))
}
if !ni.AllSizes[1] || !ni.AllSizes[2] {
t.Error("AllSizes should contain both 1 and 2")
}
}
func TestGetNodeHashSizeInfoNoAdverts(t *testing.T) {
// A node with no ADVERT packets should not appear in hash size info.
db := setupTestDB(t)
seedTestData(t, db)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
pk := "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'NoAdverts', 'repeater')", pk)
// Add a non-advert packet (payload_type=2 = TXT_MSG)
payloadType := 2
tx := &StoreTx{
ID: 6000,
RawHex: "0440aabb",
Hash: "noadverts0",
FirstSeen: "2024-01-01T00:00:00Z",
PayloadType: &payloadType,
DecodedJSON: `{"pubKey":"` + pk + `"}`,
}
store.packets = append(store.packets, tx)
store.byPayloadType[2] = append(store.byPayloadType[2], tx)
info := store.GetNodeHashSizeInfo()
if ni := info[pk]; ni != nil {
t.Errorf("expected nil hash info for node with no adverts, got HashSize=%d", ni.HashSize)
}
}
func TestHashAnalyticsZeroHopAdvert(t *testing.T) {
// A zero-hop advert (pathByte=0x00, no relay path) should contribute to
// distributionByRepeaters (per-node tracking) but NOT inflate total or
// distribution (which only count relayed packets).
db := setupTestDB(t)
seedTestData(t, db)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
// Capture baseline from seed data (bypass cache via computeAnalyticsHashSizes)
baseline := store.computeAnalyticsHashSizes("")
baseTotal, _ := baseline["total"].(int)
baseDist, _ := baseline["distribution"].(map[string]int)
baseDist1 := baseDist["1"]
pk := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'ZeroHop', 'repeater')", pk)
decoded := `{"name":"ZeroHop","pubKey":"` + pk + `"}`
// header 0x05 → routeType=1 (FLOOD), pathByte=0x00 → hashSize=1
raw := "05" + "00" + "aabb"
payloadType := 4
tx := &StoreTx{
ID: 8000,
RawHex: raw,
Hash: "zerohop0",
FirstSeen: "2024-01-01T00:00:00Z",
PayloadType: &payloadType,
DecodedJSON: decoded,
// No PathJSON → txGetParsedPath returns nil (zero hops)
}
store.packets = append(store.packets, tx)
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
result := store.computeAnalyticsHashSizes("")
// distributionByRepeaters should include the zero-hop advert's node
distByRepeaters, ok := result["distributionByRepeaters"].(map[string]int)
if !ok {
t.Fatal("distributionByRepeaters missing or wrong type")
}
if distByRepeaters["1"] < 1 {
t.Errorf("distributionByRepeaters[\"1\"]=%d, want >=1 (zero-hop advert should be tracked per-node)", distByRepeaters["1"])
}
// total and distribution must NOT have increased from the baseline
total, _ := result["total"].(int)
dist, _ := result["distribution"].(map[string]int)
if total != baseTotal {
t.Errorf("total=%d, want %d (zero-hop adverts must not inflate total)", total, baseTotal)
}
if dist["1"] != baseDist1 {
t.Errorf("distribution[\"1\"]=%d, want %d (zero-hop adverts must not inflate distribution)", dist["1"], baseDist1)
}
}
func TestAnalyticsHashSizeSameNameDifferentPubkey(t *testing.T) {
// Two nodes named "SameName" with different pubkeys should be counted
// separately in distributionByRepeaters (issue #303, byNode keying fix).
db := setupTestDB(t)
seedTestData(t, db)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
pk1 := "aaaa111122223333444455556666777788889999aaaabbbbccccddddeeee1111"
pk2 := "aaaa111122223333444455556666777788889999aaaabbbbccccddddeeee2222"
decoded1 := `{"name":"SameName","pubKey":"` + pk1 + `"}`
decoded2 := `{"name":"SameName","pubKey":"` + pk2 + `"}`
raw2byte := "05" + "40" + "aabb" // header routeType=1 (FLOOD), pathByte=0x40 → hashSize=2
payloadType := 4
for i, decoded := range []string{decoded1, decoded2} {
tx := &StoreTx{
ID: 6100 + i,
RawHex: raw2byte,
Hash: "samename" + strconv.Itoa(i),
FirstSeen: "2024-01-01T00:00:00Z",
PayloadType: &payloadType,
DecodedJSON: decoded,
PathJSON: `["AABB"]`,
}
store.packets = append(store.packets, tx)
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
}
result := store.GetAnalyticsHashSizes("")
distByRepeaters, ok := result["distributionByRepeaters"].(map[string]int)
if !ok {
t.Fatal("distributionByRepeaters missing or wrong type")
}
if distByRepeaters["2"] < 2 {
t.Errorf("distributionByRepeaters[\"2\"]=%d, want >=2 (same-name nodes with different pubkeys should be counted separately)", distByRepeaters["2"])
}
}
func TestAnalyticsHashSizesNoNullArrays(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/analytics/hash-sizes", nil)
@@ -2218,3 +2402,628 @@ func min(a, b int) int {
}
return b
}
// TestLatestSeenMaintained verifies that StoreTx.LatestSeen is populated after Load()
// and is >= FirstSeen for packets that have observations.
func TestLatestSeenMaintained(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
store.mu.RLock()
defer store.mu.RUnlock()
if len(store.packets) == 0 {
t.Fatal("expected packets in store after Load")
}
for _, tx := range store.packets {
if tx.LatestSeen == "" {
t.Errorf("packet %s has empty LatestSeen (FirstSeen=%s)", tx.Hash, tx.FirstSeen)
continue
}
// LatestSeen must be >= FirstSeen (string comparison works for RFC3339/ISO8601)
if tx.LatestSeen < tx.FirstSeen {
t.Errorf("packet %s: LatestSeen %q < FirstSeen %q", tx.Hash, tx.LatestSeen, tx.FirstSeen)
}
// For packets with observations, LatestSeen must be >= all observation timestamps.
for _, obs := range tx.Observations {
if obs.Timestamp != "" && obs.Timestamp > tx.LatestSeen {
t.Errorf("packet %s: obs.Timestamp %q > LatestSeen %q", tx.Hash, obs.Timestamp, tx.LatestSeen)
}
}
}
}
// TestQueryGroupedPacketsSortedByLatest verifies that QueryGroupedPackets returns packets
// sorted by LatestSeen DESC — i.e. the packet whose most-recent observation is newest
// comes first, even if its first_seen is older.
func TestQueryGroupedPacketsSortedByLatest(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
now := time.Now().UTC()
// oldFirst: first_seen is old, but observation is very recent.
oldFirst := now.Add(-48 * time.Hour).Format(time.RFC3339)
// newFirst: first_seen is recent, but observation is old.
newFirst := now.Add(-1 * time.Hour).Format(time.RFC3339)
recentEpoch := now.Add(-5 * time.Minute).Unix()
oldEpoch := now.Add(-72 * time.Hour).Unix()
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
VALUES ('sortobs', 'Sort Observer', 'TST', ?, '2026-01-01T00:00:00Z', 1)`, now.Format(time.RFC3339))
// Packet A: old first_seen, but a very recent observation — should sort first.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('AA01', 'sort_old_first_recent_obs', ?, 1, 2, '{"type":"TXT_MSG","text":"old first"}')`, oldFirst)
var idA int64
db.conn.QueryRow(`SELECT id FROM transmissions WHERE hash='sort_old_first_recent_obs'`).Scan(&idA)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (?, 1, 10.0, -90, '[]', ?)`, idA, recentEpoch)
// Packet B: newer first_seen, but an old observation — should sort second.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('BB02', 'sort_new_first_old_obs', ?, 1, 2, '{"type":"TXT_MSG","text":"new first"}')`, newFirst)
var idB int64
db.conn.QueryRow(`SELECT id FROM transmissions WHERE hash='sort_new_first_old_obs'`).Scan(&idB)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (?, 1, 10.0, -90, '[]', ?)`, idB, oldEpoch)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
result := store.QueryGroupedPackets(PacketQuery{Limit: 50})
if result.Total < 2 {
t.Fatalf("expected at least 2 packets, got %d", result.Total)
}
// Find the two test packets in the result (may be mixed with other entries).
firstHash := ""
secondHash := ""
for _, p := range result.Packets {
h, _ := p["hash"].(string)
if h == "sort_old_first_recent_obs" || h == "sort_new_first_old_obs" {
if firstHash == "" {
firstHash = h
} else {
secondHash = h
break
}
}
}
if firstHash != "sort_old_first_recent_obs" {
t.Errorf("expected sort_old_first_recent_obs to appear before sort_new_first_old_obs in sorted results; got first=%q second=%q", firstHash, secondHash)
}
}
// TestQueryGroupedPacketsCacheReturnsConsistentResult verifies that two rapid successive
// calls to QueryGroupedPackets return the same total count and first packet hash.
func TestQueryGroupedPacketsCacheReturnsConsistentResult(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
q := PacketQuery{Limit: 50}
r1 := store.QueryGroupedPackets(q)
r2 := store.QueryGroupedPackets(q)
if r1.Total != r2.Total {
t.Errorf("cache inconsistency: first call total=%d, second call total=%d", r1.Total, r2.Total)
}
if r1.Total == 0 {
t.Fatal("expected non-zero results from QueryGroupedPackets")
}
h1, _ := r1.Packets[0]["hash"].(string)
h2, _ := r2.Packets[0]["hash"].(string)
if h1 != h2 {
t.Errorf("cache inconsistency: first call first hash=%q, second call first hash=%q", h1, h2)
}
}
// TestGetChannelsCacheReturnsConsistentResult verifies that two rapid successive calls
// to GetChannels return the same number of channels with the same names.
func TestGetChannelsCacheReturnsConsistentResult(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
r1 := store.GetChannels("")
r2 := store.GetChannels("")
if len(r1) != len(r2) {
t.Errorf("cache inconsistency: first call len=%d, second call len=%d", len(r1), len(r2))
}
if len(r1) == 0 {
t.Fatal("expected at least one channel from seedTestData")
}
names1 := make(map[string]bool)
for _, ch := range r1 {
if n, ok := ch["name"].(string); ok {
names1[n] = true
}
}
for _, ch := range r2 {
if n, ok := ch["name"].(string); ok {
if !names1[n] {
t.Errorf("cache inconsistency: channel %q in second result but not first", n)
}
}
}
}
// TestGetChannelsNotBlockedByLargeLock verifies that GetChannels returns correct channel
// data (count and messageCount) after observations have been added — i.e. the lock-copy
// pattern works correctly and the JSON unmarshal outside the lock produces valid results.
func TestGetChannelsNotBlockedByLargeLock(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
channels := store.GetChannels("")
// seedTestData inserts one GRP_TXT (payload_type=5) packet with channel "#test".
if len(channels) != 1 {
t.Fatalf("expected 1 channel, got %d", len(channels))
}
ch := channels[0]
name, ok := ch["name"].(string)
if !ok || name != "#test" {
t.Errorf("expected channel name '#test', got %v", ch["name"])
}
// messageCount should be 1 (one CHAN packet for #test).
msgCount, ok := ch["messageCount"].(int)
if !ok {
// JSON numbers may unmarshal as float64 — but GetChannels returns native Go values.
t.Errorf("expected messageCount to be int, got %T (%v)", ch["messageCount"], ch["messageCount"])
} else if msgCount != 1 {
t.Errorf("expected messageCount=1, got %d", msgCount)
}
}
// --- Tests for computeHashCollisions (Issue #416) ---
func TestAnalyticsHashCollisionsEndpoint(t *testing.T) {
_, router := setupTestServer(t)
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{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
// Must have top-level keys
if _, ok := body["inconsistent_nodes"]; !ok {
t.Error("missing inconsistent_nodes key")
}
if _, ok := body["by_size"]; !ok {
t.Error("missing by_size key")
}
bySize, ok := body["by_size"].(map[string]interface{})
if !ok {
t.Fatal("by_size is not a map")
}
// Must have entries for 1, 2, 3 byte sizes
for _, sz := range []string{"1", "2", "3"} {
sizeData, ok := bySize[sz].(map[string]interface{})
if !ok {
t.Errorf("by_size[%s] is not a map", sz)
continue
}
stats, ok := sizeData["stats"].(map[string]interface{})
if !ok {
t.Errorf("by_size[%s].stats is not a map", sz)
continue
}
if _, ok := stats["total_nodes"]; !ok {
t.Errorf("by_size[%s].stats missing total_nodes", sz)
}
if _, ok := stats["collision_count"]; !ok {
t.Errorf("by_size[%s].stats missing collision_count", sz)
}
// collisions must be an array, not null
collisions, ok := sizeData["collisions"].([]interface{})
if !ok {
t.Errorf("by_size[%s].collisions is not an array", sz)
}
_ = collisions
}
}
func TestHashCollisionsNoNullArrays(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/analytics/hash-collisions", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// JSON must not contain "null" for arrays
bodyStr := w.Body.String()
if bodyStr == "" {
t.Fatal("empty response body")
}
// inconsistent_nodes should be [] not null
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
if body["inconsistent_nodes"] == nil {
t.Error("inconsistent_nodes is null, should be empty array")
}
}
func TestHashCollisionsRegionParamIgnored(t *testing.T) {
// Issue #417: region param was accepted but ignored.
// After fix, the endpoint should work without region and not cache per-region.
_, router := setupTestServer(t)
// Request without region
req1 := httptest.NewRequest("GET", "/api/analytics/hash-collisions", nil)
w1 := httptest.NewRecorder()
router.ServeHTTP(w1, req1)
if w1.Code != 200 {
t.Fatalf("expected 200, got %d", w1.Code)
}
// Request with region param (should be ignored, same result)
req2 := httptest.NewRequest("GET", "/api/analytics/hash-collisions?region=us-west", nil)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
if w2.Code != 200 {
t.Fatalf("expected 200, got %d", w2.Code)
}
// Both should return identical results
if w1.Body.String() != w2.Body.String() {
t.Error("responses differ with/without region param — region should be ignored")
}
}
func TestHashCollisionsOneByteCells(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/analytics/hash-collisions", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
bySize := body["by_size"].(map[string]interface{})
oneByteData := bySize["1"].(map[string]interface{})
// 1-byte data should include one_byte_cells for matrix rendering
cells, ok := oneByteData["one_byte_cells"].(map[string]interface{})
if !ok {
t.Fatal("1-byte data missing one_byte_cells")
}
// Should have 256 entries (00-FF)
if len(cells) != 256 {
t.Errorf("expected 256 one_byte_cells entries, got %d", len(cells))
}
}
func TestHashCollisionsTwoByteCells(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/analytics/hash-collisions", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
bySize := body["by_size"].(map[string]interface{})
twoByteData := bySize["2"].(map[string]interface{})
// 2-byte data should include two_byte_cells for matrix rendering
cells, ok := twoByteData["two_byte_cells"].(map[string]interface{})
if !ok {
t.Fatal("2-byte data missing two_byte_cells")
}
// Should have 256 entries (00-FF first-byte groups)
if len(cells) != 256 {
t.Errorf("expected 256 two_byte_cells entries, got %d", len(cells))
}
}
func TestHashCollisionsThreeByteNoMatrix(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/analytics/hash-collisions", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
bySize := body["by_size"].(map[string]interface{})
threeByteData := bySize["3"].(map[string]interface{})
// 3-byte data should NOT have one_byte_cells or two_byte_cells
if _, ok := threeByteData["one_byte_cells"]; ok {
t.Error("3-byte data should not have one_byte_cells")
}
if _, ok := threeByteData["two_byte_cells"]; ok {
t.Error("3-byte data should not have two_byte_cells")
}
}
func TestHashCollisionsClassification(t *testing.T) {
// Test with seed data — nodes have coordinates, so distance classification should work
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/analytics/hash-collisions", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
bySize := body["by_size"].(map[string]interface{})
// Check that collision entries have required fields
for _, sz := range []string{"1", "2", "3"} {
sizeData := bySize[sz].(map[string]interface{})
collisions := sizeData["collisions"].([]interface{})
for i, c := range collisions {
entry := c.(map[string]interface{})
if _, ok := entry["prefix"]; !ok {
t.Errorf("by_size[%s].collisions[%d] missing prefix", sz, i)
}
if _, ok := entry["classification"]; !ok {
t.Errorf("by_size[%s].collisions[%d] missing classification", sz, i)
}
class := entry["classification"].(string)
validClasses := map[string]bool{"local": true, "regional": true, "distant": true, "incomplete": true, "unknown": true}
if !validClasses[class] {
t.Errorf("by_size[%s].collisions[%d] invalid classification: %s", sz, i, class)
}
nodes, ok := entry["nodes"].([]interface{})
if !ok {
t.Errorf("by_size[%s].collisions[%d] missing nodes array", sz, i)
}
if len(nodes) < 2 {
t.Errorf("by_size[%s].collisions[%d] has %d nodes, expected >=2", sz, i, len(nodes))
}
}
}
}
func TestHashCollisionsCacheTTL(t *testing.T) {
// Issue #420: collision cache should use dedicated TTL (60s), not rfCacheTTL (15s)
db := setupTestDB(t)
seedTestData(t, db)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
if store.collisionCacheTTL != 60*time.Second {
t.Errorf("expected collisionCacheTTL=60s, got %v", store.collisionCacheTTL)
}
if store.rfCacheTTL != 15*time.Second {
t.Errorf("expected rfCacheTTL=15s, got %v", store.rfCacheTTL)
}
}
func TestHashCollisionsStatsFields(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/analytics/hash-collisions", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
bySize := body["by_size"].(map[string]interface{})
for _, sz := range []string{"1", "2", "3"} {
sizeData := bySize[sz].(map[string]interface{})
stats := sizeData["stats"].(map[string]interface{})
requiredFields := []string{"total_nodes", "nodes_for_byte", "using_this_size", "unique_prefixes", "collision_count", "space_size", "pct_used"}
for _, f := range requiredFields {
if _, ok := stats[f]; !ok {
t.Errorf("by_size[%s].stats missing field: %s", sz, f)
}
}
}
}
func TestHashCollisionsEmptyStore(t *testing.T) {
// Test with no nodes seeded
db := setupTestDB(t)
// Don't call seedTestData — empty store
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)
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)
// With no nodes, inconsistent_nodes should be empty array
incon := body["inconsistent_nodes"].([]interface{})
if len(incon) != 0 {
t.Errorf("expected 0 inconsistent nodes, got %d", len(incon))
}
// All collision lists should be empty
bySize := body["by_size"].(map[string]interface{})
for _, sz := range []string{"1", "2", "3"} {
sizeData := bySize[sz].(map[string]interface{})
collisions := sizeData["collisions"].([]interface{})
if len(collisions) != 0 {
t.Errorf("by_size[%s] expected 0 collisions with empty store, got %d", sz, len(collisions))
}
}
}
func TestHashCollisionsWithCollision(t *testing.T) {
// Seed two nodes with the same 1-byte prefix to verify collision detection
db := setupTestDB(t)
// Don't use seedTestData — create minimal data to control hash sizes
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)
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)
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)
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)
req := httptest.NewRequest("GET", "/api/analytics/hash-collisions", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
bySize := body["by_size"].(map[string]interface{})
oneByteData := bySize["1"].(map[string]interface{})
stats := oneByteData["stats"].(map[string]interface{})
collisionCount := int(stats["collision_count"].(float64))
if collisionCount < 1 {
t.Errorf("expected at least 1 collision (CC prefix), got %d", collisionCount)
}
// Check the collision entry
collisions := oneByteData["collisions"].([]interface{})
found := false
for _, c := range collisions {
entry := c.(map[string]interface{})
if entry["prefix"] == "CC" {
found = true
nodes := entry["nodes"].([]interface{})
if len(nodes) < 2 {
t.Errorf("expected >=2 nodes for AA collision, got %d", len(nodes))
}
// Both nodes have coords close together, so classification should be "local"
class := entry["classification"].(string)
if class != "local" {
t.Errorf("expected 'local' classification for nearby nodes, got %s", class)
}
}
}
if !found {
t.Error("expected collision entry with prefix 'CC'")
}
}
func TestHashCollisionsShortPublicKey(t *testing.T) {
// Nodes with very short public keys should not crash
db := setupTestDB(t)
now := time.Now().UTC()
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES ('A', 'ShortKey', 'repeater', 0, 0, ?, '2026-01-01T00:00:00Z', 1)`, recent)
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)
req := httptest.NewRequest("GET", "/api/analytics/hash-collisions", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200 even with short public key, got %d", w.Code)
}
}
func TestHashCollisionsMissingCoordinates(t *testing.T) {
// Nodes without coordinates should get "incomplete" classification
db := setupTestDB(t)
now := time.Now().UTC()
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
// Two nodes same prefix, no coordinates
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES ('BB11223344556677', 'NoCoords1', 'repeater', 0, 0, ?, '2026-01-01T00:00:00Z', 1)`, recent)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES ('BB99887766554433', 'NoCoords2', 'repeater', 0, 0, ?, '2026-01-01T00:00:00Z', 1)`, recent)
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)
req := httptest.NewRequest("GET", "/api/analytics/hash-collisions", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
bySize := body["by_size"].(map[string]interface{})
oneByteData := bySize["1"].(map[string]interface{})
collisions := oneByteData["collisions"].([]interface{})
for _, c := range collisions {
entry := c.(map[string]interface{})
if entry["prefix"] == "BB" {
class := entry["classification"].(string)
if class != "incomplete" {
t.Errorf("expected 'incomplete' for nodes without coords, got %s", class)
}
}
}
}
+599 -184
View File
File diff suppressed because it is too large Load Diff
+31 -3
View File
@@ -25,8 +25,9 @@ type Hub struct {
// Client is a single WebSocket connection.
type Client struct {
conn *websocket.Conn
send chan []byte
conn *websocket.Conn
send chan []byte
closeOnce sync.Once
}
func NewHub() *Hub {
@@ -52,12 +53,28 @@ func (h *Hub) Unregister(c *Client) {
h.mu.Lock()
if _, ok := h.clients[c]; ok {
delete(h.clients, c)
close(c.send)
c.closeOnce.Do(func() { close(c.send) })
}
h.mu.Unlock()
log.Printf("[ws] client disconnected (%d total)", h.ClientCount())
}
// Close gracefully disconnects all WebSocket clients.
func (h *Hub) Close() {
h.mu.Lock()
for c := range h.clients {
c.conn.WriteControl(
websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseGoingAway, "server shutting down"),
time.Now().Add(3*time.Second),
)
c.closeOnce.Do(func() { close(c.send) })
delete(h.clients, c)
}
h.mu.Unlock()
log.Println("[ws] all clients disconnected")
}
// Broadcast sends a message to all connected clients.
func (h *Hub) Broadcast(msg interface{}) {
data, err := json.Marshal(msg)
@@ -166,6 +183,17 @@ func NewPoller(db *DB, hub *Hub, interval time.Duration) *Poller {
func (p *Poller) Start() {
lastID := p.db.GetMaxTransmissionID()
lastObsID := p.db.GetMaxObservationID()
// If the store already loaded data, use its max IDs as a floor.
// This prevents replaying the entire DB when the DB query fails
// (e.g., corrupted DB returns 0 from COALESCE).
if p.store != nil {
if storeMax := p.store.MaxTransmissionID(); storeMax > lastID {
lastID = storeMax
}
if storeMaxObs := p.store.MaxObservationID(); storeMaxObs > lastObsID {
lastObsID = storeMaxObs
}
}
log.Printf("[poller] starting from transmission ID %d, obs ID %d, interval %v", lastID, lastObsID, p.interval)
ticker := time.NewTicker(p.interval)
+2 -1
View File
@@ -3,7 +3,8 @@
"apiKey": "your-secret-api-key-here",
"retention": {
"nodeDays": 7,
"_comment": "Nodes not seen in this many days are moved to inactive_nodes table. Default 7."
"packetDays": 30,
"_comment": "nodeDays: nodes not seen in N days are moved to inactive_nodes (default 7). packetDays: transmissions+observations older than N days are deleted daily (0 = disabled)."
},
"https": {
"cert": "/path/to/cert.pem",
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
set -e
DEPLOY_DIR="$(cd "$(dirname "$0")" && pwd)"
MATOMO_COMMIT="38c30f9"
cd "$DEPLOY_DIR"
echo "[deploy] Fetching latest from origin..."
git fetch origin
echo "[deploy] Resetting to origin/master..."
git reset --hard origin/master
echo "[deploy] Building Docker image..."
docker build -t meshcore-analyzer .
echo "[deploy] Stopping old container (30s grace period)..."
docker stop -t 30 meshcore-analyzer && docker rm meshcore-analyzer
docker run -d --name meshcore-analyzer \
--restart unless-stopped \
-p 3000:3000 \
-v "$(pwd)/config.json:/app/config.json:ro" \
-v meshcore-data:/app/data \
meshcore-analyzer
echo "[deploy] Done. Live at https://analyzer.on8ar.eu"
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
set -e
DEPLOY_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$DEPLOY_DIR"
echo "[staging] Fetching latest from origin..."
git fetch origin
BRANCH="${1:-master}"
echo "[staging] Checking out $BRANCH..."
git reset --hard "origin/$BRANCH"
echo "[staging] Building Docker image..."
docker build -t meshcore-analyzer-staging .
echo "[staging] Stopping old container (30s grace period)..."
docker stop -t 30 meshcore-staging 2>/dev/null || true
docker rm meshcore-staging 2>/dev/null || true
echo "[staging] Starting new container..."
docker run -d --name meshcore-staging \
--restart unless-stopped \
-p 3001:3000 \
-v "$(pwd)/config.json:/app/config.json:ro" \
-v meshcore-staging-data:/app/data \
meshcore-analyzer-staging
echo "[staging] Done. Live at https://staging.on8ar.eu"
+2
View File
@@ -9,6 +9,8 @@ services:
image: corescope:latest
container_name: corescope-prod
restart: unless-stopped
stop_grace_period: 30s
stop_signal: SIGTERM
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
+2
View File
@@ -10,6 +10,8 @@ services:
image: corescope-go:latest
container_name: corescope-staging-go
restart: unless-stopped
stop_grace_period: 30s
stop_signal: SIGTERM
deploy:
resources:
limits:
+2
View File
@@ -13,6 +13,8 @@ services:
image: corescope-go:latest
container_name: corescope-staging-go
restart: unless-stopped
stop_grace_period: 30s
stop_signal: SIGTERM
deploy:
resources:
limits:
+2
View File
@@ -14,6 +14,8 @@ services:
image: corescope:latest
container_name: corescope-prod
restart: unless-stopped
stop_grace_period: 30s
stop_signal: SIGTERM
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
+4
View File
@@ -12,6 +12,8 @@ autostart=true
autorestart=true
startretries=10
startsecs=2
stopsignal=TERM
stopwaitsecs=20
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
@@ -24,6 +26,8 @@ autostart=true
autorestart=true
startretries=10
startsecs=2
stopsignal=TERM
stopwaitsecs=20
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
+4
View File
@@ -21,6 +21,8 @@ autostart=true
autorestart=true
startretries=10
startsecs=2
stopsignal=TERM
stopwaitsecs=20
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
@@ -33,6 +35,8 @@ autostart=true
autorestart=true
startretries=10
startsecs=2
stopsignal=TERM
stopwaitsecs=20
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
+86
View File
@@ -0,0 +1,86 @@
// Package geofilter provides the shared geographic filter configuration and
// geometry used by both the server and ingestor packages.
package geofilter
import "math"
// Config defines the geographic filter polygon or bounding box.
// Shared between the server and ingestor packages.
type Config struct {
Polygon [][2]float64 `json:"polygon,omitempty"`
BufferKm float64 `json:"bufferKm,omitempty"`
LatMin *float64 `json:"latMin,omitempty"`
LatMax *float64 `json:"latMax,omitempty"`
LonMin *float64 `json:"lonMin,omitempty"`
LonMax *float64 `json:"lonMax,omitempty"`
}
// PassesFilter returns true if the coordinates fall within the filter area.
// Nodes with no GPS fix (0,0) are always allowed.
func PassesFilter(lat, lon float64, gf *Config) bool {
if gf == nil {
return true
}
if lat == 0 && lon == 0 {
return true
}
if len(gf.Polygon) >= 3 {
if PointInPolygon(lat, lon, gf.Polygon) {
return true
}
if gf.BufferKm > 0 {
n := len(gf.Polygon)
for i := 0; i < n; i++ {
j := (i + 1) % n
if DistToSegmentKm(lat, lon, gf.Polygon[i], gf.Polygon[j]) <= gf.BufferKm {
return true
}
}
}
return false
}
// Legacy bounding box fallback
if gf.LatMin != nil && gf.LatMax != nil && gf.LonMin != nil && gf.LonMax != nil {
return lat >= *gf.LatMin && lat <= *gf.LatMax && lon >= *gf.LonMin && lon <= *gf.LonMax
}
return true
}
// PointInPolygon uses the ray-casting algorithm.
func PointInPolygon(lat, lon float64, polygon [][2]float64) bool {
inside := false
n := len(polygon)
j := n - 1
for i := 0; i < n; i++ {
yi, xi := polygon[i][0], polygon[i][1]
yj, xj := polygon[j][0], polygon[j][1]
if (yi > lat) != (yj > lat) {
if lon < (xj-xi)*(lat-yi)/(yj-yi)+xi {
inside = !inside
}
}
j = i
}
return inside
}
// DistToSegmentKm returns the approximate distance in km from point (lat,lon)
// to line segment a→b using a flat-earth projection.
func DistToSegmentKm(lat, lon float64, a, b [2]float64) float64 {
lat1, lon1 := a[0], a[1]
lat2, lon2 := b[0], b[1]
cosLat := math.Cos((lat1+lat2) / 2.0 * math.Pi / 180.0)
ax := (lon1 - lon) * 111.0 * cosLat
ay := (lat1 - lat) * 111.0
bx := (lon2 - lon) * 111.0 * cosLat
by := (lat2 - lat) * 111.0
abx, aby := bx-ax, by-ay
abSq := abx*abx + aby*aby
if abSq == 0 {
return math.Sqrt(ax*ax + ay*ay)
}
t := math.Max(0, math.Min(1, -(ax*abx+ay*aby)/abSq))
px := ax + t*abx
py := ay + t*aby
return math.Sqrt(px*px + py*py)
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/meshcore-analyzer/geofilter
go 1.22
+67 -12
View File
@@ -509,6 +509,24 @@ cmd_setup() {
log "Docker $(docker --version | grep -oP 'version \K[^ ,]+')"
log "Compose: $DC"
# Default to latest release tag (instead of staying on master)
if ! is_done "version_pin"; then
git fetch origin --tags 2>/dev/null || true
local latest_tag
latest_tag=$(git tag -l 'v*' --sort=-v:refname | head -1)
if [ -n "$latest_tag" ]; then
local current_ref
current_ref=$(git describe --tags --exact-match 2>/dev/null || echo "")
if [ "$current_ref" != "$latest_tag" ]; then
info "Pinning to latest release: ${latest_tag}"
git checkout "$latest_tag" 2>/dev/null
else
log "Already on latest release: ${latest_tag}"
fi
fi
mark_done "version_pin"
fi
mark_done "docker"
@@ -885,14 +903,10 @@ prepare_staging_config() {
warn "No production config at ${prod_config} — staging may use defaults."
return
fi
if [ ! -f "$staging_config" ] || [ "$prod_config" -nt "$staging_config" ]; then
info "Copying production config to staging..."
cp "$prod_config" "$staging_config"
sed -i 's/"siteName":\s*"[^"]*"/"siteName": "CoreScope — STAGING"/' "$staging_config"
log "Staging config created at ${staging_config} with STAGING site name."
else
log "Staging config is up to date."
fi
info "Copying production config to staging..."
cp "$prod_config" "$staging_config"
sed -i 's/"siteName":\s*"[^"]*"/"siteName": "CoreScope — STAGING"/' "$staging_config"
log "Staging config created at ${staging_config} with STAGING site name."
# Copy Caddyfile for staging (HTTP-only on staging port)
local staging_caddy="$STAGING_DATA/Caddyfile"
if [ ! -f "$staging_caddy" ]; then
@@ -1167,6 +1181,12 @@ cmd_status() {
echo "═══════════════════════════════════════"
echo ""
# Version
local current_version
current_version=$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD 2>/dev/null || echo "unknown")
info "Version: ${current_version}"
echo ""
# Production
show_container_status "corescope-prod" "Production"
echo ""
@@ -1294,8 +1314,39 @@ cmd_promote() {
# ─── Update ───────────────────────────────────────────────────────────────
cmd_update() {
info "Pulling latest code..."
git pull --ff-only
local version="${1:-}"
info "Fetching latest changes and tags..."
git fetch origin --tags
if [ -z "$version" ]; then
# No arg: checkout latest release tag
local latest_tag
latest_tag=$(git tag -l 'v*' --sort=-v:refname | head -1)
if [ -z "$latest_tag" ]; then
err "No release tags found. Use './manage.sh update latest' for tip of master."
exit 1
fi
info "Checking out latest release: ${latest_tag}"
git checkout "$latest_tag" || { err "Failed to checkout tag '${latest_tag}'."; exit 1; }
elif [ "$version" = "latest" ]; then
# Explicit opt-in to bleeding edge (tip of master)
# Note: this creates a detached HEAD at origin/master, which is intentional —
# we want a read-only snapshot of upstream, not a local tracking branch.
info "Checking out tip of master (detached HEAD at origin/master)..."
git checkout origin/master || { err "Failed to checkout origin/master."; exit 1; }
else
# Specific tag requested
if ! git tag -l "$version" | grep -q .; then
err "Tag '${version}' not found."
echo ""
echo " Available releases:"
git tag -l 'v*' --sort=-v:refname | head -10 | sed 's/^/ /'
exit 1
fi
info "Checking out version: ${version}"
git checkout "$version" || { err "Failed to checkout '${version}'."; exit 1; }
fi
migrate_config auto
@@ -1306,6 +1357,10 @@ cmd_update() {
dc_prod up -d --force-recreate prod
log "Updated and restarted. Data preserved."
# Show current version
local current
current=$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)
log "Running version: ${current}"
}
# ─── Backup ───────────────────────────────────────────────────────────────
@@ -1515,7 +1570,7 @@ cmd_help() {
echo " logs [prod|staging] [N] Follow logs (default: prod, last 100 lines)"
echo ""
printf '%b\n' " ${BOLD}Maintain${NC}"
echo " update Pull latest code, rebuild, restart (keeps data)"
echo " update [version] Update to version (no arg=latest tag, 'latest'=master tip, or e.g. v3.1.0)"
echo " promote Promote staging → production (backup + restart)"
echo " backup [dir] Full backup: database + config + theme"
echo " restore <d> Restore from backup dir or .db file"
@@ -1534,7 +1589,7 @@ case "${1:-help}" in
restart) cmd_restart "$2" ;;
status) cmd_status ;;
logs) cmd_logs "$2" "$3" ;;
update) cmd_update ;;
update) cmd_update "$2" ;;
promote) cmd_promote ;;
backup) cmd_backup "$2" ;;
restore) cmd_restore "$2" ;;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "meshcore-analyzer",
"version": "3.1.0",
"version": "3.2.0",
"description": "Community-run alternative to the closed-source `analyzer.letsmesh.net`. MQTT packet collection + open-source web analyzer for the Bay Area MeshCore mesh.",
"main": "index.js",
"scripts": {
+156 -293
View File
@@ -143,13 +143,14 @@
_analyticsData = {};
const rqs = RegionFilter.regionQueryString();
const sep = rqs ? '?' + rqs.slice(1) : '';
const [hashData, rfData, topoData, chanData] = await Promise.all([
const [hashData, rfData, topoData, chanData, collisionData] = await Promise.all([
api('/analytics/hash-sizes' + sep, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/rf' + sep, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/topology' + sep, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/channels' + sep, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/hash-collisions', { ttl: CLIENT_TTL.analyticsRF }),
]);
_analyticsData = { hashData, rfData, topoData, chanData };
_analyticsData = { hashData, rfData, topoData, chanData, collisionData };
renderTab(_currentTab);
} catch (e) {
document.getElementById('analyticsContent').innerHTML =
@@ -166,7 +167,7 @@
case 'topology': renderTopology(el, d.topoData); break;
case 'channels': renderChannels(el, d.chanData); break;
case 'hashsizes': renderHashSizes(el, d.hashData); break;
case 'collisions': await renderCollisionTab(el, d.hashData); break;
case 'collisions': await renderCollisionTab(el, d.hashData, d.collisionData); break;
case 'subpaths': await renderSubpaths(el); break;
case 'nodes': await renderNodesTab(el); break;
case 'distance': await renderDistanceTab(el); break;
@@ -943,7 +944,7 @@
`;
}
async function renderCollisionTab(el, data) {
async function renderCollisionTab(el, data, collisionData) {
el.innerHTML = `
<nav id="hashIssuesToc" style="display:flex;gap:12px;margin-bottom:12px;font-size:13px;flex-wrap:wrap">
<a href="#/analytics?tab=collisions&section=inconsistentHashSection" style="color:var(--accent)"> Inconsistent Sizes</a>
@@ -980,11 +981,9 @@
<div id="collisionList"><div class="text-muted" style="padding:8px">Loading</div></div>
</div>
`;
let allNodes = [];
try { const nd = await api('/nodes?limit=2000' + RegionFilter.regionQueryString(), { ttl: CLIENT_TTL.nodeList }); allNodes = nd.nodes || []; } catch {}
// Render inconsistent hash sizes
const inconsistent = allNodes.filter(n => n.hash_size_inconsistent);
// Use pre-computed collision data from server (no more /nodes?limit=2000 fetch)
const cData = collisionData || { inconsistent_nodes: [], by_size: {} };
const inconsistent = cData.inconsistent_nodes || [];
const ihEl = document.getElementById('inconsistentHashList');
if (ihEl) {
if (!inconsistent.length) {
@@ -1013,10 +1012,7 @@
}
}
// Repeaters are confirmed routing nodes; null-role nodes may also route (possible conflict)
const repeaterNodes = allNodes.filter(n => n.role === 'repeater');
const nullRoleNodes = allNodes.filter(n => !n.role);
const routingNodes = [...repeaterNodes, ...nullRoleNodes];
// Repeaters and routing nodes no longer needed — collision data is server-computed
let currentBytes = 1;
function refreshHashViews(bytes) {
@@ -1037,11 +1033,11 @@
else if (bytes === 2) matrixDesc.textContent = 'Each cell = first-byte group. Color shows worst 2-byte collision within. Click a cell to see the breakdown.';
else matrixDesc.textContent = '3-byte prefix space is too large to visualize as a matrix — collision table is shown below.';
}
renderHashMatrix(data.topHops, routingNodes, bytes, allNodes);
renderHashMatrixFromServer(cData.by_size[String(bytes)], bytes);
// Hide collision risk card for 3-byte — stats are shown in the matrix panel
const riskCard = document.getElementById('collisionRiskSection');
if (riskCard) riskCard.style.display = bytes === 3 ? 'none' : '';
if (bytes !== 3) renderCollisions(data.topHops, routingNodes, bytes);
if (bytes !== 3) renderCollisionsFromServer(cData.by_size[String(bytes)], bytes);
}
// Wire up selector
@@ -1113,92 +1109,65 @@
el.addEventListener('mouseleave', hideMatrixTip);
}
// Pure data helpers — extracted for testability
// --- Shared helpers for hash matrix rendering ---
function buildOneBytePrefixMap(nodes) {
const map = {};
for (let i = 0; i < 256; i++) map[i.toString(16).padStart(2, '0').toUpperCase()] = [];
for (const n of nodes) {
const hex = n.public_key.slice(0, 2).toUpperCase();
if (map[hex]) map[hex].push(n);
}
return map;
function hashStatCardsHtml(totalNodes, usingCount, sizeLabel, spaceSize, usedCount, collisionCount) {
const pct = spaceSize > 0 && usedCount > 0 ? ((usedCount / spaceSize) * 100) : 0;
const pctStr = spaceSize > 65536 ? pct.toFixed(6) : spaceSize > 256 ? pct.toFixed(3) : pct.toFixed(1);
const spaceLabel = spaceSize >= 1e6 ? (spaceSize / 1e6).toFixed(1) + 'M' : spaceSize.toLocaleString();
return `<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Nodes tracked</div>
<div class="analytics-stat-value">${totalNodes.toLocaleString()}</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Using ${sizeLabel} ID</div>
<div class="analytics-stat-value">${usingCount.toLocaleString()}</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Prefix space used</div>
<div class="analytics-stat-value" style="font-size:16px">${pctStr}%</div>
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">${usedCount > 256 ? usedCount + ' of ' : 'of '}${spaceLabel} possible</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${collisionCount > 0 ? 'var(--status-red)' : 'var(--border)'}">
<div class="analytics-stat-label">Prefix collisions</div>
<div class="analytics-stat-value" style="color:${collisionCount > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${collisionCount}</div>
</div>
</div>`;
}
function buildTwoBytePrefixInfo(nodes) {
const info = {};
for (let i = 0; i < 256; i++) {
const h = i.toString(16).padStart(2, '0').toUpperCase();
info[h] = { groupNodes: [], twoByteMap: {}, maxCollision: 0, collisionCount: 0 };
function hashMatrixGridHtml(nibbles, cellSize, headerSize, cellDataFn) {
let html = `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
html += `<tr><td style="width:${headerSize}px"></td>`;
for (const n of nibbles) html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
html += '</tr>';
for (let hi = 0; hi < 16; hi++) {
html += `<tr><td style="text-align:right;padding-right:4px;font-weight:bold;color:var(--text-muted)">${nibbles[hi]}</td>`;
for (let lo = 0; lo < 16; lo++) {
html += cellDataFn(nibbles[hi] + nibbles[lo], cellSize);
}
html += '</tr>';
}
for (const n of nodes) {
const firstHex = n.public_key.slice(0, 2).toUpperCase();
const twoHex = n.public_key.slice(0, 4).toUpperCase();
const entry = info[firstHex];
if (!entry) continue;
entry.groupNodes.push(n);
if (!entry.twoByteMap[twoHex]) entry.twoByteMap[twoHex] = [];
entry.twoByteMap[twoHex].push(n);
}
for (const entry of Object.values(info)) {
const collisions = Object.values(entry.twoByteMap).filter(v => v.length > 1);
entry.collisionCount = collisions.length;
entry.maxCollision = collisions.length ? Math.max(...collisions.map(v => v.length)) : 0;
}
return info;
html += '</table></div>';
return html;
}
function buildCollisionHops(allNodes, bytes) {
const map = {};
for (const n of allNodes) {
const p = n.public_key.slice(0, bytes * 2).toUpperCase();
if (!map[p]) map[p] = { hex: p, count: 0, size: bytes };
map[p].count++;
}
return Object.values(map).filter(h => h.count > 1);
function hashMatrixLegendHtml(labels) {
return `<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center;flex-wrap:wrap">
${labels.map(l => `<span><span class="legend-swatch ${l.cls}"${l.style ? ' style="'+l.style+'"' : ''}></span> ${l.text}</span>`).join('\n')}
</div>`;
}
function renderHashMatrix(topHops, allNodes, bytes, totalNodes) {
bytes = bytes || 1;
totalNodes = totalNodes || allNodes;
function renderHashMatrixFromServer(sizeData, bytes) {
const el = document.getElementById('hashMatrix');
if (!sizeData) { el.innerHTML = '<div class="text-muted">No data</div>'; return; }
const stats = sizeData.stats || {};
const totalNodes = stats.total_nodes || 0;
// 3-byte: show a summary panel instead of a matrix
if (bytes === 3) {
const total = totalNodes.length;
const threeByteNodes = allNodes.filter(n => n.hash_size === 3).length;
const nodesForByte = allNodes.filter(n => n.hash_size === 3 || !n.hash_size);
const prefixMap = {};
for (const n of nodesForByte) {
const p = n.public_key.slice(0, 6).toUpperCase();
if (!prefixMap[p]) prefixMap[p] = 0;
prefixMap[p]++;
}
const uniquePrefixes = Object.keys(prefixMap).length;
const collisions = Object.values(prefixMap).filter(c => c > 1).length;
const spaceSize = 16777216; // 2^24
const pct = uniquePrefixes > 0 ? ((uniquePrefixes / spaceSize) * 100).toFixed(6) : '0';
el.innerHTML = `
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Nodes tracked</div>
<div class="analytics-stat-value">${total.toLocaleString()}</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Using 3-byte ID</div>
<div class="analytics-stat-value">${threeByteNodes.toLocaleString()}</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Prefix space used</div>
<div class="analytics-stat-value" style="font-size:16px">${pct}%</div>
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">of 16.7M possible</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${collisions > 0 ? 'var(--status-red)' : 'var(--border)'}">
<div class="analytics-stat-label">Prefix collisions</div>
<div class="analytics-stat-value" style="color:${collisions > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${collisions}</div>
</div>
</div>
<p class="text-muted" style="margin:0;font-size:0.8em">The 3-byte prefix space (16.7M values) is too large to visualize as a grid.</p>`;
el.innerHTML = hashStatCardsHtml(totalNodes, stats.using_this_size || 0, '3-byte', 16777216, stats.unique_prefixes || 0, stats.collision_count || 0) +
`<p class="text-muted" style="margin:0;font-size:0.8em">The 3-byte prefix space (16.7M values) is too large to visualize as a grid.</p>`;
return;
}
@@ -1207,41 +1176,14 @@
const headerSize = 24;
if (bytes === 1) {
const nodesForByte = allNodes.filter(n => n.hash_size === 1 || !n.hash_size);
const prefixNodes = buildOneBytePrefixMap(nodesForByte);
const oneByteCount = allNodes.filter(n => n.hash_size === 1).length;
const oneUsed = Object.values(prefixNodes).filter(v => v.length > 0).length;
const oneCollisions = Object.values(prefixNodes).filter(v => v.length > 1).length;
const onePct = ((oneUsed / 256) * 100).toFixed(1);
const oneByteCells = sizeData.one_byte_cells || {};
const oneByteCount = stats.using_this_size || 0;
const oneUsed = Object.values(oneByteCells).filter(v => v.length > 0).length;
const oneCollisions = Object.values(oneByteCells).filter(v => v.length > 1).length;
let html = `<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Nodes tracked</div>
<div class="analytics-stat-value">${totalNodes.length.toLocaleString()}</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Using 1-byte ID</div>
<div class="analytics-stat-value">${oneByteCount.toLocaleString()}</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Prefix space used</div>
<div class="analytics-stat-value" style="font-size:16px">${onePct}%</div>
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">of 256 possible</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${oneCollisions > 0 ? 'var(--status-red)' : 'var(--border)'}">
<div class="analytics-stat-label">Prefix collisions</div>
<div class="analytics-stat-value" style="color:${oneCollisions > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${oneCollisions}</div>
</div>
</div>`;
html += `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
html += `<tr><td style="width:${headerSize}px"></td>`;
for (const n of nibbles) html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
html += '</tr>';
for (let hi = 0; hi < 16; hi++) {
html += `<tr><td style="text-align:right;padding-right:4px;font-weight:bold;color:var(--text-muted)">${nibbles[hi]}</td>`;
for (let lo = 0; lo < 16; lo++) {
const hex = nibbles[hi] + nibbles[lo];
const nodes = prefixNodes[hex] || [];
let html = hashStatCardsHtml(totalNodes, oneByteCount, '1-byte', 256, oneUsed, oneCollisions);
html += hashMatrixGridHtml(nibbles, cellSize, headerSize, (hex, cs) => {
const nodes = oneByteCells[hex] || [];
const count = nodes.length;
const repeaterCount = nodes.filter(n => n.role === 'repeater').length;
const isCollision = count >= 2 && repeaterCount >= 2;
@@ -1259,18 +1201,15 @@
: isPossible
? `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">${count} nodes — POSSIBLE CONFLICT</div><div class="hash-matrix-tooltip-nodes">${nodes.slice(0,5).map(nodeLabel).join('')}${nodes.length>5?`<div class="hash-matrix-tooltip-status">+${nodes.length-5} more</div>`:''}</div>`
: `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">${count} nodes — COLLISION</div><div class="hash-matrix-tooltip-nodes">${nodes.slice(0,5).map(nodeLabel).join('')}${nodes.length>5?`<div class="hash-matrix-tooltip-status">+${nodes.length-5} more</div>`:''}</div>`;
html += `<td class="hash-cell ${cellClass}${count ? ' hash-active' : ''}" data-hex="${hex}" data-tip="${tip1.replace(/"/g,'&quot;')}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;${bgStyle}border:1px solid var(--border);cursor:${count ? 'pointer' : 'default'};font-size:11px;font-weight:${count >= 2 ? '700' : '400'}">${hex}</td>`;
}
html += '</tr>';
}
html += '</table></div>';
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:400px;font-size:0.85em"></div></div>
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center;flex-wrap:wrap">
<span><span class="legend-swatch hash-cell-empty" style="border:1px solid var(--border)"></span> Available</span>
<span><span class="legend-swatch hash-cell-taken"></span> One node</span>
<span><span class="legend-swatch hash-cell-possible"></span> Possible conflict</span>
<span><span class="legend-swatch hash-cell-collision" style="background:rgb(220,80,30)"></span> Collision</span>
</div>`;
return `<td class="hash-cell ${cellClass}${count ? ' hash-active' : ''}" data-hex="${hex}" data-tip="${tip1.replace(/"/g,'&quot;')}" style="width:${cs}px;height:${cs}px;text-align:center;${bgStyle}border:1px solid var(--border);cursor:${count ? 'pointer' : 'default'};font-size:11px;font-weight:${count >= 2 ? '700' : '400'}">${hex}</td>`;
});
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:400px;font-size:0.85em"></div></div>`;
html += hashMatrixLegendHtml([
{cls: 'hash-cell-empty', style: 'border:1px solid var(--border)', text: 'Available'},
{cls: 'hash-cell-taken', text: 'One node'},
{cls: 'hash-cell-possible', text: 'Possible conflict'},
{cls: 'hash-cell-collision', style: 'background:rgb(220,80,30)', text: 'Collision'}
]);
el.innerHTML = html;
initMatrixTooltip(el);
@@ -1278,7 +1217,7 @@
el.querySelectorAll('.hash-active').forEach(td => {
td.addEventListener('click', () => {
const hex = td.dataset.hex.toUpperCase();
const matches = prefixNodes[hex] || [];
const matches = oneByteCells[hex] || [];
const detail = document.getElementById('hashDetail');
if (!matches.length) { detail.innerHTML = `<strong class="mono">0x${hex}</strong><br><span class="text-muted">No known nodes</span>`; return; }
detail.innerHTML = `<strong class="mono" style="font-size:1.1em">0x${hex}</strong> — ${matches.length} node${matches.length !== 1 ? 's' : ''}` +
@@ -1293,47 +1232,17 @@
});
} else if (bytes === 2) {
// 2-byte mode: 16×16 grid of first-byte groups
const nodesForByte = allNodes.filter(n => n.hash_size === 2 || !n.hash_size);
const firstByteInfo = buildTwoBytePrefixInfo(nodesForByte);
const twoByteCells = sizeData.two_byte_cells || {};
const twoByteCount = stats.using_this_size || 0;
const uniqueTwoBytePrefixes = stats.unique_prefixes || 0;
const twoCollisions = Object.values(twoByteCells).filter(v => v.collision_count > 0).length;
const twoByteCount = allNodes.filter(n => n.hash_size === 2).length;
const uniqueTwoBytePrefixes = new Set(nodesForByte.map(n => n.public_key.slice(0, 4).toUpperCase())).size;
const twoCollisions = Object.values(firstByteInfo).filter(v => v.collisionCount > 0).length;
const twoPct = ((uniqueTwoBytePrefixes / 65536) * 100).toFixed(3);
let html = `<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Nodes tracked</div>
<div class="analytics-stat-value">${totalNodes.length.toLocaleString()}</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Using 2-byte ID</div>
<div class="analytics-stat-value">${twoByteCount.toLocaleString()}</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Prefix space used</div>
<div class="analytics-stat-value" style="font-size:16px">${twoPct}%</div>
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">${uniqueTwoBytePrefixes} of 65,536 possible</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${twoCollisions > 0 ? 'var(--status-red)' : 'var(--border)'}">
<div class="analytics-stat-label">Prefix collisions</div>
<div class="analytics-stat-value" style="color:${twoCollisions > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${twoCollisions}</div>
</div>
</div>`;
html += `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
html += `<tr><td style="width:${headerSize}px"></td>`;
for (const n of nibbles) html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
html += '</tr>';
for (let hi = 0; hi < 16; hi++) {
html += `<tr><td style="text-align:right;padding-right:4px;font-weight:bold;color:var(--text-muted)">${nibbles[hi]}</td>`;
for (let lo = 0; lo < 16; lo++) {
const hex = nibbles[hi] + nibbles[lo];
const info = firstByteInfo[hex] || { groupNodes: [], maxCollision: 0, collisionCount: 0 };
const nodeCount = info.groupNodes.length;
const maxCol = info.maxCollision;
// Classify worst overlap in group: confirmed collision (2+ repeaters) or possible (null-role involved)
const overlapping = Object.values(info.twoByteMap || {}).filter(v => v.length > 1);
let html = hashStatCardsHtml(totalNodes, twoByteCount, '2-byte', 65536, uniqueTwoBytePrefixes, twoCollisions);
html += hashMatrixGridHtml(nibbles, cellSize, headerSize, (hex, cs) => {
const info = twoByteCells[hex] || { group_nodes: [], max_collision: 0, collision_count: 0, two_byte_map: {} };
const nodeCount = (info.group_nodes || []).length;
const maxCol = info.max_collision || 0;
const overlapping = Object.values(info.two_byte_map || {}).filter(v => v.length > 1);
const hasConfirmed = overlapping.some(ns => ns.filter(n => n.role === 'repeater').length >= 2);
const hasPossible = !hasConfirmed && overlapping.some(ns => ns.length >= 2);
let cellClass2, bgStyle2;
@@ -1344,39 +1253,37 @@
const nodeLabel2 = m => esc(m.name||m.public_key.slice(0,8)) + (!m.role ? ' (?)' : '');
const tip2 = nodeCount === 0
? `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">No nodes in this group</div>`
: info.collisionCount === 0
: (info.collision_count || 0) === 0
? `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">${nodeCount} node${nodeCount>1?'s':''} — no 2-byte collisions</div>`
: `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">${hasConfirmed ? info.collisionCount + ' collision' + (info.collisionCount>1?'s':'') : 'Possible conflict'}</div><div class="hash-matrix-tooltip-nodes">${Object.entries(info.twoByteMap).filter(([,v])=>v.length>1).slice(0,4).map(([p,ns])=>`<div style="font-size:11px;padding:1px 0"><span style="color:${hasConfirmed?'var(--status-red)':'var(--status-yellow)'};font-family:var(--mono);font-weight:700">${p}</span> — ${ns.map(nodeLabel2).join(', ')}</div>`).join('')}</div>`;
html += `<td class="hash-cell ${cellClass2}${nodeCount ? ' hash-active' : ''}" data-hex="${hex}" data-tip="${tip2.replace(/"/g,'&quot;')}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;${bgStyle2}border:1px solid var(--border);cursor:${nodeCount ? 'pointer' : 'default'};font-size:11px;font-weight:${maxCol > 0 ? '700' : '400'}">${hex}</td>`;
}
html += '</tr>';
}
html += '</table></div>';
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:420px;font-size:0.85em"></div></div>
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center;flex-wrap:wrap">
<span><span class="legend-swatch hash-cell-empty" style="border:1px solid var(--border)"></span> No nodes in group</span>
<span><span class="legend-swatch hash-cell-taken"></span> Nodes present, no collision</span>
<span><span class="legend-swatch hash-cell-possible"></span> Possible conflict</span>
<span><span class="legend-swatch hash-cell-collision" style="background:rgb(220,80,30)"></span> Collision</span>
</div>`;
: `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">${hasConfirmed ? (info.collision_count||0) + ' collision' + ((info.collision_count||0)>1?'s':'') : 'Possible conflict'}</div><div class="hash-matrix-tooltip-nodes">${Object.entries(info.two_byte_map||{}).filter(([,v])=>v.length>1).slice(0,4).map(([p,ns])=>`<div style="font-size:11px;padding:1px 0"><span style="color:${hasConfirmed?'var(--status-red)':'var(--status-yellow)'};font-family:var(--mono);font-weight:700">${p}</span> ${ns.map(nodeLabel2).join(', ')}</div>`).join('')}</div>`;
return `<td class="hash-cell ${cellClass2}${nodeCount ? ' hash-active' : ''}" data-hex="${hex}" data-tip="${tip2.replace(/"/g,'&quot;')}" style="width:${cs}px;height:${cs}px;text-align:center;${bgStyle2}border:1px solid var(--border);cursor:${nodeCount ? 'pointer' : 'default'};font-size:11px;font-weight:${maxCol > 0 ? '700' : '400'}">${hex}</td>`;
});
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:420px;font-size:0.85em"></div></div>`;
html += hashMatrixLegendHtml([
{cls: 'hash-cell-empty', style: 'border:1px solid var(--border)', text: 'No nodes in group'},
{cls: 'hash-cell-taken', text: 'Nodes present, no collision'},
{cls: 'hash-cell-possible', text: 'Possible conflict'},
{cls: 'hash-cell-collision', style: 'background:rgb(220,80,30)', text: 'Collision'}
]);
el.innerHTML = html;
el.querySelectorAll('.hash-active').forEach(td => {
td.addEventListener('click', () => {
const hex = td.dataset.hex.toUpperCase();
const info = firstByteInfo[hex];
const info = twoByteCells[hex];
const detail = document.getElementById('hashDetail');
if (!info || !info.groupNodes.length) { detail.innerHTML = ''; return; }
let dhtml = `<strong class="mono" style="font-size:1.1em">0x${hex}__</strong> — ${info.groupNodes.length} node${info.groupNodes.length !== 1 ? 's' : ''} in group`;
if (info.collisionCount === 0) {
if (!info || !(info.group_nodes || []).length) { detail.innerHTML = ''; return; }
const groupNodes = info.group_nodes || [];
let dhtml = `<strong class="mono" style="font-size:1.1em">0x${hex}__</strong> — ${groupNodes.length} node${groupNodes.length !== 1 ? 's' : ''} in group`;
if ((info.collision_count || 0) === 0) {
dhtml += `<div class="text-muted" style="margin-top:6px;font-size:0.85em">✅ No 2-byte collisions in this group</div>`;
dhtml += `<div style="margin-top:8px">${info.groupNodes.map(m => {
dhtml += `<div style="margin-top:8px">${groupNodes.map(m => {
const prefix = m.public_key.slice(0,4).toUpperCase();
return `<div style="padding:2px 0"><code class="mono" style="font-size:0.85em">${prefix}</code> <a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a></div>`;
}).join('')}</div>`;
} else {
dhtml += `<div style="margin-top:8px">`;
for (const [twoHex, nodes] of Object.entries(info.twoByteMap).sort()) {
for (const [twoHex, nodes] of Object.entries(info.two_byte_map || {}).sort()) {
const isCollision = nodes.length > 1;
dhtml += `<div style="margin-bottom:6px;padding:4px 6px;border-radius:4px;background:${isCollision ? 'rgba(220,50,30,0.1)' : 'transparent'};border:1px solid ${isCollision ? 'rgba(220,50,30,0.3)' : 'transparent'}">`;
dhtml += `<code class="mono" style="font-size:0.9em;font-weight:${isCollision?'700':'400'}">${twoHex}</code>${isCollision ? ' <span style="color:#dc2626;font-size:0.75em;font-weight:700">COLLISION</span>' : ''} `;
@@ -1395,106 +1302,65 @@
}
}
async function renderCollisions(topHops, allNodes, bytes) {
bytes = bytes || 1;
function renderCollisionsFromServer(sizeData, bytes) {
const el = document.getElementById('collisionList');
const hopsForSize = topHops.filter(h => h.size === bytes);
if (!sizeData) { el.innerHTML = '<div class="text-muted">No data</div>'; return; }
const collisions = sizeData.collisions || [];
// For 2-byte and 3-byte, scan nodes directly — topHops only reliably covers 1-byte path hops
const hopsToCheck = bytes === 1 ? hopsForSize : buildCollisionHops(allNodes, bytes);
if (!hopsToCheck.length && bytes === 1) {
el.innerHTML = `<div class="text-muted" style="padding:8px">No 1-byte hops observed in recent packets.</div>`;
if (!collisions.length) {
const cleanMsg = bytes === 3
? '✅ No 3-byte prefix collisions detected — all nodes have unique 3-byte prefixes.'
: `✅ No ${bytes}-byte collisions detected`;
el.innerHTML = `<div class="text-muted" style="padding:8px">${cleanMsg}</div>`;
return;
}
try {
const nodes = allNodes;
const collisions = [];
for (const hop of hopsToCheck) {
const prefix = hop.hex.toLowerCase();
const matches = nodes.filter(n => n.public_key.toLowerCase().startsWith(prefix));
if (matches.length > 1) {
// Calculate pairwise distances for classification
const withCoords = matches.filter(m => m.lat && m.lon && !(m.lat === 0 && m.lon === 0));
let maxDistKm = 0;
let classification = 'unknown';
if (withCoords.length >= 2) {
for (let i = 0; i < withCoords.length; i++) {
for (let j = i + 1; j < withCoords.length; j++) {
const dLat = (withCoords[i].lat - withCoords[j].lat) * 111;
const dLon = (withCoords[i].lon - withCoords[j].lon) * 85;
const d = Math.sqrt(dLat * dLat + dLon * dLon);
if (d > maxDistKm) maxDistKm = d;
}
}
if (maxDistKm < 50) classification = 'local';
else if (maxDistKm < 200) classification = 'regional';
else classification = 'distant';
} else if (withCoords.length < 2) {
classification = 'incomplete';
}
collisions.push({ hop: hop.hex, count: hop.count, matches, maxDistKm, classification, withCoords: withCoords.length });
const showAppearances = bytes < 3;
el.innerHTML = `<table class="analytics-table">
<thead><tr>
<th scope="col">Prefix</th>
${showAppearances ? '<th scope="col">Appearances</th>' : ''}
<th scope="col">Max Distance</th>
<th scope="col">Assessment</th>
<th scope="col">Colliding Nodes</th>
</tr></thead>
<tbody>${collisions.map(c => {
let badge, tooltip;
if (c.classification === 'local') {
badge = '<span class="badge" style="background:var(--status-green);color:#fff" title="All nodes within 50km — likely true collision, same RF neighborhood">🏘️ Local</span>';
tooltip = 'Nodes close enough for direct RF — probably genuine prefix collision';
} else if (c.classification === 'regional') {
badge = '<span class="badge" style="background:var(--status-yellow);color:#fff" title="Nodes 50200km apart — edge of LoRa range, could be atmospheric">⚡ Regional</span>';
tooltip = 'At edge of 915MHz range — could indicate atmospheric ducting or hilltop-to-hilltop links';
} else if (c.classification === 'distant') {
badge = '<span class="badge" style="background:var(--status-red);color:#fff" title="Nodes >200km apart — beyond typical 915MHz range">🌐 Distant</span>';
tooltip = 'Beyond typical LoRa range — likely internet bridging, MQTT gateway, or separate mesh networks sharing prefix';
} else {
badge = '<span class="badge" style="background:#6b7280;color:#fff">❓ Unknown</span>';
tooltip = 'Not enough coordinate data to classify';
}
}
if (!collisions.length) {
const cleanMsg = bytes === 3
? '✅ No 3-byte prefix collisions detected — all nodes have unique 3-byte prefixes.'
: `✅ No ${bytes}-byte collisions detected`;
el.innerHTML = `<div class="text-muted" style="padding:8px">${cleanMsg}</div>`;
return;
}
// Sort: local first (most likely to collide), then regional, distant, incomplete
const classOrder = { local: 0, regional: 1, distant: 2, incomplete: 3, unknown: 4 };
collisions.sort((a, b) => classOrder[a.classification] - classOrder[b.classification] || b.count - a.count);
const showAppearances = bytes < 3;
el.innerHTML = `<table class="analytics-table">
<thead><tr>
<th scope="col">Prefix</th>
${showAppearances ? '<th scope="col">Appearances</th>' : ''}
<th scope="col">Max Distance</th>
<th scope="col">Assessment</th>
<th scope="col">Colliding Nodes</th>
</tr></thead>
<tbody>${collisions.map(c => {
let badge, tooltip;
if (c.classification === 'local') {
badge = '<span class="badge" style="background:var(--status-green);color:#fff" title="All nodes within 50km — likely true collision, same RF neighborhood">🏘️ Local</span>';
tooltip = 'Nodes close enough for direct RF — probably genuine prefix collision';
} else if (c.classification === 'regional') {
badge = '<span class="badge" style="background:var(--status-yellow);color:#fff" title="Nodes 50200km apart — edge of LoRa range, could be atmospheric">⚡ Regional</span>';
tooltip = 'At edge of 915MHz range — could indicate atmospheric ducting or hilltop-to-hilltop links';
} else if (c.classification === 'distant') {
badge = '<span class="badge" style="background:var(--status-red);color:#fff" title="Nodes >200km apart — beyond typical 915MHz range">🌐 Distant</span>';
tooltip = 'Beyond typical LoRa range — likely internet bridging, MQTT gateway, or separate mesh networks sharing prefix';
} else {
badge = '<span class="badge" style="background:#6b7280;color:#fff">❓ Unknown</span>';
tooltip = 'Not enough coordinate data to classify';
}
const distStr = c.withCoords >= 2 ? `${Math.round(c.maxDistKm)} km` : '<span class="text-muted">—</span>';
return `<tr>
<td class="mono">${c.hop}</td>
${showAppearances ? `<td>${c.count.toLocaleString()}</td>` : ''}
<td>${distStr}</td>
<td title="${tooltip}">${badge}</td>
<td>${c.matches.map(m => {
const loc = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
? ` <span class="text-muted" style="font-size:0.75em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>`
: ' <span class="text-muted" style="font-size:0.75em">(no coords)</span>';
return `<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a>${loc}`;
}).join('<br>')}</td>
</tr>`;
}).join('')}</tbody>
</table>
<div class="text-muted" style="padding:8px;font-size:0.8em">
<strong>🏘 Local</strong> &lt;50km: true prefix collision, same mesh area &nbsp;
<strong> Regional</strong> 50200km: edge of LoRa range, possible atmospheric propagation &nbsp;
<strong>🌐 Distant</strong> &gt;200km: beyond 915MHz range internet bridge, MQTT gateway, or separate networks
</div>`;
} catch { el.innerHTML = '<div class="text-muted">Failed to load</div>'; }
const nodes = c.nodes || [];
const distStr = c.with_coords >= 2 ? `${Math.round(c.max_dist_km)} km` : '<span class="text-muted">—</span>';
return `<tr>
<td class="mono">${c.prefix}</td>
${showAppearances ? `<td>${(c.appearances || 0).toLocaleString()}</td>` : ''}
<td>${distStr}</td>
<td title="${tooltip}">${badge}</td>
<td>${nodes.map(m => {
const loc = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
? ` <span class="text-muted" style="font-size:0.75em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>`
: ' <span class="text-muted" style="font-size:0.75em">(no coords)</span>';
return `<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a>${loc}`;
}).join('<br>')}</td>
</tr>`;
}).join('')}</tbody>
</table>
<div class="text-muted" style="padding:8px;font-size:0.8em">
<strong>🏘 Local</strong> &lt;50km: true prefix collision, same mesh area &nbsp;
<strong> Regional</strong> 50200km: edge of LoRa range, possible atmospheric propagation &nbsp;
<strong>🌐 Distant</strong> &gt;200km: beyond 915MHz range internet bridge, MQTT gateway, or separate networks
</div>`;
}
async function renderSubpaths(el) {
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">Analyzing route patterns…</div>';
try {
@@ -1942,9 +1808,6 @@ function destroy() { _analyticsData = {}; _channelData = null; }
window._analyticsSaveChannelSort = saveChannelSort;
window._analyticsChannelTbodyHtml = channelTbodyHtml;
window._analyticsChannelTheadHtml = channelTheadHtml;
window._analyticsBuildOneBytePrefixMap = buildOneBytePrefixMap;
window._analyticsBuildTwoBytePrefixInfo = buildTwoBytePrefixInfo;
window._analyticsBuildCollisionHops = buildCollisionHops;
}
registerPage('analytics', { init, destroy });
+76 -3
View File
@@ -9,6 +9,8 @@ const PAYLOAD_COLORS = { 0: 'req', 1: 'response', 2: 'txt-msg', 3: 'ack', 4: 'ad
function routeTypeName(n) { return ROUTE_TYPES[n] || 'UNKNOWN'; }
function payloadTypeName(n) { return PAYLOAD_TYPES[n] || 'UNKNOWN'; }
function payloadTypeColor(n) { return PAYLOAD_COLORS[n] || 'unknown'; }
function isTransportRoute(rt) { return rt === 0 || rt === 3; }
function transportBadge(rt) { return isTransportRoute(rt) ? ' <span class="badge badge-transport" title="' + routeTypeName(rt) + '">T</span>' : ''; }
// --- Utilities ---
const _apiPerf = { calls: 0, totalMs: 0, log: [], cacheHits: 0 };
@@ -405,7 +407,24 @@ function registerPage(name, mod) { pages[name] = mod; }
let currentPage = null;
function closeNav() {
document.querySelector('.nav-links')?.classList.remove('open');
document.body.classList.remove('nav-open');
var btn = document.getElementById('hamburger');
if (btn) btn.setAttribute('aria-expanded', 'false');
closeMoreMenu();
}
function closeMoreMenu() {
var menu = document.getElementById('navMoreMenu');
var btn = document.getElementById('navMoreBtn');
if (menu) menu.classList.remove('open');
if (btn) btn.setAttribute('aria-expanded', 'false');
}
function navigate() {
closeNav();
const hash = location.hash.replace('#/', '') || 'packets';
const route = hash.split('?')[0];
@@ -437,6 +456,13 @@ function navigate() {
document.querySelectorAll('.nav-link[data-route]').forEach(el => {
el.classList.toggle('active', el.dataset.route === basePage);
});
// Update "More" button to show active state if a low-priority page is selected
var moreBtn = document.getElementById('navMoreBtn');
if (moreBtn) {
var moreMenu = document.getElementById('navMoreMenu');
var hasActiveMore = moreMenu && moreMenu.querySelector('.nav-link.active');
moreBtn.classList.toggle('active', !!hasActiveMore);
}
if (currentPage && pages[currentPage]?.destroy) {
pages[currentPage].destroy();
@@ -531,10 +557,57 @@ window.addEventListener('DOMContentLoaded', () => {
// --- Hamburger Menu ---
const hamburger = document.getElementById('hamburger');
const navLinks = document.querySelector('.nav-links');
hamburger.addEventListener('click', () => navLinks.classList.toggle('open'));
// Close menu on nav link click
hamburger.addEventListener('click', () => {
const opening = !navLinks.classList.contains('open');
navLinks.classList.toggle('open');
document.body.classList.toggle('nav-open');
hamburger.setAttribute('aria-expanded', String(opening));
});
navLinks.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', () => navLinks.classList.remove('open'));
link.addEventListener('click', closeNav);
});
// --- "More" dropdown (tablet Priority+ nav) ---
const navMoreBtn = document.getElementById('navMoreBtn');
const navMoreMenu = document.getElementById('navMoreMenu');
if (navMoreBtn && navMoreMenu) {
// Build More menu dynamically from non-priority nav links (DRY)
navMoreMenu.innerHTML = '';
document.querySelectorAll('.nav-links a:not([data-priority="high"])').forEach(function(link) {
var clone = link.cloneNode(true);
clone.setAttribute('role', 'menuitem');
clone.addEventListener('click', closeMoreMenu);
navMoreMenu.appendChild(clone);
});
navMoreBtn.addEventListener('click', (e) => {
e.stopPropagation();
const opening = !navMoreMenu.classList.contains('open');
navMoreMenu.classList.toggle('open');
navMoreBtn.setAttribute('aria-expanded', String(opening));
if (opening) {
var firstLink = navMoreMenu.querySelector('.nav-link');
if (firstLink) firstLink.focus();
}
});
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (navMoreMenu && navMoreMenu.classList.contains('open')) closeMoreMenu();
if (navLinks.classList.contains('open')) closeNav();
}
});
document.addEventListener('click', (e) => {
if (navLinks.classList.contains('open') &&
!navLinks.contains(e.target) &&
!hamburger.contains(e.target)) {
closeNav();
}
if (navMoreMenu && navMoreMenu.classList.contains('open') &&
!navMoreMenu.contains(e.target) &&
!navMoreBtn.contains(e.target)) {
closeMoreMenu();
}
});
// --- Favorites dropdown ---
+37 -36
View File
@@ -22,9 +22,9 @@
<meta name="twitter:title" content="CoreScope">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1774937706">
<link rel="stylesheet" href="home.css?v=1774937706">
<link rel="stylesheet" href="live.css?v=1774937706">
<link rel="stylesheet" href="style.css?v=1775078686">
<link rel="stylesheet" href="home.css?v=1775078686">
<link rel="stylesheet" href="live.css?v=1775078686">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous">
@@ -44,18 +44,22 @@
<span class="live-dot" id="liveDot" title="WebSocket connected" aria-label="WebSocket connected"></span>
</a>
<div class="nav-links">
<a href="#/home" class="nav-link" data-route="home">Home</a>
<a href="#/packets" class="nav-link" data-route="packets">Packets</a>
<a href="#/map" class="nav-link" data-route="map">Map</a>
<a href="#/live" class="nav-link" data-route="live">🔴 Live</a>
<a href="#/home" class="nav-link" data-route="home" data-priority="high">Home</a>
<a href="#/packets" class="nav-link" data-route="packets" data-priority="high">Packets</a>
<a href="#/map" class="nav-link" data-route="map" data-priority="high">Map</a>
<a href="#/live" class="nav-link" data-route="live" data-priority="high">🔴 Live</a>
<a href="#/channels" class="nav-link" data-route="channels">Channels</a>
<a href="#/nodes" class="nav-link" data-route="nodes">Nodes</a>
<a href="#/nodes" class="nav-link" data-route="nodes" data-priority="high">Nodes</a>
<a href="#/traces" class="nav-link" data-route="traces">Traces</a>
<a href="#/observers" class="nav-link" data-route="observers">Observers</a>
<a href="#/analytics" class="nav-link" data-route="analytics">Analytics</a>
<a href="#/perf" class="nav-link" data-route="perf">⚡ Perf</a>
<a href="#/audio-lab" class="nav-link" data-route="audio-lab">🎵 Lab</a>
</div>
<div class="nav-more-wrap">
<button class="nav-btn nav-more-btn" id="navMoreBtn" aria-haspopup="true" aria-expanded="false" aria-controls="navMoreMenu" title="More pages">More ▾</button>
<div class="nav-more-menu" id="navMoreMenu" role="menu"></div>
</div>
</div>
<div class="nav-right">
<div class="nav-stats" id="navStats" title="Live stats"></div>
@@ -81,33 +85,30 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1774937706"></script>
<script src="customize.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774937706"></script>
<script src="hop-resolver.js?v=1774937706"></script>
<script src="hop-display.js?v=1774937706"></script>
<script src="app.js?v=1774937706"></script>
<script src="home.js?v=1774937706"></script>
<script src="packet-filter.js?v=1774937706"></script>
<script src="packets.js?v=1774937706"></script>
<script src="geo-filter-overlay.js?v=1774937706"></script>
<script src="map.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
<script src="roles.js?v=1775078686"></script>
<script src="customize.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1775078686"></script>
<script src="hop-resolver.js?v=1775078686"></script>
<script src="hop-display.js?v=1775078686"></script>
<script src="app.js?v=1775078686"></script>
<script src="home.js?v=1775078686"></script>
<script src="packet-filter.js?v=1775078686"></script>
<script src="packets.js?v=1775078686"></script>
<script src="geo-filter-overlay.js?v=1775078686"></script>
<script src="map.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>
+47 -4
View File
@@ -817,7 +817,48 @@
});
// Geo filter overlay
initGeoFilterOverlay(map, 'liveGeoFilterToggle', 'liveGeoFilterLabel').then(function (layer) { geoFilterLayer = layer; });
(async function () {
try {
const gf = await api('/config/geo-filter', { ttl: 3600 });
if (!gf || !gf.polygon || gf.polygon.length < 3) return;
const geoColor = cssVar('--geo-filter-color') || '#3b82f6';
const latlngs = gf.polygon.map(function (p) { return [p[0], p[1]]; });
const innerPoly = L.polygon(latlngs, {
color: geoColor, weight: 2, opacity: 0.8,
fillColor: geoColor, fillOpacity: 0.08
});
const bufferPoly = gf.bufferKm > 0 ? (function () {
let cLat = 0, cLon = 0;
gf.polygon.forEach(function (p) { cLat += p[0]; cLon += p[1]; });
cLat /= gf.polygon.length; cLon /= gf.polygon.length;
const cosLat = Math.cos(cLat * Math.PI / 180);
const outer = gf.polygon.map(function (p) {
const dLatM = (p[0] - cLat) * 111000;
const dLonM = (p[1] - cLon) * 111000 * cosLat;
const dist = Math.sqrt(dLatM * dLatM + dLonM * dLonM);
if (dist === 0) return [p[0], p[1]];
const scale = (gf.bufferKm * 1000) / dist;
return [p[0] + dLatM * scale / 111000, p[1] + dLonM * scale / (111000 * cosLat)];
});
return L.polygon(outer, {
color: geoColor, weight: 1.5, opacity: 0.4, dashArray: '6 4',
fillColor: geoColor, fillOpacity: 0.04
});
})() : null;
geoFilterLayer = L.layerGroup(bufferPoly ? [bufferPoly, innerPoly] : [innerPoly]);
const label = document.getElementById('liveGeoFilterLabel');
if (label) label.style.display = '';
const el = document.getElementById('liveGeoFilterToggle');
if (el) {
const saved = localStorage.getItem('meshcore-map-geo-filter');
if (saved === 'true') { el.checked = true; geoFilterLayer.addTo(map); }
el.addEventListener('change', function (e) {
localStorage.setItem('meshcore-map-geo-filter', e.target.checked);
if (e.target.checked) { geoFilterLayer.addTo(map); } else { map.removeLayer(geoFilterLayer); }
});
}
} catch (e) { /* no geo filter configured */ }
})();
const matrixToggle = document.getElementById('liveMatrixToggle');
matrixToggle.checked = matrixMode;
@@ -1809,9 +1850,11 @@
function nextHop() {
if (hopIndex >= hopPositions.length) {
activeAnims = Math.max(0, activeAnims - 1);
document.getElementById('liveAnimCount').textContent = activeAnims;
const countEl = document.getElementById('liveAnimCount');
if (countEl) countEl.textContent = activeAnims;
return;
}
if (!animLayer) return;
// Audio hook: notify per-hop callback
if (onHop) try { onHop(hopIndex, hopPositions.length, hopPositions[hopIndex]); } catch (e) {}
const hp = hopPositions[hopIndex];
@@ -1824,11 +1867,11 @@
}).addTo(animLayer);
let pulseUp = true;
const pulseTimer = setInterval(() => {
if (!animLayer.hasLayer(ghost)) { clearInterval(pulseTimer); return; }
if (!animLayer || !animLayer.hasLayer(ghost)) { clearInterval(pulseTimer); return; }
ghost.setStyle({ fillOpacity: pulseUp ? 0.6 : 0.25, opacity: pulseUp ? 0.7 : 0.4 });
pulseUp = !pulseUp;
}, 600);
setTimeout(() => { clearInterval(pulseTimer); if (animLayer.hasLayer(ghost)) animLayer.removeLayer(ghost); }, 3000);
setTimeout(() => { clearInterval(pulseTimer); if (animLayer && animLayer.hasLayer(ghost)) animLayer.removeLayer(ghost); }, 3000);
}
} else {
pulseNode(hp.key, hp.pos, typeName);
+44 -2
View File
@@ -95,7 +95,7 @@
<label for="mcClusters"><input type="checkbox" id="mcClusters"> Show clusters</label>
<label for="mcHeatmap"><input type="checkbox" id="mcHeatmap"> Heat map</label>
<label for="mcHashLabels"><input type="checkbox" id="mcHashLabels"> Hash prefix labels</label>
<label for="mcGeoFilter" id="mcGeoFilterLabel" style="display:none"><input type="checkbox" id="mcGeoFilter"> Geo filter area</label>
<label id="mcGeoFilterLabel" for="mcGeoFilter" style="display:none"><input type="checkbox" id="mcGeoFilter"> Mesh live area</label>
</fieldset>
<fieldset class="mc-section">
<legend class="mc-label">Status</legend>
@@ -228,7 +228,49 @@
});
// Geo filter overlay
initGeoFilterOverlay(map, 'mcGeoFilter', 'mcGeoFilterLabel').then(function (layer) { geoFilterLayer = layer; });
(async function () {
try {
const gf = await api('/config/geo-filter', { ttl: 3600 });
if (!gf || !gf.polygon || gf.polygon.length < 3) return;
const geoColor = getComputedStyle(document.documentElement).getPropertyValue('--geo-filter-color').trim() || '#3b82f6';
const latlngs = gf.polygon.map(function (p) { return [p[0], p[1]]; });
const innerPoly = L.polygon(latlngs, {
color: geoColor, weight: 2, opacity: 0.8,
fillColor: geoColor, fillOpacity: 0.08
});
// Approximate buffer zone — expand each vertex outward from centroid by bufferKm
const bufferPoly = gf.bufferKm > 0 ? (function () {
let cLat = 0, cLon = 0;
gf.polygon.forEach(function (p) { cLat += p[0]; cLon += p[1]; });
cLat /= gf.polygon.length; cLon /= gf.polygon.length;
const cosLat = Math.cos(cLat * Math.PI / 180);
const outer = gf.polygon.map(function (p) {
const dLatM = (p[0] - cLat) * 111000;
const dLonM = (p[1] - cLon) * 111000 * cosLat;
const dist = Math.sqrt(dLatM * dLatM + dLonM * dLonM);
if (dist === 0) return [p[0], p[1]];
const scale = (gf.bufferKm * 1000) / dist;
return [p[0] + dLatM * scale / 111000, p[1] + dLonM * scale / (111000 * cosLat)];
});
return L.polygon(outer, {
color: geoColor, weight: 1.5, opacity: 0.4, dashArray: '6 4',
fillColor: geoColor, fillOpacity: 0.04
});
})() : null;
geoFilterLayer = L.layerGroup(bufferPoly ? [bufferPoly, innerPoly] : [innerPoly]);
const label = document.getElementById('mcGeoFilterLabel');
if (label) label.style.display = '';
const el = document.getElementById('mcGeoFilter');
if (el) {
const saved = localStorage.getItem('meshcore-map-geo-filter');
if (saved === 'true') { el.checked = true; geoFilterLayer.addTo(map); }
el.addEventListener('change', function (e) {
localStorage.setItem('meshcore-map-geo-filter', e.target.checked);
if (e.target.checked) { geoFilterLayer.addTo(map); } else { map.removeLayer(geoFilterLayer); }
});
}
} catch (e) { /* no geo filter configured */ }
})();
// WS for live advert updates
wsHandler = debouncedOnWS(function (msgs) {
+299 -108
View File
@@ -24,8 +24,11 @@
let regionMap = {};
const TYPE_NAMES = { 0:'Request', 1:'Response', 2:'Direct Msg', 3:'ACK', 4:'Advert', 5:'Channel Msg', 7:'Anon Req', 8:'Path', 9:'Trace', 11:'Control' };
function typeName(t) { return TYPE_NAMES[t] ?? `Type ${t}`; }
const isMobile = window.innerWidth <= 1024;
const PACKET_LIMIT = isMobile ? 1000 : 50000;
let savedTimeWindowMin = Number(localStorage.getItem('meshcore-time-window'));
if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin < 0) savedTimeWindowMin = 15;
if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin <= 0) savedTimeWindowMin = 15;
if (isMobile && savedTimeWindowMin > 180) savedTimeWindowMin = 15;
let totalCount = 0;
let expandedHashes = new Set();
let hopNameCache = {};
@@ -34,6 +37,19 @@
const PANEL_WIDTH_KEY = 'meshcore-panel-width';
const PANEL_CLOSE_HTML = '<button class="panel-close-btn" title="Close detail pane (Esc)">✕</button>';
// --- Virtual scroll state ---
const VSCROLL_ROW_HEIGHT = 36; // estimated row height in px
const VSCROLL_BUFFER = 30; // extra rows above/below viewport
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 _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 _observerFilterSet = null; // cached Set from filters.observer, hoisted above loops (#427)
function closeDetailPanel() {
var panel = document.getElementById('pktRight');
if (panel) {
@@ -321,6 +337,13 @@
// Check if new packets pass current filters
const filtered = newPkts.filter(p => {
// Respect time window filter — drop packets outside the selected window
const windowMin = savedTimeWindowMin;
if (windowMin > 0) {
const cutoff = new Date(Date.now() - windowMin * 60000).toISOString();
const pktTime = p.latest || p.timestamp || p.first_seen;
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.hash && p.hash !== filters.hash) return false;
@@ -386,7 +409,9 @@
packets = filtered.concat(packets);
}
totalCount += filtered.length;
renderTableRows();
// Debounce WS-triggered renders to avoid rapid full rebuilds
clearTimeout(_wsRenderTimer);
_wsRenderTimer = setTimeout(function () { renderTableRows(); }, 200);
});
});
}
@@ -394,6 +419,14 @@
function destroy() {
if (wsHandler) offWS(wsHandler);
wsHandler = null;
detachVScrollListener();
clearTimeout(_wsRenderTimer);
_displayPackets = [];
_rowCounts = [];
_cumulativeOffsetsCache = null;
_observerFilterSet = null;
_lastVisibleStart = -1;
_lastVisibleEnd = -1;
if (_docActionHandler) { document.removeEventListener('click', _docActionHandler); _docActionHandler = null; }
if (_docMenuCloseHandler) { document.removeEventListener('click', _docMenuCloseHandler); _docMenuCloseHandler = null; }
if (_docColMenuCloseHandler) { document.removeEventListener('click', _docColMenuCloseHandler); _docColMenuCloseHandler = null; }
@@ -429,7 +462,7 @@
const since = new Date(Date.now() - windowMin * 60000).toISOString();
params.set('since', since);
}
params.set('limit', '50000');
params.set('limit', String(PACKET_LIMIT));
const regionParam = RegionFilter.getRegionParam();
if (regionParam) params.set('region', regionParam);
if (filters.hash) params.set('hash', filters.hash);
@@ -568,10 +601,10 @@
<option value="30">Last 30 min</option>
<option value="60">Last 1 hour</option>
<option value="180">Last 3 hours</option>
<option value="360">Last 6 hours</option>
<option value="720">Last 12 hours</option>
<option value="1440">Last 24 hours</option>
<option value="0">All time</option>
<option value="360"${isMobile ? ' disabled title="Disabled on mobile to prevent browser crashes"' : ''}>Last 6 hours</option>
<option value="720"${isMobile ? ' disabled title="Disabled on mobile to prevent browser crashes"' : ''}>Last 12 hours</option>
<option value="1440"${isMobile ? ' disabled title="Disabled on mobile to prevent browser crashes"' : ''}>Last 24 hours</option>
${isMobile ? '' : '<option value="0">All time</option>'}
</select>
</div>
<div class="filter-group">
@@ -595,6 +628,7 @@
<table class="data-table" id="pktTable">
<thead><tr>
<th scope="col"></th><th scope="col" class="col-region">Region</th><th scope="col" class="col-time">Time</th><th scope="col" class="col-hash">Hash</th><th scope="col" class="col-size">Size</th>
<th scope="col" class="col-hashsize">HB</th>
<th scope="col" class="col-type">Type</th><th scope="col" class="col-observer">Observer</th><th scope="col" class="col-path">Path</th><th scope="col" class="col-rpt">Rpt</th><th scope="col" class="col-details">Details</th>
</tr></thead>
<tbody id="pktBody"></tbody>
@@ -751,7 +785,7 @@
fTimeWindow.value = String(savedTimeWindowMin);
fTimeWindow.addEventListener('change', () => {
savedTimeWindowMin = Number(fTimeWindow.value);
if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin < 0) savedTimeWindowMin = 15;
if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin <= 0) savedTimeWindowMin = 15;
localStorage.setItem('meshcore-time-window', fTimeWindow.value);
loadPackets();
});
@@ -814,8 +848,8 @@
{ key: 'rpt', label: 'Rpt' },
{ key: 'details', label: 'Details' },
];
const isMobile = window.innerWidth <= 640;
const defaultHidden = isMobile ? ['region', 'hash', 'observer', 'path', 'rpt', 'size'] : ['region'];
const isNarrow = window.innerWidth <= 640;
const defaultHidden = isNarrow ? ['region', 'hash', 'observer', 'path', 'rpt', 'size'] : ['region'];
let visibleCols;
try {
visibleCols = JSON.parse(localStorage.getItem('packets-visible-cols'));
@@ -977,6 +1011,234 @@
makeColumnsResizable('#pktTable', 'meshcore-pkt-col-widths');
}
// Build HTML for a single grouped packet row
function buildGroupRowHtml(p) {
const isExpanded = expandedHashes.has(p.hash);
let headerObserverId = p.observer_id;
let headerPathJson = p.path_json;
if (_observerFilterSet && p._children?.length) {
const match = p._children.find(c => _observerFilterSet.has(String(c.observer_id)));
if (match) {
headerObserverId = match.observer_id;
headerPathJson = match.path_json;
}
}
const groupRegion = headerObserverId ? (observers.find(o => o.id === headerObserverId)?.iata || '') : '';
let groupPath = [];
try { groupPath = JSON.parse(headerPathJson || '[]'); } catch {}
const groupPathStr = renderPath(groupPath, headerObserverId);
const groupTypeName = payloadTypeName(p.payload_type);
const groupTypeClass = payloadTypeColor(p.payload_type);
const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
const groupHashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
const isSingle = p.count <= 1;
let html = `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${isSingle ? 'select-hash' : 'toggle-select'}" data-value="${p.hash}" tabindex="0" role="row">
<td style="width:28px;text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
<td class="col-region">${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
<td class="col-time">${renderTimestampCell(p.latest)}</td>
<td class="mono col-hash">${truncate(p.hash || '—', 8)}</td>
<td class="col-size">${groupSize ? groupSize + 'B' : '—'}</td>
<td class="col-hashsize mono">${groupHashBytes}</td>
<td class="col-type">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>${transportBadge(p.route_type)}` : '—'}</td>
<td class="col-observer">${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
<td class="col-rpt">${p.observation_count > 1 ? '<span class="badge badge-obs" title="Seen ' + p.observation_count + ' times">👁 ' + p.observation_count + '</span>' : (isSingle ? '' : p.count)}</td>
<td class="col-details">${getDetailPreview((() => { try { return JSON.parse(p.decoded_json || '{}'); } catch { return {}; } })())}</td>
</tr>`;
if (isExpanded && p._children) {
let visibleChildren = p._children;
if (_observerFilterSet) {
visibleChildren = visibleChildren.filter(c => _observerFilterSet.has(String(c.observer_id)));
}
for (const c of visibleChildren) {
const typeName = payloadTypeName(c.payload_type);
const typeClass = payloadTypeColor(c.payload_type);
const size = c.raw_hex ? Math.floor(c.raw_hex.length / 2) : 0;
const childHashBytes = ((parseInt(c.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
const childRegion = c.observer_id ? (observers.find(o => o.id === c.observer_id)?.iata || '') : '';
let childPath = [];
try { childPath = JSON.parse(c.path_json || '[]'); } catch {}
const childPathStr = renderPath(childPath, c.observer_id);
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" tabindex="0" role="row">
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : ''}</td>
<td class="col-time">${renderTimestampCell(c.timestamp)}</td>
<td class="mono col-hash">${truncate(c.hash || '', 8)}</td>
<td class="col-size">${size}B</td>
<td class="col-hashsize mono">${childHashBytes}</td>
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(c.route_type)}</td>
<td class="col-observer">${truncate(obsName(c.observer_id), 16)}</td>
<td class="col-path"><span class="path-hops">${childPathStr}</span></td>
<td class="col-rpt"></td>
<td class="col-details">${getDetailPreview((() => { try { return JSON.parse(c.decoded_json || '{}'); } catch { return {}; } })())}</td>
</tr>`;
}
}
return html;
}
// Build HTML for a single flat (ungrouped) packet row
function buildFlatRowHtml(p) {
let decoded, pathHops = [];
try { decoded = JSON.parse(p.decoded_json || '{}'); } catch {}
try { pathHops = JSON.parse(p.path_json || '[]') || []; } catch {}
const region = p.observer_id ? (observers.find(o => o.id === p.observer_id)?.iata || '') : '';
const typeName = payloadTypeName(p.payload_type);
const typeClass = payloadTypeColor(p.payload_type);
const size = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
const hashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
const pathStr = renderPath(pathHops, p.observer_id);
const detail = getDetailPreview(decoded);
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}">
<td></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : ''}</td>
<td class="col-time">${renderTimestampCell(p.timestamp)}</td>
<td class="mono col-hash">${truncate(p.hash || String(p.id), 8)}</td>
<td class="col-size">${size}B</td>
<td class="col-hashsize mono">${hashBytes}</td>
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(p.route_type)}</td>
<td class="col-observer">${truncate(obsName(p.observer_id), 16)}</td>
<td class="col-path"><span class="path-hops">${pathStr}</span></td>
<td class="col-rpt"></td>
<td class="col-details">${detail}</td>
</tr>`;
}
// 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) {
if (!_displayGrouped) return 1;
if (!expandedHashes.has(p.hash) || !p._children) return 1;
let childCount = p._children.length;
if (_observerFilterSet) {
childCount = p._children.filter(c => _observerFilterSet.has(String(c.observer_id))).length;
}
return 1 + childCount;
}
// Get the column count from the thead (dynamic, avoids hardcoded colspan — #426)
function _getColCount() {
const thead = document.querySelector('#pktLeft thead tr');
return thead ? thead.children.length : 11;
}
// Compute cumulative DOM row offsets from per-entry row counts.
// Returns array where cumulativeOffsets[i] = total <tr> rows before entry i.
function _cumulativeRowOffsets() {
if (_cumulativeOffsetsCache) return _cumulativeOffsetsCache;
const offsets = new Array(_rowCounts.length + 1);
offsets[0] = 0;
for (let i = 0; i < _rowCounts.length; i++) {
offsets[i + 1] = offsets[i] + _rowCounts[i];
}
_cumulativeOffsetsCache = offsets;
return offsets;
return offsets;
}
function renderVisibleRows() {
const tbody = document.getElementById('pktBody');
if (!tbody || !_displayPackets.length) return;
const scrollContainer = document.getElementById('pktLeft');
if (!scrollContainer) return;
// Compute total DOM rows accounting for expanded groups
const offsets = _cumulativeRowOffsets();
const totalDomRows = offsets[offsets.length - 1];
const totalHeight = totalDomRows * VSCROLL_ROW_HEIGHT;
const colCount = _getColCount();
// Get or create spacer elements
let topSpacer = document.getElementById('vscroll-top');
let bottomSpacer = document.getElementById('vscroll-bottom');
if (!topSpacer) {
topSpacer = document.createElement('tr');
topSpacer.id = 'vscroll-top';
topSpacer.innerHTML = '<td colspan="' + colCount + '" style="padding:0;border:0"></td>';
}
if (!bottomSpacer) {
bottomSpacer = document.createElement('tr');
bottomSpacer.id = 'vscroll-bottom';
bottomSpacer.innerHTML = '<td colspan="' + colCount + '" style="padding:0;border:0"></td>';
}
// Calculate visible range based on scroll position
const scrollTop = scrollContainer.scrollTop;
const viewportHeight = scrollContainer.clientHeight;
// Account for thead height (~40px)
const theadHeight = 40;
const adjustedScrollTop = Math.max(0, scrollTop - theadHeight);
// Find the first entry whose cumulative row offset covers the scroll position
const firstDomRow = Math.floor(adjustedScrollTop / VSCROLL_ROW_HEIGHT);
const visibleDomCount = Math.ceil(viewportHeight / VSCROLL_ROW_HEIGHT);
// Binary search for entry index containing firstDomRow
let lo = 0, hi = _displayPackets.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (offsets[mid + 1] <= firstDomRow) lo = mid + 1;
else hi = mid;
}
const firstEntry = lo;
// Find entry index covering last visible DOM row
const lastDomRow = firstDomRow + visibleDomCount;
lo = firstEntry; hi = _displayPackets.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (offsets[mid + 1] <= lastDomRow) lo = mid + 1;
else hi = mid;
}
const lastEntry = Math.min(lo + 1, _displayPackets.length);
const startIdx = Math.max(0, firstEntry - VSCROLL_BUFFER);
const endIdx = Math.min(_displayPackets.length, lastEntry + VSCROLL_BUFFER);
// Skip DOM rebuild if visible range hasn't changed
if (startIdx === _lastVisibleStart && endIdx === _lastVisibleEnd) return;
_lastVisibleStart = startIdx;
_lastVisibleEnd = endIdx;
// Compute padding using cumulative row counts
const topPad = offsets[startIdx] * VSCROLL_ROW_HEIGHT;
const bottomPad = (totalDomRows - offsets[endIdx]) * VSCROLL_ROW_HEIGHT;
topSpacer.firstChild.style.height = topPad + 'px';
bottomSpacer.firstChild.style.height = bottomPad + 'px';
// LAZY ROW GENERATION: only build HTML for the visible slice (#422)
const builder = _displayGrouped ? buildGroupRowHtml : buildFlatRowHtml;
const visibleSlice = _displayPackets.slice(startIdx, endIdx);
const visibleHtml = visibleSlice.map(p => builder(p)).join('');
tbody.innerHTML = '';
tbody.appendChild(topSpacer);
tbody.insertAdjacentHTML('beforeend', visibleHtml);
tbody.appendChild(bottomSpacer);
}
// Attach/detach scroll listener for virtual scrolling
function attachVScrollListener() {
const scrollContainer = document.getElementById('pktLeft');
if (!scrollContainer) return;
if (_vsScrollHandler) return; // already attached
let scrollRaf = null;
_vsScrollHandler = function () {
if (scrollRaf) return;
scrollRaf = requestAnimationFrame(function () {
scrollRaf = null;
renderVisibleRows();
});
};
scrollContainer.addEventListener('scroll', _vsScrollHandler, { passive: true });
}
function detachVScrollListener() {
if (!_vsScrollHandler) return;
const scrollContainer = document.getElementById('pktLeft');
if (scrollContainer) scrollContainer.removeEventListener('scroll', _vsScrollHandler);
_vsScrollHandler = null;
}
async function renderTableRows() {
const tbody = document.getElementById('pktBody');
if (!tbody) return;
@@ -986,7 +1248,7 @@
const groupBtn = document.getElementById('fGroup');
if (groupBtn) groupBtn.classList.toggle('active', groupByHash);
// Filter to claimed/favorited nodes if toggle is on — use server-side multi-node lookup
// Filter to claimed/favorited nodes — pure client-side filter (no server round-trip)
let displayPackets = packets;
if (filters.myNodes) {
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
@@ -994,10 +1256,10 @@
const favs = getFavorites();
const allKeys = [...new Set([...myKeys, ...favs])];
if (allKeys.length > 0) {
try {
const myData = await api('/packets?nodes=' + allKeys.join(',') + '&limit=500');
displayPackets = myData.packets || [];
} catch { displayPackets = []; }
displayPackets = displayPackets.filter(p => {
const dj = p.decoded_json || '';
return allKeys.some(k => dj.includes(k));
});
} else {
displayPackets = [];
}
@@ -1029,102 +1291,31 @@
if (countEl) countEl.textContent = `(${displayPackets.length})`;
if (!displayPackets.length) {
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-muted" style="padding:24px">' + (filters.myNodes ? 'No packets from your claimed/favorited nodes' : 'No packets found') + '</td></tr>';
_displayPackets = [];
_rowCounts = [];
_cumulativeOffsetsCache = null;
_observerFilterSet = null;
_lastVisibleStart = -1;
_lastVisibleEnd = -1;
detachVScrollListener();
const colCount = _getColCount();
tbody.innerHTML = '<tr><td colspan="' + colCount + '" class="text-center text-muted" style="padding:24px">' + (filters.myNodes ? 'No packets from your claimed/favorited nodes' : 'No packets found') + '</td></tr>';
return;
}
if (groupByHash) {
let html = '';
for (const p of displayPackets) {
const isExpanded = expandedHashes.has(p.hash);
// When observer filter is active, use first matching child's data for header
let headerObserverId = p.observer_id;
let headerPathJson = p.path_json;
if (filters.observer && p._children?.length) {
const obsIds = new Set(filters.observer.split(','));
const match = p._children.find(c => obsIds.has(String(c.observer_id)));
if (match) {
headerObserverId = match.observer_id;
headerPathJson = match.path_json;
}
}
const groupRegion = headerObserverId ? (observers.find(o => o.id === headerObserverId)?.iata || '') : '';
let groupPath = [];
try { groupPath = JSON.parse(headerPathJson || '[]'); } catch {}
const groupPathStr = renderPath(groupPath, headerObserverId);
const groupTypeName = payloadTypeName(p.payload_type);
const groupTypeClass = payloadTypeColor(p.payload_type);
const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
const isSingle = p.count <= 1;
html += `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${isSingle ? 'select-hash' : 'toggle-select'}" data-value="${p.hash}" tabindex="0" role="row">
<td style="width:28px;text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
<td class="col-region">${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
<td class="col-time">${renderTimestampCell(p.latest)}</td>
<td class="mono col-hash">${truncate(p.hash || '—', 8)}</td>
<td class="col-size">${groupSize ? groupSize + 'B' : '—'}</td>
<td class="col-type">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>` : '—'}</td>
<td class="col-observer">${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
<td class="col-rpt">${p.observation_count > 1 ? '<span class="badge badge-obs" title="Seen ' + p.observation_count + ' times">👁 ' + p.observation_count + '</span>' : (isSingle ? '' : p.count)}</td>
<td class="col-details">${getDetailPreview((() => { try { return JSON.parse(p.decoded_json || '{}'); } catch { return {}; } })())}</td>
</tr>`;
// Child rows (loaded async when expanded)
if (isExpanded && p._children) {
let visibleChildren = p._children;
// Filter children by selected observers
if (filters.observer) {
const obsSet = new Set(filters.observer.split(','));
visibleChildren = visibleChildren.filter(c => obsSet.has(String(c.observer_id)));
}
for (const c of visibleChildren) {
const typeName = payloadTypeName(c.payload_type);
const typeClass = payloadTypeColor(c.payload_type);
const size = c.raw_hex ? Math.floor(c.raw_hex.length / 2) : 0;
const childRegion = c.observer_id ? (observers.find(o => o.id === c.observer_id)?.iata || '') : '';
let childPath = [];
try { childPath = JSON.parse(c.path_json || '[]'); } catch {}
const childPathStr = renderPath(childPath, c.observer_id);
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" tabindex="0" role="row">
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : ''}</td>
<td class="col-time">${renderTimestampCell(c.timestamp)}</td>
<td class="mono col-hash">${truncate(c.hash || '', 8)}</td>
<td class="col-size">${size}B</td>
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span></td>
<td class="col-observer">${truncate(obsName(c.observer_id), 16)}</td>
<td class="col-path"><span class="path-hops">${childPathStr}</span></td>
<td class="col-rpt"></td>
<td class="col-details">${getDetailPreview((() => { try { return JSON.parse(c.decoded_json); } catch { return {}; } })())}</td>
</tr>`;
}
}
}
tbody.innerHTML = html;
return;
}
// Lazy virtual scroll: store display packets and row counts, but do NOT
// pre-generate HTML strings. HTML is built on-demand in renderVisibleRows()
// for only the visible slice + buffer (#422).
_lastVisibleStart = -1;
_lastVisibleEnd = -1;
_displayPackets = displayPackets;
_displayGrouped = groupByHash;
_observerFilterSet = filters.observer ? new Set(filters.observer.split(',')) : null;
_rowCounts = displayPackets.map(p => _getRowCount(p));
_cumulativeOffsetsCache = null;
tbody.innerHTML = displayPackets.map(p => {
let decoded, pathHops = [];
try { decoded = JSON.parse(p.decoded_json); } catch {}
try { pathHops = JSON.parse(p.path_json || '[]'); } catch {}
const region = p.observer_id ? (observers.find(o => o.id === p.observer_id)?.iata || '') : '';
const typeName = payloadTypeName(p.payload_type);
const typeClass = payloadTypeColor(p.payload_type);
const size = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
const pathStr = renderPath(pathHops, p.observer_id); const detail = getDetailPreview(decoded);
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}">
<td></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : ''}</td>
<td class="col-time">${renderTimestampCell(p.timestamp)}</td>
<td class="mono col-hash">${truncate(p.hash || String(p.id), 8)}</td>
<td class="col-size">${size}B</td>
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span></td>
<td class="col-observer">${truncate(obsName(p.observer_id), 16)}</td>
<td class="col-path"><span class="path-hops">${pathStr}</span></td>
<td class="col-rpt"></td>
<td class="col-details">${detail}</td>
</tr>`;
}).join('');
attachVScrollListener();
renderVisibleRows();
}
function getDetailPreview(decoded) {
@@ -1229,7 +1420,7 @@
let decoded;
try { decoded = JSON.parse(pkt.decoded_json); } catch { decoded = {}; }
let pathHops;
try { pathHops = JSON.parse(pkt.path_json || '[]'); } catch { pathHops = []; }
try { pathHops = JSON.parse(pkt.path_json || '[]') || []; } catch { pathHops = []; }
// Resolve sender GPS — from packet directly, or from known node in DB
let senderLat = decoded.lat != null ? decoded.lat : (decoded.latitude || null);
+55 -8
View File
@@ -5,7 +5,9 @@
--nav-bg2: #1a1a2e;
--nav-text: #ffffff;
--nav-text-muted: #cbd5e1;
--nav-active-bg: rgba(74, 158, 255, 0.15);
--accent: #4a9eff;
--geo-filter-color: #3b82f6;
--status-green: #22c55e;
--status-yellow: #eab308;
--status-red: #ef4444;
@@ -127,7 +129,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
.nav-link.active {
color: var(--nav-text);
border-bottom-color: transparent;
background: rgba(74, 158, 255, 0.15);
background: var(--nav-active-bg);
border-radius: 6px;
margin: 4px 0;
padding: 10px 12px;
@@ -297,6 +299,13 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
font-size: 10px; font-weight: 700; font-family: var(--mono);
background: var(--nav-bg); color: var(--nav-text); letter-spacing: .5px;
}
/* TODO: expose --transport-badge-bg/fg in customizer THEME_CSS_MAP (tracked in future milestone) */
.badge-transport {
display: inline-block; padding: 1px 5px; border-radius: 4px;
font-size: 9px; font-weight: 700; font-family: var(--mono);
background: var(--transport-badge-bg, #f59e0b20); color: var(--transport-badge-fg, #d97706);
letter-spacing: .5px; vertical-align: middle;
}
.badge-obs {
display: inline-block; padding: 1px 6px; border-radius: 10px;
font-size: 10px; font-weight: 600;
@@ -613,7 +622,10 @@ button.ch-item.selected { background: var(--selected-bg); }
.node-detail { padding: 4px 0; }
.node-detail-name { font-size: 20px; font-weight: 700; margin: 12px 0 4px; }
.node-detail-role { margin-bottom: 12px; }
.node-detail-section { margin-bottom: 16px; }
.node-detail-section {
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 8px; padding: 12px; margin-bottom: 8px;
}
.node-detail-section h4 {
font-size: 12px; text-transform: uppercase; letter-spacing: .5px;
color: var(--text-muted); margin-bottom: 8px; padding-bottom: 4px;
@@ -826,6 +838,22 @@ button.ch-item.selected { background: var(--selected-bg); }
/* === Hamburger (hidden on desktop) === */
.hamburger { display: none; }
/* "More" button (hidden on desktop) */
.nav-more-wrap { display: none; position: relative; }
.nav-more-btn { display: inline-flex; }
.nav-more-menu {
display: none; position: absolute; top: calc(var(--top-nav-h, 52px) - 4px); right: 0;
background: var(--nav-bg); border: 1px solid var(--border); border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); flex-direction: column;
min-width: 160px; padding: 4px 0; z-index: 1200;
}
.nav-more-menu.open { display: flex; }
.nav-more-menu .nav-link {
padding: 10px 16px; border-bottom: none; border-radius: 0; margin: 0;
white-space: nowrap;
}
.nav-more-menu .nav-link:hover { background: var(--nav-bg2); color: var(--nav-text); }
.nav-more-menu .nav-link.active { background: var(--nav-active-bg); }
/* Ensure nav stays above Leaflet map */
.nav-links.open { z-index: 1100; }
#map-wrap .leaflet-container { z-index: 1; }
@@ -840,19 +868,37 @@ button.ch-item.selected { background: var(--selected-bg); }
.map-controls { width: 180px; font-size: 12px; }
}
/* === Responsive — Mobile (≤640px) === */
@media (max-width: 640px) {
/* Nav: hamburger + collapse */
/* === Responsive — Tablet Priority+ nav (7681023px) === */
@media (min-width: 768px) and (max-width: 1023px) {
.nav-links { display: flex !important; flex-direction: row; gap: 2px; }
.nav-links a:not([data-priority="high"]) { display: none; }
.nav-more-wrap { display: flex; align-items: center; }
.hamburger { display: none; }
.nav-link { padding: 14px 8px; font-size: 13px; }
.nav-links a[data-priority="high"] { order: -1; }
.nav-link.active { background: var(--nav-active-bg); border-radius: 6px; margin: 4px 0; padding: 10px 8px; }
}
/* === Responsive — Hamburger nav (<768px) === */
@media (max-width: 767px) {
.hamburger { display: inline-flex; }
.nav-more-wrap { display: none !important; }
.nav-links {
display: none; position: absolute; top: 52px; left: 0; right: 0;
background: var(--nav-bg); flex-direction: column; padding: 8px 0;
box-shadow: 0 8px 24px rgba(0,0,0,.4); z-index: 99;
box-shadow: 0 8px 24px rgba(0,0,0,.4); z-index: 1100;
max-height: calc(100dvh - 52px); overflow-y: auto;
}
.nav-links a:not([data-priority="high"]) { display: flex; }
.nav-links.open { display: flex; }
.nav-link { padding: 12px 20px; border-bottom: none; }
.nav-link.active { background: rgba(74,158,255,0.15); border-radius: 0; margin: 0; padding: 12px 20px; }
.nav-link.active { background: var(--nav-active-bg); border-radius: 0; margin: 0; padding: 12px 20px; }
.nav-left { gap: 12px; }
body.nav-open { overflow: hidden; }
}
/* === Responsive — Mobile (≤640px) === */
@media (max-width: 640px) {
.brand-text { display: none; }
.nav-right { gap: 4px; }
@@ -1224,7 +1270,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
/* Hide low-value columns on mobile */
@media (max-width: 640px) {
.col-region, .col-rpt, .col-size, .col-pubkey { display: none; }
.col-region, .col-rpt, .col-size, .col-hashsize, .col-pubkey { display: none; }
}
/* Clickable hop links */
@@ -1370,6 +1416,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
.hide-col-observer .col-observer,
.hide-col-path .col-path,
.hide-col-rpt .col-rpt,
.hide-col-hashsize .col-hashsize,
.hide-col-details .col-details { display: none; }
/* === Home page fixes === */
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
Delete nodes from the database that fall outside the configured geo_filter polygon + bufferKm.
Nodes with no GPS coordinates are always kept.
Usage:
python3 prune-nodes-outside-geo-filter.py [db_path] [--config config.json] [--dry-run]
db_path Path to meshcore.db (default: /app/data/meshcore.db)
--config PATH Path to config.json (default: /app/config.json)
--dry-run Show what would be deleted without making any changes
"""
import sqlite3
import math
import sys
import json
import os
def point_in_polygon(lat, lon, polygon):
"""Ray-casting algorithm."""
inside = False
n = len(polygon)
j = n - 1
for i in range(n):
yi, xi = polygon[i] # lat, lon
yj, xj = polygon[j]
if ((yi > lat) != (yj > lat)) and (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi):
inside = not inside
j = i
return inside
def dist_to_segment_km(lat, lon, a, b):
"""Approximate distance (km) from point to line segment, using flat-earth projection."""
lat1, lon1 = a
lat2, lon2 = b
mid_lat = (lat1 + lat2) / 2.0
cos_lat = math.cos(math.radians(mid_lat))
km_per_deg_lat = 111.0
km_per_deg_lon = 111.0 * cos_lat
# Translate so point is at origin
ax = (lon1 - lon) * km_per_deg_lon
ay = (lat1 - lat) * km_per_deg_lat
bx = (lon2 - lon) * km_per_deg_lon
by = (lat2 - lat) * km_per_deg_lat
abx, aby = bx - ax, by - ay
ab_sq = abx * abx + aby * aby
if ab_sq == 0:
return math.sqrt(ax * ax + ay * ay)
t = max(0.0, min(1.0, -(ax * abx + ay * aby) / ab_sq))
px = ax + t * abx
py = ay + t * aby
return math.sqrt(px * px + py * py)
def node_passes_filter(lat, lon, polygon, buffer_km):
"""Return True if the node should be kept."""
if lat is None or lon is None:
return True
if lat == 0.0 and lon == 0.0:
return True # no GPS fix
if point_in_polygon(lat, lon, polygon):
return True
if buffer_km > 0:
n = len(polygon)
for i in range(n):
j = (i + 1) % n
if dist_to_segment_km(lat, lon, polygon[i], polygon[j]) <= buffer_km:
return True
return False
def load_geo_filter(config_path):
"""Load polygon and bufferKm from config.json geo_filter section."""
if not os.path.exists(config_path):
print(f"ERROR: config not found at {config_path}")
sys.exit(1)
with open(config_path) as f:
cfg = json.load(f)
gf = cfg.get('geo_filter')
if not gf:
print("ERROR: no geo_filter section found in config.json")
sys.exit(1)
polygon = gf.get('polygon', [])
if len(polygon) < 3:
print("ERROR: geo_filter.polygon must have at least 3 points")
sys.exit(1)
buffer_km = gf.get('bufferKm', 0.0)
print(f"Loaded geo_filter from {config_path}: {len(polygon)} points, bufferKm={buffer_km}")
return polygon, buffer_km
def main():
args = sys.argv[1:]
dry_run = '--dry-run' in args
args = [a for a in args if a != '--dry-run']
config_path = '/app/config.json'
if '--config' in args:
idx = args.index('--config')
config_path = args[idx + 1]
args = args[:idx] + args[idx + 2:]
db_path = args[0] if args else '/app/data/meshcore.db'
polygon, buffer_km = load_geo_filter(config_path)
if not os.path.exists(db_path):
print(f"ERROR: database not found at {db_path}")
sys.exit(1)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute('SELECT public_key, name, lat, lon FROM nodes ORDER BY name')
nodes = cur.fetchall()
keep, remove = [], []
for row in nodes:
lat = row['lat']
lon = row['lon']
if node_passes_filter(lat, lon, polygon, buffer_km):
keep.append(row)
else:
remove.append(row)
print(f"Total nodes in DB : {len(nodes)}")
print(f"Nodes to keep : {len(keep)}")
print(f"Nodes to delete : {len(remove)}")
if not remove:
print("\nNothing to delete.")
conn.close()
return
print("\nNodes that will be DELETED:")
for row in remove:
lat = row['lat'] or 0
lon = row['lon'] or 0
name = row['name'] or row['public_key'][:12]
print(f" {name:<30} lat={lat:.4f} lon={lon:.4f}")
if dry_run:
print("\n[dry-run] No changes made.")
conn.close()
return
confirm = input(f"\nDelete {len(remove)} nodes? Type 'yes' to confirm: ").strip()
if confirm.lower() != 'yes':
print("Aborted.")
conn.close()
return
pubkeys = [row['public_key'] for row in remove]
cur.executemany('DELETE FROM nodes WHERE public_key = ?', [(pk,) for pk in pubkeys])
conn.commit()
print(f"\nDeleted {cur.rowcount if cur.rowcount >= 0 else len(pubkeys)} nodes.")
conn.close()
if __name__ == '__main__':
main()
+15 -6
View File
@@ -363,8 +363,15 @@ async function run() {
// Test 4: Packets page loads with filter
await test('Packets page loads with filter', async () => {
// Ensure desktop viewport and broad time window so fixture timestamps are included.
await page.setViewportSize({ width: 1280, height: 720 });
// Set time window BEFORE packets.js IIFE re-executes (525600 min ≈ 1 year).
// Navigate to the packets URL then reload — avoids about:blank cross-origin issues
// that can prevent the SPA from fully initializing within the timeout.
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('table tbody tr');
await page.evaluate(() => localStorage.setItem('meshcore-time-window', '525600'));
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('table tbody tr', { timeout: 15000 });
const rowsBefore = await page.$$('table tbody tr');
assert(rowsBefore.length > 0, 'No packets visible');
// Use the specific filter input
@@ -379,8 +386,7 @@ async function run() {
});
await test('Packets initial fetch honors persisted time window', async () => {
// Navigate to base first to get same-origin context for localStorage
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
// Set persisted time window to 60 min and reload so the IIFE reads it
await page.evaluate(() => localStorage.setItem('meshcore-time-window', '60'));
const packetsRequestPromise = page.waitForRequest((req) => {
@@ -392,8 +398,8 @@ async function run() {
}
}, { timeout: 10000 });
// Full reload to packets page — forces app to re-read localStorage
await page.evaluate(() => { window.location.href = window.location.origin + '/#/packets'; window.location.reload(); });
// Full reload on the packets page — scripts re-execute, IIFE reads localStorage
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('#fTimeWindow', { timeout: 10000 });
const timeWindowValue = await page.$eval('#fTimeWindow', (el) => el.value);
assert(timeWindowValue === '60', `Expected time window dropdown to restore 60, got ${timeWindowValue}`);
@@ -417,7 +423,10 @@ async function run() {
// Test: Packets groupByHash toggle changes view
await test('Packets groupByHash toggle works', async () => {
await page.waitForSelector('table tbody tr');
// Restore wide time window — previous test set it to 60 min which excludes fixture data
await page.evaluate(() => localStorage.setItem('meshcore-time-window', '525600'));
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('table tbody tr', { timeout: 15000 });
const groupBtn = await page.$('#fGroup');
assert(groupBtn, 'Group by hash button (#fGroup) not found');
// Check initial state (default is grouped/active)
+374 -147
View File
@@ -1431,6 +1431,31 @@ console.log('\n=== app.js: formatEngineBadge ===');
});
}
// ===== APP.JS: isTransportRoute + transportBadge =====
console.log('\n=== app.js: isTransportRoute + transportBadge ===');
{
const ctx = makeSandbox();
loadInCtx(ctx, 'public/roles.js');
loadInCtx(ctx, 'public/app.js');
const isTransportRoute = ctx.isTransportRoute;
const transportBadge = ctx.transportBadge;
test('isTransportRoute(0) is true (TRANSPORT_FLOOD)', () => assert.strictEqual(isTransportRoute(0), true));
test('isTransportRoute(3) is true (TRANSPORT_DIRECT)', () => assert.strictEqual(isTransportRoute(3), true));
test('isTransportRoute(1) is false (FLOOD)', () => assert.strictEqual(isTransportRoute(1), false));
test('isTransportRoute(2) is false (DIRECT)', () => assert.strictEqual(isTransportRoute(2), false));
test('isTransportRoute(null) is false', () => assert.strictEqual(isTransportRoute(null), false));
test('isTransportRoute(undefined) is false', () => assert.strictEqual(isTransportRoute(undefined), false));
test('transportBadge(0) contains badge-transport class', () => {
const html = transportBadge(0);
assert.ok(html.includes('badge-transport'), 'should contain badge-transport class');
assert.ok(html.includes('>T<'), 'should contain T label');
assert.ok(html.includes('TRANSPORT_FLOOD'), 'should contain route type name in title');
});
test('transportBadge(1) returns empty string', () => assert.strictEqual(transportBadge(1), ''));
}
// ===== APP.JS: formatVersionBadge =====
console.log('\n=== app.js: formatVersionBadge ===');
{
@@ -1767,153 +1792,6 @@ console.log('\n=== analytics.js: sortChannels ===');
});
}
// === analytics.js: hash prefix helpers ===
console.log('\n=== analytics.js: hash prefix helpers ===');
{
const ctx = (() => {
const c = makeSandbox();
c.getComputedStyle = () => ({ getPropertyValue: () => '' });
c.registerPage = () => {};
c.api = () => Promise.resolve({});
c.timeAgo = () => '—';
c.RegionFilter = { init: () => {}, onChange: () => {}, regionQueryString: () => '' };
c.onWS = () => {};
c.offWS = () => {};
c.connectWS = () => {};
c.invalidateApiCache = () => {};
c.makeColumnsResizable = () => {};
c.initTabBar = () => {};
c.IATA_COORDS_GEO = {};
loadInCtx(c, 'public/roles.js');
loadInCtx(c, 'public/app.js');
try { loadInCtx(c, 'public/analytics.js'); } catch (e) {
for (const k of Object.keys(c.window)) c[k] = c.window[k];
}
return c;
})();
const buildOne = ctx.window._analyticsBuildOneBytePrefixMap;
const buildTwo = ctx.window._analyticsBuildTwoBytePrefixInfo;
const buildHops = ctx.window._analyticsBuildCollisionHops;
const node = (pk, extra) => ({ public_key: pk, name: pk.slice(0, 4), ...(extra || {}) });
test('buildOneBytePrefixMap exports exist', () => assert.ok(buildOne, 'must be exported'));
test('buildTwoBytePrefixInfo exports exist', () => assert.ok(buildTwo, 'must be exported'));
test('buildCollisionHops exports exist', () => assert.ok(buildHops, 'must be exported'));
// --- 1-byte prefix map ---
test('1-byte map has 256 keys', () => {
const m = buildOne([]);
assert.strictEqual(Object.keys(m).length, 256);
});
test('1-byte map places node in correct bucket', () => {
const n = node('AABBCC');
const m = buildOne([n]);
assert.strictEqual(m['AA'].length, 1);
assert.strictEqual(m['AA'][0].public_key, 'AABBCC');
assert.strictEqual(m['BB'].length, 0);
});
test('1-byte map groups two nodes with same prefix', () => {
const a = node('AA1111'), b = node('AA2222');
const m = buildOne([a, b]);
assert.strictEqual(m['AA'].length, 2);
});
test('1-byte map is case-insensitive for node keys', () => {
const n = node('aabbcc');
const m = buildOne([n]);
assert.strictEqual(m['AA'].length, 1);
});
test('1-byte map: empty input yields all empty buckets', () => {
const m = buildOne([]);
assert.ok(Object.values(m).every(v => v.length === 0));
});
// --- 2-byte prefix info ---
test('2-byte info has 256 first-byte keys', () => {
const info = buildTwo([]);
assert.strictEqual(Object.keys(info).length, 256);
});
test('2-byte info: no nodes → zero collisions', () => {
const info = buildTwo([]);
assert.ok(Object.values(info).every(e => e.collisionCount === 0));
});
test('2-byte info: node placed in correct first-byte group', () => {
const n = node('AABB1122');
const info = buildTwo([n]);
assert.strictEqual(info['AA'].groupNodes.length, 1);
assert.strictEqual(info['BB'].groupNodes.length, 0);
});
test('2-byte info: same 2-byte prefix = collision', () => {
const a = node('AABB0001'), b = node('AABB0002');
const info = buildTwo([a, b]);
assert.strictEqual(info['AA'].collisionCount, 1);
assert.strictEqual(info['AA'].maxCollision, 2);
});
test('2-byte info: different 2-byte prefixes in same group = no collision', () => {
const a = node('AA110001'), b = node('AA220002');
const info = buildTwo([a, b]);
assert.strictEqual(info['AA'].collisionCount, 0);
assert.strictEqual(info['AA'].maxCollision, 0);
});
test('2-byte info: twoByteMap built correctly', () => {
const a = node('AABB0001'), b = node('AABB0002'), c = node('AACC0003');
const info = buildTwo([a, b, c]);
assert.strictEqual(Object.keys(info['AA'].twoByteMap).length, 2);
assert.strictEqual(info['AA'].twoByteMap['AABB'].length, 2);
assert.strictEqual(info['AA'].twoByteMap['AACC'].length, 1);
});
// --- 3-byte stat summary (via buildCollisionHops) ---
test('buildCollisionHops: no collisions returns empty array', () => {
const nodes = [node('AA000001'), node('BB000002'), node('CC000003')];
assert.deepStrictEqual(buildHops(nodes, 1), []);
});
test('buildCollisionHops: detects 1-byte collision', () => {
const nodes = [node('AA000001'), node('AA000002')];
const hops = buildHops(nodes, 1);
assert.strictEqual(hops.length, 1);
assert.strictEqual(hops[0].hex, 'AA');
assert.strictEqual(hops[0].count, 2);
});
test('buildCollisionHops: detects 2-byte collision', () => {
const nodes = [node('AABB0001'), node('AABB0002'), node('AACC0003')];
const hops = buildHops(nodes, 2);
assert.strictEqual(hops.length, 1);
assert.strictEqual(hops[0].hex, 'AABB');
assert.strictEqual(hops[0].count, 2);
});
test('buildCollisionHops: detects 3-byte collision', () => {
const nodes = [node('AABBCC0001'), node('AABBCC0002')];
const hops = buildHops(nodes, 3);
assert.strictEqual(hops.length, 1);
assert.strictEqual(hops[0].hex, 'AABBCC');
});
test('buildCollisionHops: size field set correctly', () => {
const nodes = [node('AABB0001'), node('AABB0002')];
const hops = buildHops(nodes, 2);
assert.strictEqual(hops[0].size, 2);
});
test('buildCollisionHops: empty input returns empty array', () => {
assert.deepStrictEqual(buildHops([], 1), []);
assert.deepStrictEqual(buildHops([], 2), []);
assert.deepStrictEqual(buildHops([], 3), []);
});
}
// ===== CUSTOMIZE.JS: initState merge behavior =====
console.log('\n=== customize.js: initState merge behavior ===');
@@ -2497,6 +2375,355 @@ console.log('\n=== channels.js: WS batch + region snapshot integration ===');
assert.ok(historyCalls.includes('#/channels'), 'should route back to channels root');
});
}
// ===== PACKETS.JS: savedTimeWindowMin default guard =====
console.log('\n=== packets.js: savedTimeWindowMin defaults ===');
{
async function captureInitialPacketsRequest(storageValue, innerWidth) {
const ctx = makeSandbox();
const apiCalls = [];
if (storageValue !== undefined) ctx.localStorage.setItem('meshcore-time-window', storageValue);
ctx.window.localStorage = ctx.localStorage;
ctx.window.innerWidth = innerWidth;
const dom = {
pktRight: { addEventListener() {}, classList: { add() {}, remove() {}, contains() { return false; } }, innerHTML: '' },
};
ctx.document.getElementById = (id) => {
if (id === 'fTimeWindow') return null;
return dom[id] || null;
};
ctx.document.addEventListener = () => {};
ctx.document.removeEventListener = () => {};
ctx.document.body = { appendChild() {}, removeChild() {}, contains() { return false; } };
ctx.window.addEventListener = () => {};
ctx.window.removeEventListener = () => {};
ctx.RegionFilter = { init() {}, onChange() { return () => {}; }, offChange() {}, getRegionParam() { return ''; } };
ctx.CLIENT_TTL = { observers: 120000 };
ctx.debouncedOnWS = (fn) => fn;
ctx.onWS = () => {};
ctx.offWS = () => {};
ctx.registerPage = (name, handlers) => { if (name === 'packets') ctx._packetsHandlers = handlers; };
ctx.api = (path) => {
apiCalls.push(path);
if (path.indexOf('/observers') === 0) return Promise.resolve({ observers: [] });
if (path.indexOf('/packets?') === 0) return Promise.reject(new Error('stop after request capture'));
if (path.indexOf('/config/regions') === 0) return Promise.resolve({});
return Promise.resolve({});
};
loadInCtx(ctx, 'public/packets.js');
assert.ok(ctx._packetsHandlers && typeof ctx._packetsHandlers.init === 'function',
'packets page should register init handler');
await ctx._packetsHandlers.init({ innerHTML: '' });
const firstPacketsCall = apiCalls.find(p => p.indexOf('/packets?') === 0);
assert.ok(firstPacketsCall, 'packets API should be called during initial packets page load');
const params = new URLSearchParams((firstPacketsCall.split('?')[1] || ''));
return { firstPacketsCall, params };
}
test('savedTimeWindowMin defaults to 15 when localStorage returns null', async () => {
const r = await captureInitialPacketsRequest(undefined, 1366);
const since = r.params.get('since');
assert.ok(since, 'initial packets request should include since parameter');
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
assert.ok(deltaMin > 10 && deltaMin < 25, `expected default ~15m window, got ${deltaMin.toFixed(2)}m`);
});
test('savedTimeWindowMin defaults to 15 when localStorage returns "0"', async () => {
const r = await captureInitialPacketsRequest('0', 1366);
const since = r.params.get('since');
assert.ok(since, 'initial packets request should include since parameter');
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
assert.ok(deltaMin > 10 && deltaMin < 25, `expected default ~15m window, got ${deltaMin.toFixed(2)}m`);
});
test('savedTimeWindowMin preserves valid value (60)', async () => {
const r = await captureInitialPacketsRequest('60', 1366);
const since = r.params.get('since');
assert.ok(since, 'initial packets request should include since parameter');
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
assert.ok(deltaMin > 45 && deltaMin < 75, `expected persisted ~60m window, got ${deltaMin.toFixed(2)}m`);
});
test('savedTimeWindowMin defaults to 15 for negative value', async () => {
const r = await captureInitialPacketsRequest('-5', 1366);
const since = r.params.get('since');
assert.ok(since, 'initial packets request should include since parameter');
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
assert.ok(deltaMin > 10 && deltaMin < 25, `expected default ~15m window, got ${deltaMin.toFixed(2)}m`);
});
test('savedTimeWindowMin defaults to 15 for NaN string', async () => {
const r = await captureInitialPacketsRequest('abc', 1366);
const since = r.params.get('since');
assert.ok(since, 'initial packets request should include since parameter');
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
assert.ok(deltaMin > 10 && deltaMin < 25, `expected default ~15m window, got ${deltaMin.toFixed(2)}m`);
});
test('PACKET_LIMIT is 1000 on mobile', async () => {
const r = await captureInitialPacketsRequest('15', 375);
assert.strictEqual(r.params.get('limit'), '1000');
});
test('PACKET_LIMIT is 50000 on desktop', async () => {
const r = await captureInitialPacketsRequest('15', 1366);
assert.strictEqual(r.params.get('limit'), '50000');
});
test('mobile caps large time window to 15', async () => {
const r = await captureInitialPacketsRequest('1440', 375);
const since = r.params.get('since');
assert.ok(since, 'initial packets request should include since parameter');
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
assert.ok(deltaMin > 10 && deltaMin < 25, `expected capped ~15m window, got ${deltaMin.toFixed(2)}m`);
});
test('mobile allows 180 min window', async () => {
const r = await captureInitialPacketsRequest('180', 375);
const since = r.params.get('since');
assert.ok(since, 'initial packets request should include since parameter');
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
assert.ok(deltaMin > 160 && deltaMin < 210, `expected ~180m window, got ${deltaMin.toFixed(2)}m`);
});
test('mobile corrects desktop-persisted all-time value to 15 minutes', async () => {
const r = await captureInitialPacketsRequest('0', 375);
const since = r.params.get('since');
assert.ok(since, 'mobile should not keep all-time persisted value');
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
assert.ok(deltaMin > 10 && deltaMin < 25, `expected capped ~15m window, got ${deltaMin.toFixed(2)}m`);
});
}
// ===== My Nodes client-side filter (issue #381) =====
{
console.log('\n--- My Nodes client-side filter ---');
// Simulate the client-side filter logic from packets.js renderTableRows()
function filterMyNodes(packets, allKeys) {
if (!allKeys.length) return [];
return packets.filter(p => {
const dj = p.decoded_json || '';
return allKeys.some(k => dj.includes(k));
});
}
const testPackets = [
{ decoded_json: '{"pubKey":"abc123","name":"Node1"}' },
{ decoded_json: '{"pubKey":"def456","name":"Node2"}' },
{ decoded_json: '{"pubKey":"ghi789","name":"Node3","hops":["abc123"]}' },
{ decoded_json: '' },
{ decoded_json: null },
];
test('filters packets matching a single pubkey', () => {
const result = filterMyNodes(testPackets, ['abc123']);
assert.strictEqual(result.length, 2, 'should match sender + hop');
assert.ok(result[0].decoded_json.includes('abc123'));
assert.ok(result[1].decoded_json.includes('abc123'));
});
test('filters packets matching multiple pubkeys', () => {
const result = filterMyNodes(testPackets, ['abc123', 'def456']);
assert.strictEqual(result.length, 3);
});
test('returns empty array for no matching keys', () => {
const result = filterMyNodes(testPackets, ['zzz999']);
assert.strictEqual(result.length, 0);
});
test('returns empty array when allKeys is empty', () => {
const result = filterMyNodes(testPackets, []);
assert.strictEqual(result.length, 0);
});
test('handles null/empty decoded_json gracefully', () => {
const result = filterMyNodes(testPackets, ['abc123']);
assert.strictEqual(result.length, 2);
});
}
// ===== Packets page: virtual scroll infrastructure =====
{
console.log('\nPackets page — virtual scroll:');
const packetsSource = fs.readFileSync('public/packets.js', 'utf8');
// --- Behavioral tests using extracted logic ---
// Extract _cumulativeRowOffsets logic for testing
function cumulativeRowOffsets(rowCounts) {
const offsets = new Array(rowCounts.length + 1);
offsets[0] = 0;
for (let i = 0; i < rowCounts.length; i++) {
offsets[i + 1] = offsets[i] + rowCounts[i];
}
return offsets;
}
// Extract _getRowCount logic for testing (#424 — single source of truth)
function getRowCount(p, grouped, expandedHashes, observerFilterSet) {
if (!grouped) return 1;
if (!expandedHashes.has(p.hash) || !p._children) return 1;
let childCount = p._children.length;
if (observerFilterSet) {
childCount = p._children.filter(c => observerFilterSet.has(String(c.observer_id))).length;
}
return 1 + childCount;
}
test('cumulativeRowOffsets computes correct offsets for flat rows', () => {
const counts = [1, 1, 1, 1, 1];
const offsets = cumulativeRowOffsets(counts);
assert.deepStrictEqual(offsets, [0, 1, 2, 3, 4, 5]);
});
test('cumulativeRowOffsets handles expanded groups with multiple rows', () => {
const counts = [1, 4, 1];
const offsets = cumulativeRowOffsets(counts);
assert.deepStrictEqual(offsets, [0, 1, 5, 6]);
assert.strictEqual(offsets[offsets.length - 1], 6);
});
test('total scroll height accounts for expanded group rows', () => {
const VSCROLL_ROW_HEIGHT = 36;
const counts = [1, 4, 1, 4, 1];
const offsets = cumulativeRowOffsets(counts);
const totalDomRows = offsets[offsets.length - 1];
assert.strictEqual(totalDomRows, 11);
assert.strictEqual(totalDomRows * VSCROLL_ROW_HEIGHT, 396);
});
test('scroll height with all collapsed equals entries * row height', () => {
const VSCROLL_ROW_HEIGHT = 36;
const counts = [1, 1, 1, 1, 1];
const offsets = cumulativeRowOffsets(counts);
const totalDomRows = offsets[offsets.length - 1];
assert.strictEqual(totalDomRows * VSCROLL_ROW_HEIGHT, 5 * VSCROLL_ROW_HEIGHT);
});
// --- Behavioral tests for _getRowCount (#424, #428 — test logic, not source strings) ---
test('getRowCount returns 1 for flat (ungrouped) mode', () => {
const p = { hash: 'abc', _children: [{observer_id: '1'}, {observer_id: '2'}] };
assert.strictEqual(getRowCount(p, false, new Set(), null), 1);
});
test('getRowCount returns 1 for collapsed group', () => {
const p = { hash: 'abc', _children: [{observer_id: '1'}, {observer_id: '2'}] };
assert.strictEqual(getRowCount(p, true, new Set(), null), 1);
});
test('getRowCount returns 1+children for expanded group', () => {
const p = { hash: 'abc', _children: [{observer_id: '1'}, {observer_id: '2'}, {observer_id: '3'}] };
const expanded = new Set(['abc']);
assert.strictEqual(getRowCount(p, true, expanded, null), 4);
});
test('getRowCount filters children by observer set', () => {
const p = { hash: 'abc', _children: [{observer_id: '1'}, {observer_id: '2'}, {observer_id: '3'}] };
const expanded = new Set(['abc']);
const obsFilter = new Set(['1', '3']);
assert.strictEqual(getRowCount(p, true, expanded, obsFilter), 3);
});
test('getRowCount returns 1 for expanded group with no _children', () => {
const p = { hash: 'abc' };
const expanded = new Set(['abc']);
assert.strictEqual(getRowCount(p, true, expanded, null), 1);
});
test('renderVisibleRows uses cumulative offsets not flat entry count', () => {
assert.ok(packetsSource.includes('_cumulativeRowOffsets'),
'renderVisibleRows should use cumulative row offsets');
assert.ok(!packetsSource.includes('const totalRows = _displayPackets.length'),
'should NOT use flat array length for total row count');
});
test('renderVisibleRows skips DOM rebuild when range unchanged', () => {
assert.ok(packetsSource.includes('startIdx === _lastVisibleStart && endIdx === _lastVisibleEnd'),
'should skip rebuild when range is unchanged');
});
test('lazy row generation — HTML built only for visible slice', () => {
assert.ok(!packetsSource.includes('_lastRenderedRows'),
'should NOT have pre-built row HTML cache');
assert.ok(packetsSource.includes('_displayPackets.slice(startIdx, endIdx)'),
'should slice display packets for visible range');
assert.ok(packetsSource.includes('visibleSlice.map(p => builder(p))'),
'should build HTML lazily per visible packet');
});
test('observer filter Set is hoisted, not recreated per-packet', () => {
assert.ok(packetsSource.includes('_observerFilterSet = filters.observer ? new Set(filters.observer.split'),
'observer filter Set should be created once in renderTableRows');
assert.ok(packetsSource.includes('_observerFilterSet.has(String(c.observer_id))'),
'buildGroupRowHtml should use hoisted _observerFilterSet');
});
test('buildFlatRowHtml has null-safe decoded_json', () => {
const flatBuilderMatch = packetsSource.match(/function buildFlatRowHtml[\s\S]*?(?=\n function )/);
assert.ok(flatBuilderMatch, 'buildFlatRowHtml should exist');
assert.ok(flatBuilderMatch[0].includes("p.decoded_json || '{}'"),
'buildFlatRowHtml should have null-safe decoded_json fallback');
});
test('pathHops null guard in buildFlatRowHtml (issue #451)', () => {
const flatBuilderMatch = packetsSource.match(/function buildFlatRowHtml[\s\S]*?(?=\n function )/);
assert.ok(flatBuilderMatch, 'buildFlatRowHtml should exist');
// The JSON.parse result must be coalesced with || [] to handle literal null from path_json
assert.ok(flatBuilderMatch[0].includes("|| '[]') || []"),
'buildFlatRowHtml should coalesce parsed path_json with || [] to guard against null');
});
test('pathHops null guard in detail pane (issue #451)', () => {
// The detail pane (selectPacket / showPacketDetail) also parses path_json
const detailMatch = packetsSource.match(/let pathHops;\s*try \{[^}]+\} catch/);
assert.ok(detailMatch, 'detail pane pathHops parsing should exist');
assert.ok(detailMatch[0].includes("|| '[]') || []"),
'detail pane should coalesce parsed path_json with || [] to guard against null');
});
test('destroy cleans up virtual scroll state', () => {
assert.ok(packetsSource.includes('detachVScrollListener'),
'destroy should detach virtual scroll listener');
assert.ok(packetsSource.includes("_displayPackets = []"),
'destroy should reset display packets');
assert.ok(packetsSource.includes("_rowCounts = []"),
'destroy should reset row counts');
assert.ok(packetsSource.includes("_lastVisibleStart = -1"),
'destroy should reset visible start');
});
}
// ===== live.js: nextHop null guards =====
console.log('\n=== live.js: nextHop null guards ===');
{
const liveSource = fs.readFileSync('public/live.js', 'utf8');
test('nextHop guards animLayer null before use', () => {
assert.ok(liveSource.includes('if (!animLayer) return;'),
'nextHop must return early when animLayer is null (post-destroy)');
});
test('nextHop setInterval guards animLayer null', () => {
assert.ok(liveSource.includes('if (!animLayer || !animLayer.hasLayer(ghost))'),
'setInterval in nextHop must guard animLayer null');
});
test('nextHop setTimeout guards animLayer null', () => {
assert.ok(liveSource.includes('if (animLayer && animLayer.hasLayer(ghost)) animLayer.removeLayer(ghost)'),
'setTimeout in nextHop must guard animLayer null');
});
test('nextHop guards liveAnimCount element null', () => {
assert.ok(liveSource.includes('const countEl = document.getElementById(\'liveAnimCount\')'),
'nextHop must null-check liveAnimCount element');
assert.ok(liveSource.includes('if (countEl) countEl.textContent = activeAnims'),
'nextHop must conditionally update liveAnimCount');
});
}
// ===== SUMMARY =====
Promise.allSettled(pendingTests).then(() => {
console.log(`\n${'═'.repeat(40)}`);
+153
View File
@@ -0,0 +1,153 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoFilter Builder</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #1a1a2e; color: #e0e0e0; height: 100vh; display: flex; flex-direction: column; }
header { padding: 12px 16px; background: #0f0f23; border-bottom: 1px solid #333; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
header h1 { font-size: 1rem; font-weight: 600; color: #4a9eff; white-space: nowrap; }
.controls { display: flex; gap: 8px; flex-wrap: wrap; }
button { padding: 6px 14px; border: none; border-radius: 6px; cursor: pointer; font-size: 0.85rem; font-weight: 500; }
#btnUndo { background: #333; color: #ccc; }
#btnClear { background: #5a2020; color: #ffaaaa; }
#btnUndo:hover { background: #444; }
#btnClear:hover { background: #7a2020; }
.hint { font-size: 0.8rem; color: #888; margin-left: auto; }
#map { flex: 1; }
#output-panel { background: #0f0f23; border-top: 1px solid #333; padding: 12px 16px; display: flex; gap: 12px; align-items: flex-start; }
#output-panel label { font-size: 0.75rem; color: #888; white-space: nowrap; padding-top: 6px; }
#output { flex: 1; background: #111; border: 1px solid #333; border-radius: 6px; padding: 10px 12px; font-family: monospace; font-size: 0.78rem; color: #7ec8e3; white-space: pre; overflow-x: auto; min-height: 54px; max-height: 140px; overflow-y: auto; cursor: text; }
#output.empty { color: #555; font-style: italic; }
#btnCopy { padding: 6px 14px; background: #1a4a7a; color: #7ec8e3; border-radius: 6px; border: none; cursor: pointer; font-size: 0.85rem; white-space: nowrap; align-self: flex-end; }
#btnCopy:hover { background: #2a6aaa; }
#btnCopy.copied { background: #1a6a3a; color: #7effa0; }
#counter { font-size: 0.8rem; color: #888; padding-top: 6px; white-space: nowrap; }
.bufferRow { display: flex; align-items: center; gap: 8px; }
.bufferRow label { font-size: 0.85rem; color: #aaa; }
.bufferRow input { width: 60px; padding: 5px 8px; background: #222; border: 1px solid #444; border-radius: 6px; color: #eee; font-size: 0.85rem; }
</style>
</head>
<body>
<header>
<h1>GeoFilter Builder</h1>
<div class="controls">
<button id="btnUndo">↩ Undo</button>
<button id="btnClear">✕ Clear</button>
</div>
<div class="bufferRow">
<label for="bufferKm">Buffer km:</label>
<input type="number" id="bufferKm" value="20" min="0" max="500"/>
</div>
<span class="hint">Click on the map to add polygon points</span>
</header>
<div id="map"></div>
<div id="output-panel">
<label>config.json</label>
<div id="output" class="empty">Add at least 3 points to generate config…</div>
<div style="display:flex;flex-direction:column;gap:8px;align-items:flex-end">
<span id="counter">0 points</span>
<button id="btnCopy">Copy</button>
</div>
</div>
<script>
const map = L.map('map').setView([50.5, 4.4], 8);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap © CartoDB',
maxZoom: 19
}).addTo(map);
let points = [];
let markers = [];
let polygon = null;
let closingLine = null;
function latLonPair(latlng) {
return [parseFloat(latlng.lat.toFixed(6)), parseFloat(latlng.lng.toFixed(6))];
}
function render() {
// Remove existing polygon and closing line
if (polygon) { map.removeLayer(polygon); polygon = null; }
if (closingLine) { map.removeLayer(closingLine); closingLine = null; }
if (points.length >= 3) {
polygon = L.polygon(points, {
color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.12
}).addTo(map);
} else if (points.length === 2) {
closingLine = L.polyline(points, { color: '#4a9eff', weight: 2, dashArray: '5,5' }).addTo(map);
}
updateOutput();
}
function updateOutput() {
const el = document.getElementById('output');
const counter = document.getElementById('counter');
counter.textContent = points.length + ' point' + (points.length !== 1 ? 's' : '');
if (points.length < 3) {
el.textContent = 'Add at least 3 points to generate config…';
el.classList.add('empty');
return;
}
el.classList.remove('empty');
const bufferKm = parseFloat(document.getElementById('bufferKm').value) || 0;
const config = { bufferKm, polygon: points };
el.textContent = JSON.stringify({ geo_filter: config }, null, 2);
}
map.on('click', function(e) {
const pt = latLonPair(e.latlng);
points.push(pt);
const idx = points.length;
const marker = L.circleMarker(e.latlng, {
radius: 6, color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.9
}).addTo(map).bindTooltip(String(idx), { permanent: true, direction: 'top', offset: [0, -8], className: 'pt-label' });
markers.push(marker);
render();
});
document.getElementById('btnUndo').addEventListener('click', function() {
if (!points.length) return;
points.pop();
const m = markers.pop();
if (m) map.removeLayer(m);
render();
});
document.getElementById('btnClear').addEventListener('click', function() {
points = [];
markers.forEach(m => map.removeLayer(m));
markers = [];
render();
});
document.getElementById('bufferKm').addEventListener('input', updateOutput);
document.getElementById('btnCopy').addEventListener('click', function() {
if (points.length < 3) return;
const text = document.getElementById('output').textContent;
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('btnCopy');
btn.textContent = 'Copied!';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000);
});
});
</script>
</body>
</html>