mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-16 15:25:10 +00:00
Compare commits
296 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95b59d1792 | |||
| e7aa4246ac | |||
| f1aa6caf93 | |||
| a882aae681 | |||
| aa35164252 | |||
| 84f33aef7b | |||
| baa60cac0f | |||
| d7e415daa7 | |||
| 2c6148fd2d | |||
| 2feb2c5b94 | |||
| 10b11106f6 | |||
| 326d411c4a | |||
| 15a93d5ea4 | |||
| 055467ca43 | |||
| 4f7b02a91c | |||
| f0db317051 | |||
| 9bf78bd28d | |||
| 5fe275b3f8 | |||
| 74a08d99b0 | |||
| 76d63ffe75 | |||
| 157dc9a979 | |||
| 2f07ae2e5c | |||
| 1f9cd3ead1 | |||
| 4b5b801def | |||
| 8ef65def7d | |||
| 9ef5c1a809 | |||
| e31e4aa356 | |||
| db884f12eb | |||
| 116f0c8dfb | |||
| b3e8dcaa93 | |||
| 920eab04c1 | |||
| d67b531bf2 | |||
| 5a6847bbf4 | |||
| 034c68c154 | |||
| 477dcde82f | |||
| c997318cd2 | |||
| fa40ede9e7 | |||
| 8ce2262813 | |||
| 90c4c03ac3 | |||
| 87bbd93d12 | |||
| e837dba000 | |||
| fa72e6242d | |||
| 2fcbcd97d1 | |||
| 311db0285d | |||
| 01df7f7871 | |||
| 90fa755e7d | |||
| 039d1fc28f | |||
| d8c0e3a156 | |||
| bee124e6d2 | |||
| fd919a2a80 | |||
| f58728118d | |||
| 4aa78305d3 | |||
| 8659cda7b7 | |||
| 2713d501b4 | |||
| 4ff72935ca | |||
| a756517647 | |||
| 74983d3f74 | |||
| ab35ced2bf | |||
| ff86a78480 | |||
| 2a076dfb1d | |||
| fceff15e2f | |||
| 9c87f0040e | |||
| 395abc2585 | |||
| e82e4fe05f | |||
| 6cf9793706 | |||
| 1772b34e8f | |||
| fea8a7e0b5 | |||
| 2e486e2a66 | |||
| f0c29b38f1 | |||
| 46d9b690ee | |||
| 2e51e5f743 | |||
| 11b398cfe1 | |||
| f4ac789ee9 | |||
| 2a2a80b4ea | |||
| 6dd077be13 | |||
| 2b3597dff1 | |||
| 77b7b218b1 | |||
| 0a499745ec | |||
| c83eb099c9 | |||
| cd01da5a64 | |||
| 0b4590e48d | |||
| f5d377e396 | |||
| dc703ebf28 | |||
| 89c1e84924 | |||
| 50b6124325 | |||
| f08756a6ac | |||
| c2bc07bb4a | |||
| e589fd959a | |||
| 706227b106 | |||
| 44f9a95ec5 | |||
| b481df424f | |||
| 2edcca77f1 | |||
| cd678d492d | |||
| 4c6172bc6e | |||
| d01fa7e17f | |||
| 35e86c34e0 | |||
| f8638974c7 | |||
| 1be6b4f4ad | |||
| d8d0572abb | |||
| de658bfb0d | |||
| 720d019a28 | |||
| ce030c91f7 | |||
| 99ef07ca05 | |||
| 141c28231e | |||
| 2b7ed064d1 | |||
| 415440d36d | |||
| 5832c73a0d | |||
| e98e04553a | |||
| 8587286896 | |||
| 4fff11976e | |||
| 68b79d2d50 | |||
| 681cf82cd6 | |||
| 36598a3623 | |||
| 0dc2dd3f25 | |||
| 0106c8ebf9 | |||
| cb4773b426 | |||
| 9c6608acc2 | |||
| dc4e91a348 | |||
| 78034cbbc0 | |||
| 435a19057a | |||
| 90abb42904 | |||
| 93c7f4c9eb | |||
| 175d9269ec | |||
| 4fc9c25a5d | |||
| d7f0e0c9fe | |||
| f054841a99 | |||
| 0be9e8b4fd | |||
| 08063c1316 | |||
| ffe26f7d03 | |||
| d99aa3ac11 | |||
| b407fa4f28 | |||
| 6cc2f3a8c7 | |||
| c62902cd9c | |||
| 46abc7b11b | |||
| 80215f9d31 | |||
| 25ae36c4b6 | |||
| 6ca5336563 | |||
| fda8d73588 | |||
| ca21dc5608 | |||
| f2b0145da0 | |||
| 3048166648 | |||
| f947af5b01 | |||
| ad5e12fc45 | |||
| 55ee3c6327 | |||
| d1a4333b87 | |||
| b91ba7e38a | |||
| fb1cfae089 | |||
| 3be3a039f1 | |||
| 8549ac4ac9 | |||
| a84a8b8bb0 | |||
| 77bc6c9391 | |||
| bab0b6c441 | |||
| 2e48e5db2f | |||
| 4060ddd326 | |||
| b62c8c7b43 | |||
| 963778632e | |||
| 21b1cbc332 | |||
| 1ecc95db5a | |||
| d41477d1d8 | |||
| 58531e5da7 | |||
| 84b817745f | |||
| 989de353b5 | |||
| d4c131ec1e | |||
| 3e38d88bed | |||
| 4431769b00 | |||
| f8b05f15b9 | |||
| 2cf11bea54 | |||
| fe34fc81a7 | |||
| cfbdc8a9e0 | |||
| 8e3a860cb7 | |||
| 08d3fd3539 | |||
| ca96d5dfbc | |||
| 6dd258be65 | |||
| 27ba362ace | |||
| e44f288dab | |||
| dac05aff1a | |||
| 472090aeb5 | |||
| ec20906fe1 | |||
| 45590991dd | |||
| 5b4c741f19 | |||
| 016ba3bef6 | |||
| 6b8526548a | |||
| 5d536c382f | |||
| 71969785bf | |||
| 3cbf315e99 | |||
| 11e6973bca | |||
| 9e4308a1d0 | |||
| b0f6ccf12e | |||
| b36790445b | |||
| 767754fc10 | |||
| 924587e2dc | |||
| 780f8477e3 | |||
| 2414059bac | |||
| 1c25a2bc5b | |||
| bd560b9e52 | |||
| 5255f7091e | |||
| e8be570ff5 | |||
| 02ae79beba | |||
| 1f3b8756af | |||
| cb2b67a8b5 | |||
| 3372870674 | |||
| e1b382a5fe | |||
| 7260b36534 | |||
| 72743fd9ee | |||
| e1a465b113 | |||
| 080d4bc3c1 | |||
| b4d0d6a056 | |||
| 5205cf04fe | |||
| fd77731b54 | |||
| 5b78a4a216 | |||
| 7c90a260ca | |||
| 8616145c98 | |||
| e1873e1451 | |||
| 3f1f6b91c7 | |||
| dbb7abb72c | |||
| 1c5dfd67a9 | |||
| 3831e3e4b9 | |||
| 169e8b0c02 | |||
| 01670e7671 | |||
| a0c2429756 | |||
| 1159886285 | |||
| c4e82551c2 | |||
| 02b8034a4c | |||
| 6e8c941396 | |||
| 81520e4660 | |||
| ec87455b79 | |||
| 0eca0ce61c | |||
| 0f06fe881d | |||
| 53117c79a7 | |||
| 2dabad8fb9 | |||
| 4ae565b829 | |||
| 4726245988 | |||
| fdbd6511ff | |||
| 850768395e | |||
| 69297d8eec | |||
| b922c20604 | |||
| 25fc2924e0 | |||
| a271696766 | |||
| bcf1a6d90a | |||
| 49ed4b80e2 | |||
| c969a0218f | |||
| 4a21f8419f | |||
| b7d8ec0cad | |||
| 0c1e50499b | |||
| 932ac4807e | |||
| 74013c17a2 | |||
| b22a9bccca | |||
| aa7445e1fb | |||
| f608f55c3e | |||
| 4d1f3ce09d | |||
| 338054d759 | |||
| ecbfe4246b | |||
| 46c6a6337e | |||
| 07a6a0ecc2 | |||
| fe7276815f | |||
| e23169e918 | |||
| a2cb5c4928 | |||
| b95999200d | |||
| 0a55c5a84b | |||
| 307a9ea4e2 | |||
| 335af2874a | |||
| dcfd4db318 | |||
| 600f24248f | |||
| 636c17aa89 | |||
| 43e62f9baf | |||
| 1c184d948d | |||
| 2cbd0ed1c4 | |||
| 3187425a3f | |||
| 8c9c49f9ab | |||
| 2b77c5c9f8 | |||
| a0c4290535 | |||
| f2631980e2 | |||
| 731d0a3a14 | |||
| 524fd8df49 | |||
| ac31028b49 | |||
| 58a8d929f7 | |||
| 2c9953896a | |||
| 2e5748f031 | |||
| d8189a5435 | |||
| b89b8bb5a3 | |||
| 3334ed98b4 | |||
| ef2ecf9998 | |||
| 73d6a08c07 | |||
| 075ffaf311 | |||
| e66aaebc54 | |||
| 429f3542f1 | |||
| a988ef67b0 | |||
| df87f24b7f | |||
| 58ab38d5f5 | |||
| 41afca1959 | |||
| fae8083745 | |||
| 9164ebf3d7 | |||
| e525566080 | |||
| 4fb1f89bbc | |||
| c7dc9e4b50 | |||
| 46349172f6 |
@@ -0,0 +1,14 @@
|
||||
# Docker
|
||||
.git
|
||||
node_modules
|
||||
data
|
||||
config.json
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.bak
|
||||
benchmark*.sh
|
||||
benchmark*.js
|
||||
PERFORMANCE.md
|
||||
docs/
|
||||
.gitignore
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
concurrency:
|
||||
group: deploy
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Validate JS
|
||||
run: sh scripts/validate.sh
|
||||
|
||||
- name: Build and deploy
|
||||
run: |
|
||||
set -e
|
||||
docker build -t meshcore-analyzer .
|
||||
docker stop meshcore-analyzer 2>/dev/null && docker rm meshcore-analyzer 2>/dev/null || true
|
||||
docker run -d \
|
||||
--name meshcore-analyzer \
|
||||
--restart unless-stopped \
|
||||
-p 80:80 -p 443:443 -p 1883:1883 \
|
||||
-v $HOME/meshcore-data:/app/data \
|
||||
-v $HOME/meshcore-config.json:/app/config.json:ro \
|
||||
-v $HOME/caddy-data:/data/caddy \
|
||||
-v $HOME/meshcore-analyzer/Caddyfile:/etc/caddy/Caddyfile \
|
||||
meshcore-analyzer
|
||||
echo "Deployed $(git rev-parse --short HEAD)"
|
||||
@@ -4,3 +4,5 @@ data/
|
||||
*.db
|
||||
*.db-journal
|
||||
config.json
|
||||
data-lincomatic/
|
||||
config-lincomatic.json
|
||||
|
||||
+36
-33
@@ -1,42 +1,45 @@
|
||||
# Changelog
|
||||
|
||||
## v2.0.0 (2026-03-20)
|
||||
## v2.1.1 — Multi-Broker MQTT & Observer Detail (2026-03-20)
|
||||
|
||||
85+ commits — analytics, mobile redesign, accessibility, 100+ bug fixes.
|
||||
### 🆕 New Features
|
||||
|
||||
### ✨ New Features
|
||||
- Per-node analytics page (6 charts, stat cards, peer table, time range selector)
|
||||
- Global analytics — Nodes tab (network status, role breakdown, claimed nodes, leaderboards)
|
||||
- Live map VCR playback — rewind/replay/scrub 24h at up to 4× speed, retro LCD clock
|
||||
- Richer node detail — status badge, avg SNR/hops, observer table, QR codes, recent packets
|
||||
- Claimed (My Mesh) nodes — star your nodes, always sorted to top, auto-sync favorites
|
||||
- Packets "My Nodes" toggle — filter to only your mesh traffic
|
||||
- Bulk health API (`GET /api/nodes/bulk-health`)
|
||||
- Network status API (`GET /api/nodes/network-status`)
|
||||
- Live theme toggle — dark/light tiles swap instantly via MutationObserver
|
||||
- **Multi-Broker MQTT** — Connect to multiple MQTT brokers simultaneously via `mqttSources` config array. Each source gets its own connection, topics, credentials, TLS settings, and optional IATA region filter. Legacy `mqtt` config still works.
|
||||
- **IATA Region Filtering** — `mqttSources[].iataFilter` restricts accepted regions per source (e.g. only accept SJC/SFO/OAK packets from a shared feed).
|
||||
- **Observer Detail Pages** — Click any observer row for a full detail page with status, radio info, battery/uptime/noise floor, packet type donut chart, timeline, unique nodes chart, SNR distribution, and recent packets table.
|
||||
- **Observer Status Topic Parsing** — `meshcore/<region>/<id>/status` messages populate model, firmware, client_version, radio config, battery, uptime, and noise floor. 7 new columns in the observers table with auto-migration.
|
||||
- **Channel Key Auto-Derivation** — Hashtag channel keys (`#channel`) are automatically derived as `SHA256("#channelname")` first 16 bytes on startup. Only non-hashtag keys (like `public`) need manual config.
|
||||
- **Map Dark/Light Mode** — Map page now uses CartoDB dark/light tiles that swap automatically with the theme toggle (same as live page).
|
||||
- **Shareable URLs** — Copy Link button on packet detail, standalone packet page at `#/packet/ID`, deep links to channels and observer detail pages.
|
||||
- **Multi-Node Packet Filter** — "My Nodes" toggle in packets view now uses server-side `findPacketsForNode()` to find ALL packet types (messages, acks, traces), not just ADVERTs.
|
||||
|
||||
### 📱 Mobile
|
||||
- Two-row VCR bar layout (controls+LCD / full-width timeline)
|
||||
- iOS safe area support (home indicator clearance)
|
||||
- Feed/legend hidden on mobile — just map + VCR + LCD
|
||||
- JS-driven viewport height for reliable orientation changes
|
||||
- Touch-friendly targets, horizontal scroll on tables
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
### ♿ Accessibility
|
||||
- ARIA tab patterns, focus management, keyboard navigation
|
||||
- Distinct SVG marker shapes per node role
|
||||
- Color-blind safe palettes, screen reader support
|
||||
- **Observer name resolution** — MQTT packets now pass `msg.origin` (friendly name) to both packet records and observer upserts. Previously only the status handler used it.
|
||||
- **Observer analytics ordering** — Fixed `recentPackets` returning oldest instead of newest (wrong slice direction). Sorted observer analytics packets explicitly.
|
||||
- **Spark bars visible** — Fixed `.data-table td { max-width: 0 }` crushing spark bar cells to zero width with inline style override.
|
||||
- **My Nodes filter field names** — Fixed `pubkey` → `pubKey`, `to`/`from` → `srcPubKey`/`destPubKey`/`srcHash`/`destHash`.
|
||||
- **Duplicate pin buttons** — Live page destroy now removes the nav pin button; init guards against duplicates.
|
||||
- **Packets page crash** — Fixed non-async `renderTableRows` using `await` (syntax error prevented entire page from loading).
|
||||
- **Node search all packet types** — Search by node name now returns messages, acks, and traces — not just ADVERTs.
|
||||
- **Node packet count accuracy** — `findPacketsForNode()` is now single source of truth for all node packet lookups.
|
||||
- **Health endpoint recentPackets** — Changed from `slice(-10).reverse()` to `slice(0, 20)` — 20 newest DESC instead of 10 oldest.
|
||||
- **RF analytics total packets** — Added `totalAllPackets` field so frontend shows both total and signal-filtered counts.
|
||||
- **Duplicate `const crypto` crash** — Removed duplicate `require('crypto')` that crashed prod for ~2 minutes.
|
||||
- **PII scrubbed from git history** — Removed real names and coordinates from seed data across all commits.
|
||||
|
||||
### 🐛 Bug Fixes (100+)
|
||||
- Excel-like column resize — steal proportionally from all right columns
|
||||
- Panel drag live reflow
|
||||
- VCR scrub pagination, replay buffer management
|
||||
- Express route ordering (named before parameterized)
|
||||
- XSS escaping, WebSocket cleanup, memory leaks
|
||||
- Dark mode consistency, empty states, SRI hashes
|
||||
- Stray CSS fragment corrupting live.css
|
||||
- Geographic prefix disambiguation restored
|
||||
### 🏗️ Infrastructure
|
||||
|
||||
## v1.0.0 (2026-03-19)
|
||||
- **Docker container deployed to Azure VM** — Live at `https://analyzer.00id.net` with automatic Let's Encrypt TLS via Caddy.
|
||||
- **`deploy.sh` fixed** — Config mount (`-v config.json:/app/config.json:ro`) was missing, causing every deploy to fall back to placeholder credentials. Added `|| true` to stop/rm to prevent chain failures.
|
||||
- **CI/CD via GitHub Actions** — Self-hosted runner on VM, auto-deploys on push to master.
|
||||
|
||||
Initial release.
|
||||
---
|
||||
|
||||
## v2.0.1 — Mobile Packets (2026-03-18)
|
||||
|
||||
See [v2.0.1 release](https://github.com/Kpa-clawbot/meshcore-analyzer/releases/tag/v2.0.1).
|
||||
|
||||
## v2.0.0 — Live Trace Map & VCR Playback (2026-03-17)
|
||||
|
||||
See [v2.0.0 release](https://github.com/Kpa-clawbot/meshcore-analyzer/releases/tag/v2.0.0).
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
RUN apk add --no-cache mosquitto mosquitto-clients supervisor caddy
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Node dependencies
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --production
|
||||
|
||||
# Copy application
|
||||
COPY *.js config.example.json ./
|
||||
COPY public/ ./public/
|
||||
|
||||
# Supervisor + Mosquitto + Caddy config
|
||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
COPY docker/mosquitto.conf /etc/mosquitto/mosquitto.conf
|
||||
COPY docker/Caddyfile /etc/caddy/Caddyfile
|
||||
|
||||
# Create data directory for SQLite + Mosquitto persistence + Caddy certs
|
||||
RUN mkdir -p /app/data /var/lib/mosquitto /data/caddy && \
|
||||
chown -R node:node /app/data && \
|
||||
chown -R mosquitto:mosquitto /var/lib/mosquitto
|
||||
|
||||
# Default config: copy example if no config mounted
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 80 443 1883
|
||||
|
||||
VOLUME ["/app/data", "/data/caddy"]
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -0,0 +1,67 @@
|
||||
# Performance — v2.1.0
|
||||
|
||||
**Dataset:** 28,014 packets, ~650 nodes, 2 observers
|
||||
**Hardware:** ARM64 (MikroTik CCR2116), single-core Node.js
|
||||
|
||||
## A/B Benchmark: v2.0.1 (before) vs v2.1.0 (after)
|
||||
|
||||
All times are averages over 3 runs. "Cached" = warm TTL cache hit.
|
||||
|
||||
| Endpoint | v2.0.1 | v2.1.0 (cold) | v2.1.0 (cached) | Speedup |
|
||||
|---|---|---|---|---|
|
||||
| **Bulk Health** | 7,059 ms | 3 ms | 1 ms | **7,059×** |
|
||||
| **Node Analytics** | 381 ms | 2 ms | 1 ms | **381×** |
|
||||
| **Hash Sizes** | 353 ms | 193 ms | 1 ms | **353×** |
|
||||
| **Topology** | 685 ms | 579 ms | 2 ms | **342×** |
|
||||
| **RF Analytics** | 253 ms | 235 ms | 1 ms | **253×** |
|
||||
| **Channels** | 206 ms | 77 ms | 1 ms | **206×** |
|
||||
| **Node Health** | 195 ms | 1 ms | 1 ms | **195×** |
|
||||
| **Node Detail** | 133 ms | 1 ms | 1 ms | **133×** |
|
||||
| **Channel Analytics** | 95 ms | 73 ms | 2 ms | **47×** |
|
||||
| **Packets (grouped)** | 76 ms | 33 ms | 28 ms | **2×** |
|
||||
| **Stats** | 2 ms | 1 ms | 1 ms | 2× |
|
||||
| **Nodes List** | 3 ms | 2 ms | 2 ms | 1× |
|
||||
| **Observers** | 1 ms | 8 ms | 1 ms | 1× |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Two-Layer Performance Stack
|
||||
|
||||
1. **In-Memory Packet Store** (`packet-store.js`)
|
||||
- All packets loaded from SQLite into RAM on startup (~28K packets = ~12MB)
|
||||
- Indexed by `id`, `hash`, `observer`, and `node` (Map-based O(1) lookup)
|
||||
- Ring buffer with configurable max memory (default 1GB, ~2.3M packets)
|
||||
- SQLite becomes **write-only** for packets — reads never touch disk
|
||||
- New packets from MQTT written to both RAM + SQLite
|
||||
|
||||
2. **TTL Cache** (`server.js`)
|
||||
- Computed API responses cached with configurable TTLs (via `config.json`)
|
||||
- Smart invalidation: packet bursts only invalidate channels/observers; analytics expire by TTL only
|
||||
- Pre-warmed on startup: subpaths, RF, topology, channels, hash-sizes, bulk-health
|
||||
- Result: most API responses served in **1-2ms** from cache
|
||||
|
||||
### Key Optimizations
|
||||
|
||||
- **Eliminated all `LIKE '%pubkey%'` queries**: Every node-specific endpoint was doing full-table scans on the packets table via `decoded_json LIKE '%pubkey%'`. Replaced with O(1) `pktStore.byNode` Map lookups.
|
||||
- **Single-pass computations**: Channels, analytics, and subpaths computed in one loop instead of multiple SQL queries.
|
||||
- **Client-side WebSocket prepend**: New packets appended to the table without re-fetching the API.
|
||||
- **RF response compression**: Server-side histograms + scatter downsampling (1MB → 15KB).
|
||||
- **Configurable everything**: All TTLs, packet store limits, and thresholds in `config.json`.
|
||||
|
||||
### What Didn't Work
|
||||
|
||||
- **Background refresh (`setInterval`)**: Attempted to re-warm caches at 80% TTL. Blocked the event loop — Node.js is single-threaded. Response times went from 3ms to 1,200ms. Reverted immediately.
|
||||
- **Worker threads**: `structuredClone` overhead of 416ms for 28K packets negated the compute savings. Only viable at 10× data growth or with `SharedArrayBuffer` (zero-copy).
|
||||
|
||||
## Running the Benchmark
|
||||
|
||||
```bash
|
||||
# Stop the production server first
|
||||
supervisorctl stop meshcore-analyzer
|
||||
|
||||
# Run A/B benchmark (launches two servers: old v2.0.1 vs current)
|
||||
./benchmark-ab.sh
|
||||
|
||||
# Restart production
|
||||
supervisorctl start meshcore-analyzer
|
||||
```
|
||||
@@ -48,14 +48,87 @@ Full experience on your phone — proper touch controls, iOS safe area support,
|
||||
- **Observer Status** — health monitoring, packet counts, uptime
|
||||
- **Hash Collision Matrix** — detect address collisions across the mesh
|
||||
- **Claimed Nodes** — star your nodes, always sorted to top, visual distinction
|
||||
- **Dark / Light Mode** — auto-detects system preference, instant toggle
|
||||
- **Dark / Light Mode** — auto-detects system preference, instant toggle, map tiles swap too
|
||||
- **Multi-Broker MQTT** — connect to multiple MQTT brokers simultaneously with per-source IATA filtering
|
||||
- **Observer Detail Pages** — click any observer for analytics, charts, status, radio info, recent packets
|
||||
- **Channel Key Auto-Derivation** — hashtag channels (`#channel`) keys derived automatically via SHA256
|
||||
- **Global Search** — search packets, nodes, and channels (Ctrl+K)
|
||||
- **Shareable URLs** — deep links to individual packets, channels, and observer detail pages
|
||||
- **Mobile Responsive** — proper two-row VCR bar, iOS safe area support, touch-friendly
|
||||
- **Accessible** — ARIA patterns, keyboard navigation, screen reader support, distinct marker shapes
|
||||
|
||||
### ⚡ Performance (v2.1.1)
|
||||
|
||||
Two-layer caching architecture: in-memory packet store + TTL response cache. All packet reads served from RAM — SQLite is write-only. Heavy endpoints pre-warmed on startup.
|
||||
|
||||
| Endpoint | Before | After | Speedup |
|
||||
|---|---|---|---|
|
||||
| Bulk Health | 7,059 ms | 1 ms | **7,059×** |
|
||||
| Node Analytics | 381 ms | 1 ms | **381×** |
|
||||
| Topology | 685 ms | 2 ms | **342×** |
|
||||
| Node Health | 195 ms | 1 ms | **195×** |
|
||||
| Node Detail | 133 ms | 1 ms | **133×** |
|
||||
|
||||
See [PERFORMANCE.md](PERFORMANCE.md) for the full benchmark.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
### Docker (Recommended)
|
||||
|
||||
The easiest way to run MeshCore Analyzer. Includes Mosquitto MQTT broker — everything in one container.
|
||||
|
||||
```bash
|
||||
docker build -t meshcore-analyzer .
|
||||
docker run -d \
|
||||
--name meshcore-analyzer \
|
||||
-p 80:80 \
|
||||
-p 443:443 \
|
||||
-p 1883:1883 \
|
||||
-v meshcore-data:/app/data \
|
||||
-v caddy-certs:/data/caddy \
|
||||
meshcore-analyzer
|
||||
```
|
||||
|
||||
Open `http://localhost`. Point your MeshCore gateway's MQTT to `<host-ip>:1883`.
|
||||
|
||||
**With a domain (automatic HTTPS):**
|
||||
```bash
|
||||
# Create a Caddyfile with your domain
|
||||
echo 'analyzer.example.com { reverse_proxy localhost:3000 }' > Caddyfile
|
||||
|
||||
docker run -d \
|
||||
--name meshcore-analyzer \
|
||||
-p 80:80 \
|
||||
-p 443:443 \
|
||||
-p 1883:1883 \
|
||||
-v meshcore-data:/app/data \
|
||||
-v caddy-certs:/data/caddy \
|
||||
-v $(pwd)/Caddyfile:/etc/caddy/Caddyfile \
|
||||
meshcore-analyzer
|
||||
```
|
||||
|
||||
Caddy automatically provisions Let's Encrypt TLS certificates.
|
||||
|
||||
**Custom config:**
|
||||
```bash
|
||||
# Copy and edit the example config
|
||||
cp config.example.json config.json
|
||||
# Edit config.json with your channel keys, regions, etc.
|
||||
|
||||
docker run -d \
|
||||
--name meshcore-analyzer \
|
||||
-p 3000:3000 \
|
||||
-p 1883:1883 \
|
||||
-v meshcore-data:/app/data \
|
||||
-v $(pwd)/config.json:/app/config.json \
|
||||
meshcore-analyzer
|
||||
```
|
||||
|
||||
**Persist your database** across container rebuilds by using a named volume (`meshcore-data`) or bind mount (`-v ./data:/app/data`).
|
||||
|
||||
### Manual Install
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- **Node.js** 18+ (tested with 22.x)
|
||||
- **MQTT broker** (Mosquitto recommended) — optional, can inject packets via API
|
||||
@@ -79,6 +152,17 @@ Edit `config.json`:
|
||||
"broker": "mqtt://localhost:1883",
|
||||
"topic": "meshcore/+/+/packets"
|
||||
},
|
||||
"mqttSources": [
|
||||
{
|
||||
"name": "remote-feed",
|
||||
"broker": "mqtts://remote-broker:8883",
|
||||
"topics": ["meshcore/+/+/packets", "meshcore/+/+/status"],
|
||||
"username": "user",
|
||||
"password": "pass",
|
||||
"rejectUnauthorized": false,
|
||||
"iataFilter": ["SJC", "SFO", "OAK"]
|
||||
}
|
||||
],
|
||||
"channelKeys": {
|
||||
"public": "8b3387e9c5cdea6ac9e5edbaa115cd72"
|
||||
},
|
||||
@@ -94,9 +178,16 @@ Edit `config.json`:
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `port` | HTTP server port (default: 3000) |
|
||||
| `mqtt.broker` | MQTT broker URL. Set to `""` to disable MQTT and use API-only mode |
|
||||
| `mqtt.broker` | Local MQTT broker URL. Set to `""` to disable |
|
||||
| `mqtt.topic` | MQTT topic pattern for packet ingestion |
|
||||
| `channelKeys` | Named channel decryption keys (hex). `public` is the default MeshCore public channel |
|
||||
| `mqttSources` | Array of external MQTT broker connections (optional) |
|
||||
| `mqttSources[].name` | Friendly name for logging |
|
||||
| `mqttSources[].broker` | Broker URL (`mqtt://` or `mqtts://` for TLS) |
|
||||
| `mqttSources[].topics` | Array of MQTT topic patterns to subscribe to |
|
||||
| `mqttSources[].username` / `password` | Broker credentials |
|
||||
| `mqttSources[].rejectUnauthorized` | Set `false` for self-signed TLS certs |
|
||||
| `mqttSources[].iataFilter` | Only accept packets from these IATA regions |
|
||||
| `channelKeys` | Named channel decryption keys (hex). Hashtag channels auto-derived via SHA256 |
|
||||
| `defaultRegion` | Default IATA region code for the UI |
|
||||
| `regions` | Map of IATA codes to human-readable region names |
|
||||
|
||||
@@ -158,17 +249,26 @@ Observer Node → USB → meshcoretomqtt → MQTT Broker → Analyzer Server →
|
||||
|
||||
```
|
||||
meshcore-analyzer/
|
||||
├── config.json # MQTT, channel keys, regions
|
||||
├── Dockerfile # Single-container build (Node + Mosquitto + Caddy)
|
||||
├── .dockerignore
|
||||
├── config.example.json # Example config (copy to config.json)
|
||||
├── config.json # MQTT, channel keys, regions (gitignored)
|
||||
├── server.js # Express + WebSocket + MQTT + REST API
|
||||
├── decoder.js # Custom MeshCore packet decoder
|
||||
├── db.js # SQLite schema + queries
|
||||
├── packet-store.js # In-memory packet store (ring buffer, indexed)
|
||||
├── docker/
|
||||
│ ├── supervisord.conf # Process manager config
|
||||
│ ├── mosquitto.conf # MQTT broker config
|
||||
│ ├── Caddyfile # Default Caddy config (localhost)
|
||||
│ └── entrypoint.sh # Container entrypoint
|
||||
├── data/
|
||||
│ └── meshcore.db # Packet database (auto-created)
|
||||
├── public/
|
||||
│ ├── index.html # SPA shell
|
||||
│ ├── style.css # Theme (light/dark)
|
||||
│ ├── app.js # Router, WebSocket, utilities
|
||||
│ ├── packets.js # Packet feed + byte breakdown
|
||||
│ ├── packets.js # Packet feed + byte breakdown + detail page
|
||||
│ ├── map.js # Leaflet map with route visualization
|
||||
│ ├── live.js # Live trace page with VCR playback
|
||||
│ ├── channels.js # Channel chat
|
||||
@@ -176,7 +276,10 @@ meshcore-analyzer/
|
||||
│ ├── analytics.js # Global analytics dashboard
|
||||
│ ├── node-analytics.js # Per-node analytics with charts
|
||||
│ ├── traces.js # Packet tracing
|
||||
│ └── observers.js # Observer status
|
||||
│ ├── observers.js # Observer status
|
||||
│ ├── observer-detail.js # Observer detail with analytics
|
||||
│ ├── home.js # Dashboard home page
|
||||
│ └── perf.js # Performance monitoring dashboard
|
||||
└── tools/
|
||||
├── generate-packets.js # Synthetic packet generator
|
||||
├── e2e-test.js # End-to-end API tests
|
||||
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/bin/bash
|
||||
# A/B benchmark: old (pre-perf) vs new (current)
|
||||
# Usage: ./benchmark-ab.sh
|
||||
set -e
|
||||
|
||||
PORT_OLD=13003
|
||||
PORT_NEW=13004
|
||||
RUNS=3
|
||||
DB_PATH="$(pwd)/data/meshcore.db"
|
||||
|
||||
OLD_COMMIT="23caae4"
|
||||
NEW_COMMIT="$(git rev-parse HEAD)"
|
||||
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo " A/B Benchmark: Pre-optimization vs Current"
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo "OLD: $OLD_COMMIT (v2.0.1 — before any perf work)"
|
||||
echo "NEW: $NEW_COMMIT (current)"
|
||||
echo "Runs per endpoint: $RUNS"
|
||||
echo ""
|
||||
|
||||
# Get a real node pubkey for testing
|
||||
ORIG_DIR="$(pwd)"
|
||||
PUBKEY=$(sqlite3 "$DB_PATH" "SELECT public_key FROM nodes ORDER BY last_seen DESC LIMIT 1")
|
||||
echo "Test node: ${PUBKEY:0:16}..."
|
||||
echo ""
|
||||
|
||||
# Setup old version in temp dir
|
||||
OLD_DIR=$(mktemp -d)
|
||||
echo "Cloning old version to $OLD_DIR..."
|
||||
git worktree add "$OLD_DIR" "$OLD_COMMIT" --quiet 2>/dev/null || {
|
||||
git worktree add "$OLD_DIR" "$OLD_COMMIT" --detach --quiet
|
||||
}
|
||||
# Copy config + db symlink
|
||||
# Copy config + db + share node_modules
|
||||
cp config.json "$OLD_DIR/"
|
||||
mkdir -p "$OLD_DIR/data"
|
||||
cp "$ORIG_DIR/data/meshcore.db" "$OLD_DIR/data/meshcore.db"
|
||||
ln -sf "$ORIG_DIR/node_modules" "$OLD_DIR/node_modules"
|
||||
|
||||
ENDPOINTS=(
|
||||
"Stats|/api/stats"
|
||||
"Packets(50)|/api/packets?limit=50"
|
||||
"PacketsGrouped|/api/packets?limit=50&groupByHash=true"
|
||||
"NodesList|/api/nodes?limit=50"
|
||||
"NodeDetail|/api/nodes/$PUBKEY"
|
||||
"NodeHealth|/api/nodes/$PUBKEY/health"
|
||||
"NodeAnalytics|/api/nodes/$PUBKEY/analytics?days=7"
|
||||
"BulkHealth|/api/nodes/bulk-health?limit=50"
|
||||
"NetworkStatus|/api/nodes/network-status"
|
||||
"Channels|/api/channels"
|
||||
"Observers|/api/observers"
|
||||
"RF|/api/analytics/rf"
|
||||
"Topology|/api/analytics/topology"
|
||||
"ChannelAnalytics|/api/analytics/channels"
|
||||
"HashSizes|/api/analytics/hash-sizes"
|
||||
)
|
||||
|
||||
bench_endpoint() {
|
||||
local port=$1 path=$2 runs=$3 nocache=$4
|
||||
local total=0
|
||||
for i in $(seq 1 $runs); do
|
||||
local url="http://127.0.0.1:$port$path"
|
||||
if [ "$nocache" = "1" ]; then
|
||||
if echo "$path" | grep -q '?'; then
|
||||
url="${url}&nocache=1"
|
||||
else
|
||||
url="${url}?nocache=1"
|
||||
fi
|
||||
fi
|
||||
local ms=$(curl -s -o /dev/null -w "%{time_total}" "$url" 2>/dev/null)
|
||||
local ms_int=$(echo "$ms * 1000" | bc | cut -d. -f1)
|
||||
total=$((total + ms_int))
|
||||
done
|
||||
echo $((total / runs))
|
||||
}
|
||||
|
||||
# Launch old server
|
||||
echo "Starting OLD server (port $PORT_OLD)..."
|
||||
cd "$OLD_DIR"
|
||||
PORT=$PORT_OLD node server.js &>/dev/null &
|
||||
OLD_PID=$!
|
||||
cd - >/dev/null
|
||||
|
||||
# Launch new server
|
||||
echo "Starting NEW server (port $PORT_NEW)..."
|
||||
PORT=$PORT_NEW node server.js &>/dev/null &
|
||||
NEW_PID=$!
|
||||
|
||||
# Wait for both
|
||||
sleep 12 # old server has no memory store; new needs prewarm
|
||||
|
||||
# Verify
|
||||
curl -s "http://127.0.0.1:$PORT_OLD/api/stats" >/dev/null 2>&1 || { echo "OLD server failed to start"; kill $OLD_PID $NEW_PID 2>/dev/null; exit 1; }
|
||||
curl -s "http://127.0.0.1:$PORT_NEW/api/stats" >/dev/null 2>&1 || { echo "NEW server failed to start"; kill $OLD_PID $NEW_PID 2>/dev/null; exit 1; }
|
||||
|
||||
echo ""
|
||||
echo "Warming up caches on new server..."
|
||||
for ep in "${ENDPOINTS[@]}"; do
|
||||
path="${ep#*|}"
|
||||
curl -s -o /dev/null "http://127.0.0.1:$PORT_NEW$path" 2>/dev/null
|
||||
done
|
||||
sleep 2
|
||||
|
||||
printf "\n%-22s %9s %9s %9s %9s\n" "Endpoint" "Old(ms)" "New-cold" "New-cache" "Speedup"
|
||||
printf "%-22s %9s %9s %9s %9s\n" "──────────────────────" "─────────" "─────────" "─────────" "─────────"
|
||||
|
||||
for ep in "${ENDPOINTS[@]}"; do
|
||||
name="${ep%%|*}"
|
||||
path="${ep#*|}"
|
||||
|
||||
old_ms=$(bench_endpoint $PORT_OLD "$path" $RUNS 0)
|
||||
new_cold=$(bench_endpoint $PORT_NEW "$path" $RUNS 1)
|
||||
new_cached=$(bench_endpoint $PORT_NEW "$path" $RUNS 0)
|
||||
|
||||
if [ "$old_ms" -gt 0 ] && [ "$new_cached" -gt 0 ]; then
|
||||
speedup="${old_ms}/${new_cached}"
|
||||
speedup_x=$(echo "scale=0; $old_ms / $new_cached" | bc 2>/dev/null || echo "?")
|
||||
printf "%-22s %7dms %7dms %7dms %7d×\n" "$name" "$old_ms" "$new_cold" "$new_cached" "$speedup_x"
|
||||
else
|
||||
printf "%-22s %7dms %7dms %7dms %9s\n" "$name" "$old_ms" "$new_cold" "$new_cached" "∞"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
|
||||
# Cleanup
|
||||
kill $OLD_PID $NEW_PID 2>/dev/null
|
||||
git worktree remove "$OLD_DIR" --force 2>/dev/null
|
||||
echo "Done."
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Benchmark suite for meshcore-analyzer.
|
||||
* Launches two server instances — one with in-memory store, one with pure SQLite —
|
||||
* and compares performance side by side.
|
||||
*
|
||||
* Usage: node benchmark.js [--runs 5] [--json]
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const RUNS = Number(args.find((a, i) => args[i - 1] === '--runs') || 5);
|
||||
const JSON_OUT = args.includes('--json');
|
||||
|
||||
const PORT_MEM = 13001; // In-memory store
|
||||
const PORT_SQL = 13002; // SQLite-only
|
||||
|
||||
const ENDPOINTS = [
|
||||
{ name: 'Stats', path: '/api/stats' },
|
||||
{ name: 'Packets (50)', path: '/api/packets?limit=50' },
|
||||
{ name: 'Packets (100)', path: '/api/packets?limit=100' },
|
||||
{ name: 'Packets grouped', path: '/api/packets?limit=100&groupByHash=true' },
|
||||
{ name: 'Packets filtered', path: '/api/packets?limit=50&type=5' },
|
||||
{ name: 'Packets timestamps', path: '/api/packets/timestamps?since=2020-01-01' },
|
||||
{ name: 'Nodes list', path: '/api/nodes?limit=50' },
|
||||
{ name: 'Node detail', path: '/api/nodes/__FIRST_NODE__' },
|
||||
{ name: 'Node health', path: '/api/nodes/__FIRST_NODE__/health' },
|
||||
{ name: 'Bulk health', path: '/api/nodes/bulk-health?limit=50' },
|
||||
{ name: 'Network status', path: '/api/nodes/network-status' },
|
||||
{ name: 'Observers', path: '/api/observers' },
|
||||
{ name: 'Channels', path: '/api/channels' },
|
||||
{ name: 'RF Analytics', path: '/api/analytics/rf' },
|
||||
{ name: 'Topology', path: '/api/analytics/topology' },
|
||||
{ name: 'Channel Analytics', path: '/api/analytics/channels' },
|
||||
{ name: 'Hash Sizes', path: '/api/analytics/hash-sizes' },
|
||||
{ name: 'Subpaths 2-hop', path: '/api/analytics/subpaths?minLen=2&maxLen=2&limit=50' },
|
||||
{ name: 'Subpaths 3-hop', path: '/api/analytics/subpaths?minLen=3&maxLen=3&limit=30' },
|
||||
{ name: 'Subpaths 4-hop', path: '/api/analytics/subpaths?minLen=4&maxLen=4&limit=20' },
|
||||
{ name: 'Subpaths 5-8 hop', path: '/api/analytics/subpaths?minLen=5&maxLen=8&limit=15' },
|
||||
];
|
||||
|
||||
function fetch(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t0 = process.hrtime.bigint();
|
||||
const req = http.get(url, (res) => {
|
||||
let body = '';
|
||||
res.on('data', c => body += c);
|
||||
res.on('end', () => {
|
||||
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
||||
resolve({ ms, bytes: Buffer.byteLength(body), status: res.statusCode, body });
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(60000, () => { req.destroy(); reject(new Error('timeout')); });
|
||||
});
|
||||
}
|
||||
|
||||
function median(arr) { const s = [...arr].sort((a,b)=>a-b); return s[Math.floor(s.length/2)]; }
|
||||
function p95(arr) { const s = [...arr].sort((a,b)=>a-b); return s[Math.floor(s.length*0.95)]; }
|
||||
function avg(arr) { return arr.reduce((a,b)=>a+b,0)/arr.length; }
|
||||
function fmt(ms) { return ms >= 1000 ? (ms/1000).toFixed(1)+'s' : ms.toFixed(1)+'ms'; }
|
||||
function fmtSize(b) { return b >= 1048576 ? (b/1048576).toFixed(1)+'MB' : b >= 1024 ? (b/1024).toFixed(0)+'KB' : b+'B'; }
|
||||
|
||||
function launchServer(port, env = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('node', ['server.js'], {
|
||||
cwd: __dirname,
|
||||
env: { ...process.env, PORT: String(port), ...env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let started = false;
|
||||
const timeout = setTimeout(() => { if (!started) { child.kill(); reject(new Error('Server start timeout')); } }, 30000);
|
||||
|
||||
child.stdout.on('data', (d) => {
|
||||
if (!started && (d.toString().includes('listening') || d.toString().includes('running'))) {
|
||||
started = true; clearTimeout(timeout); resolve(child);
|
||||
}
|
||||
});
|
||||
child.stderr.on('data', (d) => {
|
||||
if (!started && (d.toString().includes('listening') || d.toString().includes('running'))) {
|
||||
started = true; clearTimeout(timeout); resolve(child);
|
||||
}
|
||||
});
|
||||
child.on('exit', (code) => { if (!started) { clearTimeout(timeout); reject(new Error(`Server exited with ${code}`)); } });
|
||||
|
||||
// Fallback: wait longer (SQLite-only mode pre-warms subpaths ~6s)
|
||||
setTimeout(() => {
|
||||
if (!started) {
|
||||
started = true; clearTimeout(timeout);
|
||||
resolve(child);
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer(port, maxMs = 20000) {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < maxMs) {
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${port}/api/stats`);
|
||||
if (r.status === 200) return true;
|
||||
} catch {}
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`Server on port ${port} didn't start`);
|
||||
}
|
||||
|
||||
async function benchmarkEndpoints(port, endpoints, nocache = false) {
|
||||
const results = [];
|
||||
for (const ep of endpoints) {
|
||||
const suffix = nocache ? (ep.path.includes('?') ? '&nocache=1' : '?nocache=1') : '';
|
||||
const url = `http://127.0.0.1:${port}${ep.path}${suffix}`;
|
||||
|
||||
// Warm-up
|
||||
try { await fetch(url); } catch {}
|
||||
|
||||
const times = [];
|
||||
let bytes = 0;
|
||||
let failed = false;
|
||||
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
if (r.status !== 200) { failed = true; break; }
|
||||
times.push(r.ms);
|
||||
bytes = r.bytes;
|
||||
} catch { failed = true; break; }
|
||||
}
|
||||
|
||||
if (failed || !times.length) {
|
||||
results.push({ name: ep.name, failed: true });
|
||||
} else {
|
||||
results.push({
|
||||
name: ep.name,
|
||||
avg: Math.round(avg(times) * 10) / 10,
|
||||
p50: Math.round(median(times) * 10) / 10,
|
||||
p95: Math.round(p95(times) * 10) / 10,
|
||||
bytes
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log(`\nMeshCore Analyzer Benchmark — ${RUNS} runs per endpoint`);
|
||||
console.log('Launching servers...\n');
|
||||
|
||||
// Launch both servers
|
||||
let memServer, sqlServer;
|
||||
try {
|
||||
console.log(' Starting in-memory server (port ' + PORT_MEM + ')...');
|
||||
memServer = await launchServer(PORT_MEM, {});
|
||||
await waitForServer(PORT_MEM);
|
||||
console.log(' ✅ In-memory server ready');
|
||||
|
||||
console.log(' Starting SQLite-only server (port ' + PORT_SQL + ')...');
|
||||
sqlServer = await launchServer(PORT_SQL, { NO_MEMORY_STORE: '1' });
|
||||
await waitForServer(PORT_SQL);
|
||||
console.log(' ✅ SQLite-only server ready\n');
|
||||
} catch (e) {
|
||||
console.error('Failed to start servers:', e.message);
|
||||
if (memServer) memServer.kill();
|
||||
if (sqlServer) sqlServer.kill();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get first node pubkey
|
||||
let firstNode = '';
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${PORT_MEM}/api/nodes?limit=1`);
|
||||
const data = JSON.parse(r.body);
|
||||
firstNode = data.nodes?.[0]?.public_key || '';
|
||||
} catch {}
|
||||
|
||||
const endpoints = ENDPOINTS.map(e => ({
|
||||
...e,
|
||||
path: e.path.replace('__FIRST_NODE__', firstNode),
|
||||
}));
|
||||
|
||||
// Get packet count
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${PORT_MEM}/api/stats`);
|
||||
const stats = JSON.parse(r.body);
|
||||
console.log(`Dataset: ${(stats.totalPackets || '?').toLocaleString()} packets\n`);
|
||||
} catch {}
|
||||
|
||||
// Run benchmarks
|
||||
console.log('Benchmarking in-memory store (nocache for true compute cost)...');
|
||||
const memResults = await benchmarkEndpoints(PORT_MEM, endpoints, true);
|
||||
|
||||
console.log('Benchmarking SQLite-only (nocache)...');
|
||||
const sqlResults = await benchmarkEndpoints(PORT_SQL, endpoints, true);
|
||||
|
||||
// Also test cached in-memory for the full picture
|
||||
console.log('Benchmarking in-memory store (cached)...');
|
||||
const memCachedResults = await benchmarkEndpoints(PORT_MEM, endpoints, false);
|
||||
|
||||
// Kill servers
|
||||
memServer.kill();
|
||||
sqlServer.kill();
|
||||
|
||||
if (JSON_OUT) {
|
||||
console.log(JSON.stringify({ memoryNocache: memResults, sqliteNocache: sqlResults, memoryCached: memCachedResults }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
// Print results
|
||||
const W = 94;
|
||||
console.log(`\n${'═'.repeat(W)}`);
|
||||
console.log(' 🏁 BENCHMARK RESULTS: SQLite vs In-Memory Store');
|
||||
console.log(`${'═'.repeat(W)}`);
|
||||
console.log(`${'Endpoint'.padEnd(24)} ${'SQLite'.padStart(9)} ${'Memory'.padStart(9)} ${'Cached'.padStart(9)} ${'Speedup'.padStart(9)} ${'Size (SQL)'.padStart(10)} ${'Size (Mem)'.padStart(10)}`);
|
||||
console.log(`${'─'.repeat(24)} ${'─'.repeat(9)} ${'─'.repeat(9)} ${'─'.repeat(9)} ${'─'.repeat(9)} ${'─'.repeat(10)} ${'─'.repeat(10)}`);
|
||||
|
||||
for (let i = 0; i < endpoints.length; i++) {
|
||||
const sql = sqlResults[i];
|
||||
const mem = memResults[i];
|
||||
const cached = memCachedResults[i];
|
||||
if (!sql || sql.failed || !mem || mem.failed) {
|
||||
console.log(`${endpoints[i].name.padEnd(24)} ${'FAILED'.padStart(9)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const speedup = sql.avg > 0 && mem.avg > 0 ? Math.round(sql.avg / mem.avg) + '×' : '—';
|
||||
const cachedStr = cached && !cached.failed ? fmt(cached.avg) : '—';
|
||||
|
||||
console.log(
|
||||
`${sql.name.padEnd(24)} ${fmt(sql.avg).padStart(9)} ${fmt(mem.avg).padStart(9)} ${cachedStr.padStart(9)} ${speedup.padStart(9)} ${fmtSize(sql.bytes).padStart(10)} ${fmtSize(mem.bytes).padStart(10)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Summary
|
||||
const sqlTotal = sqlResults.filter(r => !r.failed).reduce((s, r) => s + r.avg, 0);
|
||||
const memTotal = memResults.filter(r => !r.failed).reduce((s, r) => s + r.avg, 0);
|
||||
console.log(`${'─'.repeat(24)} ${'─'.repeat(9)} ${'─'.repeat(9)} ${'─'.repeat(9)} ${'─'.repeat(9)}`);
|
||||
console.log(`${'TOTAL'.padEnd(24)} ${fmt(sqlTotal).padStart(9)} ${fmt(memTotal).padStart(9)} ${''.padStart(9)} ${(Math.round(sqlTotal/memTotal)+'×').padStart(9)}`);
|
||||
console.log(`\n${'═'.repeat(W)}\n`);
|
||||
}
|
||||
|
||||
run().catch(e => { console.error(e); process.exit(1); });
|
||||
+56
-2
@@ -4,6 +4,35 @@
|
||||
"broker": "mqtt://localhost:1883",
|
||||
"topic": "meshcore/+/+/packets"
|
||||
},
|
||||
"mqttSources": [
|
||||
{
|
||||
"name": "local",
|
||||
"broker": "mqtt://localhost:1883",
|
||||
"topics": [
|
||||
"meshcore/+/+/packets",
|
||||
"meshcore/#"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "lincomatic",
|
||||
"broker": "mqtts://mqtt.lincomatic.com:8883",
|
||||
"username": "your-username",
|
||||
"password": "your-password",
|
||||
"rejectUnauthorized": false,
|
||||
"topics": [
|
||||
"meshcore/SJC/#",
|
||||
"meshcore/SFO/#",
|
||||
"meshcore/OAK/#",
|
||||
"meshcore/MRY/#"
|
||||
],
|
||||
"iataFilter": [
|
||||
"SJC",
|
||||
"SFO",
|
||||
"OAK",
|
||||
"MRY"
|
||||
]
|
||||
}
|
||||
],
|
||||
"channelKeys": {
|
||||
"public": "8b3387e9c5cdea6ac9e5edbaa115cd72",
|
||||
"#test": "9cd8fcf22a47333b591d96a2b848b73f",
|
||||
@@ -20,7 +49,32 @@
|
||||
"SJC": "San Jose, US",
|
||||
"SFO": "San Francisco, US",
|
||||
"OAK": "Oakland, US",
|
||||
"MRY": "Monterey, US",
|
||||
"LAR": "Los Angeles, US"
|
||||
"MRY": "Monterey, US"
|
||||
},
|
||||
"cacheTTL": {
|
||||
"stats": 10,
|
||||
"nodeDetail": 300,
|
||||
"nodeHealth": 300,
|
||||
"nodeList": 90,
|
||||
"bulkHealth": 600,
|
||||
"networkStatus": 600,
|
||||
"observers": 300,
|
||||
"channels": 15,
|
||||
"channelMessages": 10,
|
||||
"analyticsRF": 1800,
|
||||
"analyticsTopology": 1800,
|
||||
"analyticsChannels": 1800,
|
||||
"analyticsHashSizes": 3600,
|
||||
"analyticsSubpaths": 3600,
|
||||
"analyticsSubpathDetail": 3600,
|
||||
"nodeAnalytics": 60,
|
||||
"nodeSearch": 10,
|
||||
"invalidationDebounce": 30,
|
||||
"_comment": "All values in seconds. Server uses these directly. Client fetches via /api/config/cache."
|
||||
},
|
||||
"packetStore": {
|
||||
"maxMemoryMB": 1024,
|
||||
"estimatedPacketBytes": 450,
|
||||
"_comment": "In-memory packet store. maxMemoryMB caps RAM usage. All packets loaded on startup, served from RAM."
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,14 @@ db.exec(`
|
||||
iata TEXT,
|
||||
last_seen TEXT,
|
||||
first_seen TEXT,
|
||||
packet_count INTEGER DEFAULT 0
|
||||
packet_count INTEGER DEFAULT 0,
|
||||
model TEXT,
|
||||
firmware TEXT,
|
||||
client_version TEXT,
|
||||
radio TEXT,
|
||||
battery_mv INTEGER,
|
||||
uptime_secs INTEGER,
|
||||
noise_floor INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paths (
|
||||
@@ -64,8 +71,53 @@ db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_packets_payload_type ON packets(payload_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_last_seen ON nodes(last_seen);
|
||||
CREATE INDEX IF NOT EXISTS idx_observers_last_seen ON observers(last_seen);
|
||||
|
||||
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),
|
||||
hash TEXT NOT NULL,
|
||||
observer_id TEXT,
|
||||
observer_name TEXT,
|
||||
direction TEXT,
|
||||
snr REAL,
|
||||
rssi REAL,
|
||||
score INTEGER,
|
||||
path_json TEXT,
|
||||
timestamp TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transmissions_hash ON transmissions(hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_transmissions_first_seen ON transmissions(first_seen);
|
||||
CREATE INDEX IF NOT EXISTS idx_transmissions_payload_type ON transmissions(payload_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_hash ON observations(hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_transmission_id ON observations(transmission_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_observer_id ON observations(observer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_timestamp ON observations(timestamp);
|
||||
`);
|
||||
|
||||
// --- Migrations for existing DBs ---
|
||||
const observerCols = db.pragma('table_info(observers)').map(c => c.name);
|
||||
for (const col of ['model', 'firmware', 'client_version', 'radio', 'battery_mv', 'uptime_secs', 'noise_floor']) {
|
||||
if (!observerCols.includes(col)) {
|
||||
const type = ['battery_mv', 'uptime_secs', 'noise_floor'].includes(col) ? 'INTEGER' : 'TEXT';
|
||||
db.exec(`ALTER TABLE observers ADD COLUMN ${col} ${type}`);
|
||||
console.log(`[migration] Added observers.${col}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Prepared statements ---
|
||||
const stmts = {
|
||||
insertPacket: db.prepare(`
|
||||
@@ -85,13 +137,35 @@ const stmts = {
|
||||
advert_count = advert_count + 1
|
||||
`),
|
||||
upsertObserver: db.prepare(`
|
||||
INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
|
||||
VALUES (@id, @name, @iata, @last_seen, @first_seen, 1)
|
||||
INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor)
|
||||
VALUES (@id, @name, @iata, @last_seen, @first_seen, 1, @model, @firmware, @client_version, @radio, @battery_mv, @uptime_secs, @noise_floor)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = COALESCE(@name, name),
|
||||
iata = COALESCE(@iata, iata),
|
||||
last_seen = @last_seen,
|
||||
packet_count = packet_count + 1
|
||||
packet_count = packet_count + 1,
|
||||
model = COALESCE(@model, model),
|
||||
firmware = COALESCE(@firmware, firmware),
|
||||
client_version = COALESCE(@client_version, client_version),
|
||||
radio = COALESCE(@radio, radio),
|
||||
battery_mv = COALESCE(@battery_mv, battery_mv),
|
||||
uptime_secs = COALESCE(@uptime_secs, uptime_secs),
|
||||
noise_floor = COALESCE(@noise_floor, noise_floor)
|
||||
`),
|
||||
updateObserverStatus: db.prepare(`
|
||||
INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor)
|
||||
VALUES (@id, @name, @iata, @last_seen, @first_seen, 0, @model, @firmware, @client_version, @radio, @battery_mv, @uptime_secs, @noise_floor)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = COALESCE(@name, name),
|
||||
iata = COALESCE(@iata, iata),
|
||||
last_seen = @last_seen,
|
||||
model = COALESCE(@model, model),
|
||||
firmware = COALESCE(@firmware, firmware),
|
||||
client_version = COALESCE(@client_version, client_version),
|
||||
radio = COALESCE(@radio, radio),
|
||||
battery_mv = COALESCE(@battery_mv, battery_mv),
|
||||
uptime_secs = COALESCE(@uptime_secs, uptime_secs),
|
||||
noise_floor = COALESCE(@noise_floor, noise_floor)
|
||||
`),
|
||||
getPacket: db.prepare(`SELECT * FROM packets WHERE id = ?`),
|
||||
getPathsForPacket: db.prepare(`SELECT * FROM paths WHERE packet_id = ? ORDER BY hop_index`),
|
||||
@@ -105,6 +179,16 @@ const stmts = {
|
||||
countNodes: db.prepare(`SELECT COUNT(*) as count FROM nodes`),
|
||||
countObservers: db.prepare(`SELECT COUNT(*) as count FROM observers`),
|
||||
countRecentPackets: db.prepare(`SELECT COUNT(*) as count FROM packets WHERE timestamp > ?`),
|
||||
getTransmissionByHash: db.prepare(`SELECT id, first_seen FROM transmissions WHERE hash = ?`),
|
||||
insertTransmission: db.prepare(`
|
||||
INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json)
|
||||
VALUES (@raw_hex, @hash, @first_seen, @route_type, @payload_type, @payload_version, @decoded_json)
|
||||
`),
|
||||
updateTransmissionFirstSeen: db.prepare(`UPDATE transmissions SET first_seen = @first_seen WHERE id = @id`),
|
||||
insertObservation: db.prepare(`
|
||||
INSERT INTO observations (transmission_id, hash, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp)
|
||||
VALUES (@transmission_id, @hash, @observer_id, @observer_name, @direction, @snr, @rssi, @score, @path_json, @timestamp)
|
||||
`),
|
||||
};
|
||||
|
||||
// --- Helper functions ---
|
||||
@@ -129,6 +213,49 @@ function insertPacket(data) {
|
||||
return stmts.insertPacket.run(d).lastInsertRowid;
|
||||
}
|
||||
|
||||
function insertTransmission(data) {
|
||||
const hash = data.hash;
|
||||
if (!hash) return null; // Can't deduplicate without a hash
|
||||
|
||||
const timestamp = data.timestamp || new Date().toISOString();
|
||||
let transmissionId;
|
||||
|
||||
const existing = stmts.getTransmissionByHash.get(hash);
|
||||
if (existing) {
|
||||
transmissionId = existing.id;
|
||||
// Update first_seen if this observation is earlier
|
||||
if (timestamp < existing.first_seen) {
|
||||
stmts.updateTransmissionFirstSeen.run({ id: transmissionId, first_seen: timestamp });
|
||||
}
|
||||
} else {
|
||||
const result = stmts.insertTransmission.run({
|
||||
raw_hex: data.raw_hex || '',
|
||||
hash,
|
||||
first_seen: timestamp,
|
||||
route_type: data.route_type ?? null,
|
||||
payload_type: data.payload_type ?? null,
|
||||
payload_version: data.payload_version ?? null,
|
||||
decoded_json: data.decoded_json || null,
|
||||
});
|
||||
transmissionId = result.lastInsertRowid;
|
||||
}
|
||||
|
||||
const obsResult = stmts.insertObservation.run({
|
||||
transmission_id: transmissionId,
|
||||
hash,
|
||||
observer_id: data.observer_id || null,
|
||||
observer_name: data.observer_name || null,
|
||||
direction: data.direction || null,
|
||||
snr: data.snr ?? null,
|
||||
rssi: data.rssi ?? null,
|
||||
score: data.score ?? null,
|
||||
path_json: data.path_json || null,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
return { transmissionId, observationId: obsResult.lastInsertRowid };
|
||||
}
|
||||
|
||||
function insertPath(packetId, hops) {
|
||||
const tx = db.transaction((hops) => {
|
||||
for (let i = 0; i < hops.length; i++) {
|
||||
@@ -159,6 +286,31 @@ function upsertObserver(data) {
|
||||
iata: data.iata || null,
|
||||
last_seen: data.last_seen || now,
|
||||
first_seen: data.first_seen || now,
|
||||
model: data.model || null,
|
||||
firmware: data.firmware || null,
|
||||
client_version: data.client_version || null,
|
||||
radio: data.radio || null,
|
||||
battery_mv: data.battery_mv || null,
|
||||
uptime_secs: data.uptime_secs || null,
|
||||
noise_floor: data.noise_floor || null,
|
||||
});
|
||||
}
|
||||
|
||||
function updateObserverStatus(data) {
|
||||
const now = new Date().toISOString();
|
||||
stmts.updateObserverStatus.run({
|
||||
id: data.id,
|
||||
name: data.name || null,
|
||||
iata: data.iata || null,
|
||||
last_seen: data.last_seen || now,
|
||||
first_seen: data.first_seen || now,
|
||||
model: data.model || null,
|
||||
firmware: data.firmware || null,
|
||||
client_version: data.client_version || null,
|
||||
radio: data.radio || null,
|
||||
battery_mv: data.battery_mv || null,
|
||||
uptime_secs: data.uptime_secs || null,
|
||||
noise_floor: data.noise_floor || null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -212,8 +364,15 @@ function getObservers() {
|
||||
|
||||
function getStats() {
|
||||
const oneHourAgo = new Date(Date.now() - 3600000).toISOString();
|
||||
// Try to get transmission count from normalized schema
|
||||
let totalTransmissions = null;
|
||||
try {
|
||||
totalTransmissions = db.prepare('SELECT COUNT(*) as count FROM transmissions').get().count;
|
||||
} catch {}
|
||||
return {
|
||||
totalPackets: stmts.countPackets.get().count,
|
||||
totalTransmissions,
|
||||
totalObservations: stmts.countPackets.get().count, // legacy packets = observations
|
||||
totalNodes: stmts.countNodes.get().count,
|
||||
totalObservers: stmts.countObservers.get().count,
|
||||
packetsLastHour: stmts.countRecentPackets.get(oneHourAgo).count,
|
||||
@@ -225,13 +384,13 @@ function seed() {
|
||||
const now = new Date().toISOString();
|
||||
const rawHex = '11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172';
|
||||
|
||||
upsertObserver({ id: 'obs-sjc-001', name: 'Iavor Observer', iata: 'SJC', last_seen: now, first_seen: now });
|
||||
upsertObserver({ id: 'obs-seed-001', name: 'Seed Observer', iata: 'UNK', last_seen: now, first_seen: now });
|
||||
|
||||
const pktId = insertPacket({
|
||||
raw_hex: rawHex,
|
||||
timestamp: now,
|
||||
observer_id: 'obs-sjc-001',
|
||||
observer_name: 'Iavor Observer',
|
||||
observer_id: 'obs-seed-001',
|
||||
observer_name: 'Seed Observer',
|
||||
direction: 'rx',
|
||||
snr: 10.5,
|
||||
rssi: -85,
|
||||
@@ -241,17 +400,17 @@ function seed() {
|
||||
payload_type: 4,
|
||||
payload_version: 1,
|
||||
path_json: JSON.stringify(['A1B2', 'C3D4']),
|
||||
decoded_json: JSON.stringify({ type: 'ADVERT', name: 'Kpa Roof Solar', role: 'repeater', lat: 37.31468, lon: -121.8921 }),
|
||||
decoded_json: JSON.stringify({ type: 'ADVERT', name: 'Test Repeater', role: 'repeater', lat: 0, lon: 0 }),
|
||||
});
|
||||
|
||||
insertPath(pktId, ['A1B2', 'C3D4']);
|
||||
|
||||
upsertNode({
|
||||
public_key: 'kpa-roof-solar-pubkey',
|
||||
name: 'Kpa Roof Solar',
|
||||
public_key: 'seed-test-pubkey',
|
||||
name: 'Test Repeater',
|
||||
role: 'repeater',
|
||||
lat: 37.31468,
|
||||
lon: -121.8921,
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
last_seen: now,
|
||||
first_seen: now,
|
||||
});
|
||||
@@ -493,4 +652,4 @@ function getNodeAnalytics(pubkey, days) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { db, insertPacket, insertPath, upsertNode, upsertObserver, getPackets, getPacket, getNodes, getNode, getObservers, getStats, seed, searchNodes, getNodeHealth, getNodeAnalytics };
|
||||
module.exports = { db, insertPacket, insertTransmission, insertPath, upsertNode, upsertObserver, updateObserverStatus, getPackets, getPacket, getNodes, getNode, getObservers, getStats, seed, searchNodes, getNodeHealth, getNodeAnalytics };
|
||||
|
||||
+2
-2
@@ -269,7 +269,7 @@ module.exports = { decodePacket, ROUTE_TYPES, PAYLOAD_TYPES };
|
||||
|
||||
// --- Tests ---
|
||||
if (require.main === module) {
|
||||
console.log('=== Test 1: ADVERT, FLOOD, 5 hops (2-byte hashes), "Kpa Roof Solar" ===');
|
||||
console.log('=== Test 1: ADVERT, FLOOD, 5 hops (2-byte hashes), "Test Repeater" ===');
|
||||
const pkt1 = decodePacket(
|
||||
'11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172'
|
||||
);
|
||||
@@ -285,7 +285,7 @@ if (require.main === module) {
|
||||
assert(pkt1.path.hops[0] === '1000', 'first hop should be 1000');
|
||||
assert(pkt1.path.hops[1] === 'D818', 'second hop should be D818');
|
||||
assert(pkt1.transportCodes === null, 'FLOOD has no transport codes');
|
||||
assert(pkt1.payload.name === 'Kpa Roof Solar', 'name should be "Kpa Roof Solar"');
|
||||
assert(pkt1.payload.name === 'Test Repeater', 'name should be "Test Repeater"');
|
||||
console.log('✅ Test 1 passed\n');
|
||||
|
||||
console.log('=== Test 2: ADVERT, FLOOD, 0 hops (zero-path) ===');
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Default Caddyfile — reverse proxy to Node app
|
||||
# Override by mounting your own: -v ./Caddyfile:/etc/caddy/Caddyfile
|
||||
#
|
||||
# For automatic HTTPS, replace :80 with your domain:
|
||||
# analyzer.example.com {
|
||||
# reverse_proxy localhost:3000
|
||||
# }
|
||||
|
||||
:80 {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Copy example config if no config.json exists
|
||||
if [ ! -f /app/config.json ]; then
|
||||
echo "[entrypoint] No config.json found, copying from config.example.json"
|
||||
cp /app/config.example.json /app/config.json
|
||||
fi
|
||||
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||
@@ -0,0 +1,10 @@
|
||||
# Mosquitto config for MeshCore Analyzer
|
||||
listener 1883 0.0.0.0
|
||||
allow_anonymous true
|
||||
persistence true
|
||||
persistence_location /var/lib/mosquitto/
|
||||
|
||||
# Logging
|
||||
log_dest stdout
|
||||
log_type warning
|
||||
log_type error
|
||||
@@ -0,0 +1,36 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
logfile=/dev/stdout
|
||||
logfile_maxbytes=0
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:mosquitto]
|
||||
command=/usr/sbin/mosquitto -c /etc/mosquitto/mosquitto.conf
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:meshcore-analyzer]
|
||||
command=node /app/server.js
|
||||
directory=/app
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
environment=NODE_ENV="production"
|
||||
|
||||
[program:caddy]
|
||||
command=/usr/sbin/caddy run --config /etc/caddy/Caddyfile
|
||||
environment=XDG_DATA_HOME="/data"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "meshcore-analyzer",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.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": {
|
||||
|
||||
+600
@@ -0,0 +1,600 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* In-memory packet store — loads transmissions + observations from SQLite on startup,
|
||||
* serves reads from RAM, writes to both RAM + SQLite.
|
||||
* M3: Restructured around transmissions (deduped by hash) with observations.
|
||||
* Caps memory at configurable limit (default 1GB).
|
||||
*/
|
||||
class PacketStore {
|
||||
constructor(dbModule, config = {}) {
|
||||
this.dbModule = dbModule; // The full db module (has .db, .insertPacket, .getPacket)
|
||||
this.db = dbModule.db; // Raw better-sqlite3 instance for queries
|
||||
this.maxBytes = (config.maxMemoryMB || 1024) * 1024 * 1024;
|
||||
this.estPacketBytes = config.estimatedPacketBytes || 450;
|
||||
this.maxPackets = Math.floor(this.maxBytes / this.estPacketBytes);
|
||||
|
||||
// SQLite-only mode: skip RAM loading, all reads go to DB
|
||||
this.sqliteOnly = process.env.NO_MEMORY_STORE === '1';
|
||||
|
||||
// Primary storage: transmissions sorted by first_seen DESC (newest first)
|
||||
// Each transmission looks like a packet for backward compat
|
||||
this.packets = [];
|
||||
|
||||
// Indexes
|
||||
this.byId = new Map(); // observation_id → observation object (backward compat for packet detail links)
|
||||
this.byHash = new Map(); // hash → transmission object (1:1)
|
||||
this.byObserver = new Map(); // observer_id → [observation objects]
|
||||
this.byNode = new Map(); // pubkey → [transmission objects] (deduped)
|
||||
this.byTransmission = new Map(); // hash → transmission object (same refs as byHash)
|
||||
|
||||
// Track which hashes are indexed per node pubkey (avoid dupes in byNode)
|
||||
this._nodeHashIndex = new Map(); // pubkey → Set<hash>
|
||||
|
||||
this.loaded = false;
|
||||
this.stats = { totalLoaded: 0, totalObservations: 0, evicted: 0, inserts: 0, queries: 0 };
|
||||
}
|
||||
|
||||
/** Load all packets from SQLite into memory */
|
||||
load() {
|
||||
if (this.sqliteOnly) {
|
||||
console.log('[PacketStore] SQLite-only mode (NO_MEMORY_STORE=1) — all reads go to database');
|
||||
this.loaded = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
|
||||
// Check if normalized schema exists
|
||||
const hasTransmissions = this.db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='transmissions'"
|
||||
).get();
|
||||
|
||||
if (hasTransmissions) {
|
||||
this._loadNormalized();
|
||||
} else {
|
||||
this._loadLegacy();
|
||||
}
|
||||
|
||||
this.stats.totalLoaded = this.packets.length;
|
||||
this.loaded = true;
|
||||
const elapsed = Date.now() - t0;
|
||||
console.log(`[PacketStore] Loaded ${this.packets.length} transmissions (${this.stats.totalObservations} observations) in ${elapsed}ms (${Math.round(this.packets.length * this.estPacketBytes / 1024 / 1024)}MB est)`);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Load from normalized transmissions + observations tables */
|
||||
_loadNormalized() {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT t.id AS transmission_id, t.raw_hex, t.hash, t.first_seen, t.route_type,
|
||||
t.payload_type, t.payload_version, t.decoded_json,
|
||||
o.id AS observation_id, o.observer_id, o.observer_name, o.direction,
|
||||
o.snr, o.rssi, o.score, o.path_json, o.timestamp AS obs_timestamp
|
||||
FROM transmissions t
|
||||
LEFT JOIN observations o ON o.transmission_id = t.id
|
||||
ORDER BY t.first_seen DESC, o.timestamp DESC
|
||||
`).all();
|
||||
|
||||
for (const row of rows) {
|
||||
if (this.packets.length >= this.maxPackets && !this.byTransmission.has(row.hash)) break;
|
||||
|
||||
let tx = this.byTransmission.get(row.hash);
|
||||
if (!tx) {
|
||||
tx = {
|
||||
id: row.transmission_id,
|
||||
raw_hex: row.raw_hex,
|
||||
hash: row.hash,
|
||||
first_seen: row.first_seen,
|
||||
timestamp: row.first_seen,
|
||||
route_type: row.route_type,
|
||||
payload_type: row.payload_type,
|
||||
decoded_json: row.decoded_json,
|
||||
observations: [],
|
||||
observation_count: 0,
|
||||
// Filled from first observation for backward compat
|
||||
observer_id: null,
|
||||
observer_name: null,
|
||||
snr: null,
|
||||
rssi: null,
|
||||
path_json: null,
|
||||
direction: null,
|
||||
};
|
||||
this.byTransmission.set(row.hash, tx);
|
||||
this.byHash.set(row.hash, tx);
|
||||
this.packets.push(tx);
|
||||
this._indexByNode(tx);
|
||||
}
|
||||
|
||||
if (row.observation_id != null) {
|
||||
const obs = {
|
||||
id: row.observation_id,
|
||||
observer_id: row.observer_id,
|
||||
observer_name: row.observer_name,
|
||||
direction: row.direction,
|
||||
snr: row.snr,
|
||||
rssi: row.rssi,
|
||||
score: row.score,
|
||||
path_json: row.path_json,
|
||||
timestamp: row.obs_timestamp,
|
||||
// Carry transmission fields for backward compat
|
||||
hash: row.hash,
|
||||
raw_hex: row.raw_hex,
|
||||
payload_type: row.payload_type,
|
||||
decoded_json: row.decoded_json,
|
||||
route_type: row.route_type,
|
||||
};
|
||||
|
||||
tx.observations.push(obs);
|
||||
tx.observation_count++;
|
||||
|
||||
// Fill first observation data into transmission for backward compat
|
||||
if (tx.observer_id == null && obs.observer_id) {
|
||||
tx.observer_id = obs.observer_id;
|
||||
tx.observer_name = obs.observer_name;
|
||||
tx.snr = obs.snr;
|
||||
tx.rssi = obs.rssi;
|
||||
tx.path_json = obs.path_json;
|
||||
tx.direction = obs.direction;
|
||||
}
|
||||
|
||||
// byId maps observation IDs for packet detail links
|
||||
this.byId.set(obs.id, obs);
|
||||
|
||||
// byObserver
|
||||
if (obs.observer_id) {
|
||||
if (!this.byObserver.has(obs.observer_id)) this.byObserver.set(obs.observer_id, []);
|
||||
this.byObserver.get(obs.observer_id).push(obs);
|
||||
}
|
||||
|
||||
this.stats.totalObservations++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fallback: load from legacy packets table */
|
||||
_loadLegacy() {
|
||||
const rows = this.db.prepare(
|
||||
'SELECT * FROM packets ORDER BY timestamp DESC'
|
||||
).all();
|
||||
|
||||
for (const row of rows) {
|
||||
if (this.packets.length >= this.maxPackets) break;
|
||||
this._indexLegacy(row);
|
||||
}
|
||||
}
|
||||
|
||||
/** Index a legacy packet row (old flat structure) — builds transmission + observation */
|
||||
_indexLegacy(pkt) {
|
||||
let tx = this.byTransmission.get(pkt.hash);
|
||||
if (!tx) {
|
||||
tx = {
|
||||
id: pkt.id,
|
||||
raw_hex: pkt.raw_hex,
|
||||
hash: pkt.hash,
|
||||
first_seen: pkt.timestamp,
|
||||
timestamp: pkt.timestamp,
|
||||
route_type: pkt.route_type,
|
||||
payload_type: pkt.payload_type,
|
||||
decoded_json: pkt.decoded_json,
|
||||
observations: [],
|
||||
observation_count: 0,
|
||||
observer_id: pkt.observer_id,
|
||||
observer_name: pkt.observer_name,
|
||||
snr: pkt.snr,
|
||||
rssi: pkt.rssi,
|
||||
path_json: pkt.path_json,
|
||||
direction: pkt.direction,
|
||||
};
|
||||
this.byTransmission.set(pkt.hash, tx);
|
||||
this.byHash.set(pkt.hash, tx);
|
||||
this.packets.push(tx);
|
||||
this._indexByNode(tx);
|
||||
}
|
||||
|
||||
if (pkt.timestamp < tx.first_seen) {
|
||||
tx.first_seen = pkt.timestamp;
|
||||
tx.timestamp = pkt.timestamp;
|
||||
}
|
||||
|
||||
const obs = {
|
||||
id: pkt.id,
|
||||
observer_id: pkt.observer_id,
|
||||
observer_name: pkt.observer_name,
|
||||
direction: pkt.direction,
|
||||
snr: pkt.snr,
|
||||
rssi: pkt.rssi,
|
||||
score: pkt.score,
|
||||
path_json: pkt.path_json,
|
||||
timestamp: pkt.timestamp,
|
||||
hash: pkt.hash,
|
||||
raw_hex: pkt.raw_hex,
|
||||
payload_type: pkt.payload_type,
|
||||
decoded_json: pkt.decoded_json,
|
||||
route_type: pkt.route_type,
|
||||
};
|
||||
tx.observations.push(obs);
|
||||
tx.observation_count++;
|
||||
|
||||
this.byId.set(pkt.id, obs);
|
||||
|
||||
if (pkt.observer_id) {
|
||||
if (!this.byObserver.has(pkt.observer_id)) this.byObserver.set(pkt.observer_id, []);
|
||||
this.byObserver.get(pkt.observer_id).push(obs);
|
||||
}
|
||||
|
||||
this.stats.totalObservations++;
|
||||
}
|
||||
|
||||
/** Extract node pubkeys from decoded_json and index transmission in byNode */
|
||||
_indexByNode(tx) {
|
||||
if (!tx.decoded_json) return;
|
||||
try {
|
||||
const decoded = JSON.parse(tx.decoded_json);
|
||||
const keys = new Set();
|
||||
if (decoded.pubKey) keys.add(decoded.pubKey);
|
||||
if (decoded.destPubKey) keys.add(decoded.destPubKey);
|
||||
if (decoded.srcPubKey) keys.add(decoded.srcPubKey);
|
||||
for (const k of keys) {
|
||||
if (!this._nodeHashIndex.has(k)) this._nodeHashIndex.set(k, new Set());
|
||||
if (this._nodeHashIndex.get(k).has(tx.hash)) continue; // already indexed
|
||||
this._nodeHashIndex.get(k).add(tx.hash);
|
||||
if (!this.byNode.has(k)) this.byNode.set(k, []);
|
||||
this.byNode.get(k).push(tx);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Remove oldest transmissions when over memory limit */
|
||||
_evict() {
|
||||
while (this.packets.length > this.maxPackets) {
|
||||
const old = this.packets.pop();
|
||||
this.byHash.delete(old.hash);
|
||||
this.byTransmission.delete(old.hash);
|
||||
// Remove observations from byId and byObserver
|
||||
for (const obs of old.observations) {
|
||||
this.byId.delete(obs.id);
|
||||
if (obs.observer_id && this.byObserver.has(obs.observer_id)) {
|
||||
const arr = this.byObserver.get(obs.observer_id).filter(o => o.id !== obs.id);
|
||||
if (arr.length) this.byObserver.set(obs.observer_id, arr); else this.byObserver.delete(obs.observer_id);
|
||||
}
|
||||
}
|
||||
// Skip node index cleanup (expensive, low value)
|
||||
this.stats.evicted++;
|
||||
}
|
||||
}
|
||||
|
||||
/** Insert a new packet (to both memory and SQLite) */
|
||||
insert(packetData) {
|
||||
const id = this.dbModule.insertPacket(packetData);
|
||||
const row = this.dbModule.getPacket(id);
|
||||
if (row && !this.sqliteOnly) {
|
||||
// Update or create transmission in memory
|
||||
let tx = this.byTransmission.get(row.hash);
|
||||
if (!tx) {
|
||||
tx = {
|
||||
id: row.id,
|
||||
raw_hex: row.raw_hex,
|
||||
hash: row.hash,
|
||||
first_seen: row.timestamp,
|
||||
timestamp: row.timestamp,
|
||||
route_type: row.route_type,
|
||||
payload_type: row.payload_type,
|
||||
decoded_json: row.decoded_json,
|
||||
observations: [],
|
||||
observation_count: 0,
|
||||
observer_id: row.observer_id,
|
||||
observer_name: row.observer_name,
|
||||
snr: row.snr,
|
||||
rssi: row.rssi,
|
||||
path_json: row.path_json,
|
||||
direction: row.direction,
|
||||
};
|
||||
this.byTransmission.set(row.hash, tx);
|
||||
this.byHash.set(row.hash, tx);
|
||||
this.packets.unshift(tx); // newest first
|
||||
this._indexByNode(tx);
|
||||
} else {
|
||||
// Update first_seen if earlier
|
||||
if (row.timestamp < tx.first_seen) {
|
||||
tx.first_seen = row.timestamp;
|
||||
tx.timestamp = row.timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
// Add observation
|
||||
const obs = {
|
||||
id: row.id,
|
||||
observer_id: row.observer_id,
|
||||
observer_name: row.observer_name,
|
||||
direction: row.direction,
|
||||
snr: row.snr,
|
||||
rssi: row.rssi,
|
||||
score: row.score,
|
||||
path_json: row.path_json,
|
||||
timestamp: row.timestamp,
|
||||
hash: row.hash,
|
||||
raw_hex: row.raw_hex,
|
||||
payload_type: row.payload_type,
|
||||
decoded_json: row.decoded_json,
|
||||
route_type: row.route_type,
|
||||
};
|
||||
tx.observations.push(obs);
|
||||
tx.observation_count++;
|
||||
|
||||
// Update transmission's display fields if this is first observation
|
||||
if (tx.observations.length === 1) {
|
||||
tx.observer_id = obs.observer_id;
|
||||
tx.observer_name = obs.observer_name;
|
||||
tx.snr = obs.snr;
|
||||
tx.rssi = obs.rssi;
|
||||
tx.path_json = obs.path_json;
|
||||
}
|
||||
|
||||
this.byId.set(obs.id, obs);
|
||||
if (obs.observer_id) {
|
||||
if (!this.byObserver.has(obs.observer_id)) this.byObserver.set(obs.observer_id, []);
|
||||
this.byObserver.get(obs.observer_id).push(obs);
|
||||
}
|
||||
|
||||
this.stats.totalObservations++;
|
||||
this._evict();
|
||||
this.stats.inserts++;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ALL packets referencing a node — by pubkey index + name + pubkey text search.
|
||||
* Returns unique transmissions (deduped).
|
||||
* @param {string} nodeIdOrName - pubkey or friendly name
|
||||
* @param {Array} [fromPackets] - packet array to filter (defaults to this.packets)
|
||||
* @returns {{ packets: Array, pubkey: string, nodeName: string }}
|
||||
*/
|
||||
findPacketsForNode(nodeIdOrName, fromPackets) {
|
||||
let pubkey = nodeIdOrName;
|
||||
let nodeName = nodeIdOrName;
|
||||
|
||||
// Always resolve to get both pubkey and name
|
||||
try {
|
||||
const row = this.db.prepare("SELECT public_key, name FROM nodes WHERE public_key = ? OR name = ? LIMIT 1").get(nodeIdOrName, nodeIdOrName);
|
||||
if (row) { pubkey = row.public_key; nodeName = row.name || nodeIdOrName; }
|
||||
} catch {}
|
||||
|
||||
// Combine: index hits + text search
|
||||
const indexed = this.byNode.get(pubkey);
|
||||
const hashSet = indexed ? new Set(indexed.map(t => t.hash)) : new Set();
|
||||
const source = fromPackets || this.packets;
|
||||
const packets = source.filter(t =>
|
||||
hashSet.has(t.hash) ||
|
||||
(t.decoded_json && (t.decoded_json.includes(nodeName) || t.decoded_json.includes(pubkey)))
|
||||
);
|
||||
|
||||
return { packets, pubkey, nodeName };
|
||||
}
|
||||
|
||||
/** Count transmissions and observations for a node */
|
||||
countForNode(pubkey) {
|
||||
const txs = this.byNode.get(pubkey) || [];
|
||||
let observations = 0;
|
||||
for (const tx of txs) observations += tx.observation_count;
|
||||
return { transmissions: txs.length, observations };
|
||||
}
|
||||
|
||||
/** Query packets with filters — all from memory (or SQLite in fallback mode) */
|
||||
query({ limit = 50, offset = 0, type, route, region, observer, hash, since, until, node, order = 'DESC' } = {}) {
|
||||
this.stats.queries++;
|
||||
|
||||
if (this.sqliteOnly) return this._querySQLite({ limit, offset, type, route, region, observer, hash, since, until, node, order });
|
||||
|
||||
let results = this.packets;
|
||||
|
||||
// Use indexes for single-key filters when possible
|
||||
if (hash && !type && !route && !region && !observer && !since && !until && !node) {
|
||||
const tx = this.byHash.get(hash);
|
||||
results = tx ? [tx] : [];
|
||||
} else if (observer && !type && !route && !region && !hash && !since && !until && !node) {
|
||||
// For observer filter, find unique transmissions where any observation matches
|
||||
results = this._transmissionsForObserver(observer);
|
||||
} else if (node && !type && !route && !region && !observer && !hash && !since && !until) {
|
||||
results = this.findPacketsForNode(node).packets;
|
||||
} else {
|
||||
// Apply filters sequentially
|
||||
if (type !== undefined) {
|
||||
const t = Number(type);
|
||||
results = results.filter(p => p.payload_type === t);
|
||||
}
|
||||
if (route !== undefined) {
|
||||
const r = Number(route);
|
||||
results = results.filter(p => p.route_type === r);
|
||||
}
|
||||
if (observer) results = this._transmissionsForObserver(observer, results);
|
||||
if (hash) {
|
||||
const tx = this.byHash.get(hash);
|
||||
results = tx ? results.filter(p => p.hash === hash) : [];
|
||||
}
|
||||
if (since) results = results.filter(p => p.timestamp > since);
|
||||
if (until) results = results.filter(p => p.timestamp < until);
|
||||
if (region) {
|
||||
const regionObservers = new Set();
|
||||
try {
|
||||
const obs = this.db.prepare('SELECT id FROM observers WHERE iata = ?').all(region);
|
||||
obs.forEach(o => regionObservers.add(o.id));
|
||||
} catch {}
|
||||
results = results.filter(p =>
|
||||
p.observations.some(o => regionObservers.has(o.observer_id))
|
||||
);
|
||||
}
|
||||
if (node) {
|
||||
results = this.findPacketsForNode(node, results).packets;
|
||||
}
|
||||
}
|
||||
|
||||
const total = results.length;
|
||||
|
||||
// Sort
|
||||
if (order === 'ASC') {
|
||||
results = results.slice().sort((a, b) => {
|
||||
if (a.timestamp < b.timestamp) return -1;
|
||||
if (a.timestamp > b.timestamp) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
// Default DESC — packets array is already sorted newest-first
|
||||
|
||||
// Paginate
|
||||
const paginated = results.slice(Number(offset), Number(offset) + Number(limit));
|
||||
return { packets: paginated, total };
|
||||
}
|
||||
|
||||
/** Find unique transmissions that have at least one observation from given observer */
|
||||
_transmissionsForObserver(observerId, fromTransmissions) {
|
||||
if (fromTransmissions) {
|
||||
return fromTransmissions.filter(tx =>
|
||||
tx.observations.some(o => o.observer_id === observerId)
|
||||
);
|
||||
}
|
||||
// Use byObserver index: get observations, then unique transmissions
|
||||
const obs = this.byObserver.get(observerId) || [];
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const o of obs) {
|
||||
if (!seen.has(o.hash)) {
|
||||
seen.add(o.hash);
|
||||
const tx = this.byTransmission.get(o.hash);
|
||||
if (tx) result.push(tx);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Query with groupByHash — now trivial since packets ARE transmissions */
|
||||
queryGrouped({ limit = 50, offset = 0, type, route, region, observer, hash, since, until, node } = {}) {
|
||||
this.stats.queries++;
|
||||
|
||||
if (this.sqliteOnly) return this._queryGroupedSQLite({ limit, offset, type, route, region, observer, hash, since, until, node });
|
||||
|
||||
// Get filtered transmissions
|
||||
const { packets: filtered, total: filteredTotal } = this.query({
|
||||
limit: 999999, offset: 0, type, route, region, observer, hash, since, until, node
|
||||
});
|
||||
|
||||
// Already grouped by hash — just format for backward compat
|
||||
const sorted = filtered.map(tx => ({
|
||||
hash: tx.hash,
|
||||
count: tx.observation_count,
|
||||
observer_count: new Set(tx.observations.map(o => o.observer_id).filter(Boolean)).size,
|
||||
latest: tx.observations.length ? tx.observations.reduce((max, o) => o.timestamp > max ? o.timestamp : max, tx.observations[0].timestamp) : tx.timestamp,
|
||||
observer_id: tx.observer_id,
|
||||
observer_name: tx.observer_name,
|
||||
path_json: tx.path_json,
|
||||
payload_type: tx.payload_type,
|
||||
raw_hex: tx.raw_hex,
|
||||
decoded_json: tx.decoded_json,
|
||||
observation_count: tx.observation_count,
|
||||
})).sort((a, b) => b.latest.localeCompare(a.latest));
|
||||
|
||||
const total = sorted.length;
|
||||
const paginated = sorted.slice(Number(offset), Number(offset) + Number(limit));
|
||||
return { packets: paginated, total };
|
||||
}
|
||||
|
||||
/** Get timestamps for sparkline */
|
||||
getTimestamps(since) {
|
||||
if (this.sqliteOnly) {
|
||||
return this.db.prepare('SELECT timestamp FROM packets WHERE timestamp > ? ORDER BY timestamp ASC').all(since).map(r => r.timestamp);
|
||||
}
|
||||
const results = [];
|
||||
for (const p of this.packets) {
|
||||
if (p.timestamp <= since) break;
|
||||
results.push(p.timestamp);
|
||||
}
|
||||
return results.reverse();
|
||||
}
|
||||
|
||||
/** Get a single packet by ID — checks observation IDs first (backward compat) */
|
||||
getById(id) {
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets WHERE id = ?').get(id) || null;
|
||||
return this.byId.get(id) || null;
|
||||
}
|
||||
|
||||
/** Get all siblings of a packet (same hash) — returns observations array */
|
||||
getSiblings(hash) {
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets WHERE hash = ? ORDER BY timestamp DESC').all(hash);
|
||||
const tx = this.byTransmission.get(hash);
|
||||
return tx ? tx.observations : [];
|
||||
}
|
||||
|
||||
/** Get all transmissions (backward compat — returns packets array) */
|
||||
all() {
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets ORDER BY timestamp DESC').all();
|
||||
return this.packets;
|
||||
}
|
||||
|
||||
/** Get all transmissions matching a filter function */
|
||||
filter(fn) {
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets ORDER BY timestamp DESC').all().filter(fn);
|
||||
return this.packets.filter(fn);
|
||||
}
|
||||
|
||||
/** Memory stats */
|
||||
getStats() {
|
||||
return {
|
||||
...this.stats,
|
||||
inMemory: this.sqliteOnly ? 0 : this.packets.length,
|
||||
sqliteOnly: this.sqliteOnly,
|
||||
maxPackets: this.maxPackets,
|
||||
estimatedMB: this.sqliteOnly ? 0 : Math.round(this.packets.length * this.estPacketBytes / 1024 / 1024),
|
||||
maxMB: Math.round(this.maxBytes / 1024 / 1024),
|
||||
indexes: {
|
||||
byHash: this.byHash.size,
|
||||
byObserver: this.byObserver.size,
|
||||
byNode: this.byNode.size,
|
||||
byTransmission: this.byTransmission.size,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** SQLite fallback: query with filters */
|
||||
_querySQLite({ limit, offset, type, route, region, observer, hash, since, until, node, order }) {
|
||||
const where = []; const params = [];
|
||||
if (type !== undefined) { where.push('payload_type = ?'); params.push(Number(type)); }
|
||||
if (route !== undefined) { where.push('route_type = ?'); params.push(Number(route)); }
|
||||
if (observer) { where.push('observer_id = ?'); params.push(observer); }
|
||||
if (hash) { where.push('hash = ?'); params.push(hash); }
|
||||
if (since) { where.push('timestamp > ?'); params.push(since); }
|
||||
if (until) { where.push('timestamp < ?'); params.push(until); }
|
||||
if (region) { where.push('observer_id IN (SELECT id FROM observers WHERE iata = ?)'); params.push(region); }
|
||||
if (node) { try { const nr = this.db.prepare('SELECT public_key FROM nodes WHERE public_key = ? OR name = ? LIMIT 1').get(node, node); const pk = nr ? nr.public_key : node; where.push('(decoded_json LIKE ? OR id IN (SELECT packet_id FROM paths WHERE node_hash = ?))'); params.push('%' + pk + '%', pk.substring(0, 8)); } catch(e) { where.push('decoded_json LIKE ?'); params.push('%' + node + '%'); } }
|
||||
const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||
const total = this.db.prepare(`SELECT COUNT(*) as c FROM packets ${w}`).get(...params).c;
|
||||
const packets = this.db.prepare(`SELECT * FROM packets ${w} ORDER BY timestamp ${order === 'ASC' ? 'ASC' : 'DESC'} LIMIT ? OFFSET ?`).all(...params, limit, offset);
|
||||
return { packets, total };
|
||||
}
|
||||
|
||||
/** SQLite fallback: grouped query */
|
||||
_queryGroupedSQLite({ limit, offset, type, route, region, observer, hash, since, until, node }) {
|
||||
const where = []; const params = [];
|
||||
if (type !== undefined) { where.push('payload_type = ?'); params.push(Number(type)); }
|
||||
if (route !== undefined) { where.push('route_type = ?'); params.push(Number(route)); }
|
||||
if (observer) { where.push('observer_id = ?'); params.push(observer); }
|
||||
if (hash) { where.push('hash = ?'); params.push(hash); }
|
||||
if (since) { where.push('timestamp > ?'); params.push(since); }
|
||||
if (until) { where.push('timestamp < ?'); params.push(until); }
|
||||
if (region) { where.push('observer_id IN (SELECT id FROM observers WHERE iata = ?)'); params.push(region); }
|
||||
if (node) { try { const nr = this.db.prepare('SELECT public_key FROM nodes WHERE public_key = ? OR name = ? LIMIT 1').get(node, node); const pk = nr ? nr.public_key : node; where.push('(decoded_json LIKE ? OR id IN (SELECT packet_id FROM paths WHERE node_hash = ?))'); params.push('%' + pk + '%', pk.substring(0, 8)); } catch(e) { where.push('decoded_json LIKE ?'); params.push('%' + node + '%'); } }
|
||||
const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||
|
||||
const sql = `SELECT hash, COUNT(*) as count, COUNT(DISTINCT observer_id) as observer_count,
|
||||
MAX(timestamp) as latest, MIN(observer_id) as observer_id, MIN(observer_name) as observer_name,
|
||||
MIN(path_json) as path_json, MIN(payload_type) as payload_type, MIN(raw_hex) as raw_hex,
|
||||
MIN(decoded_json) as decoded_json
|
||||
FROM packets ${w} GROUP BY hash ORDER BY latest DESC LIMIT ? OFFSET ?`;
|
||||
const packets = this.db.prepare(sql).all(...params, limit, offset);
|
||||
|
||||
const countSql = `SELECT COUNT(DISTINCT hash) as c FROM packets ${w}`;
|
||||
const total = this.db.prepare(countSql).get(...params).c;
|
||||
return { packets, total };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PacketStore;
|
||||
+33
-21
@@ -40,7 +40,15 @@
|
||||
return svg;
|
||||
}
|
||||
|
||||
function histogram(values, bins, color, w = 800, h = 180) {
|
||||
function histogram(data, bins, color, w = 800, h = 180) {
|
||||
// Support pre-computed histogram from server { bins: [{x, w, count}], min, max }
|
||||
if (data && data.bins && Array.isArray(data.bins)) {
|
||||
const buckets = data.bins.map(b => b.count);
|
||||
const labels = data.bins.map(b => b.x.toFixed(1));
|
||||
return { svg: barChart(buckets, labels, color, w, h), buckets, labels };
|
||||
}
|
||||
// Legacy: raw values array
|
||||
const values = data;
|
||||
const min = Math.min(...values), max = Math.max(...values);
|
||||
const step = (max - min) / bins;
|
||||
const buckets = Array(bins).fill(0);
|
||||
@@ -101,10 +109,10 @@
|
||||
try {
|
||||
_analyticsData = {};
|
||||
const [hashData, rfData, topoData, chanData] = await Promise.all([
|
||||
api('/analytics/hash-sizes'),
|
||||
api('/analytics/rf'),
|
||||
api('/analytics/topology'),
|
||||
api('/analytics/channels'),
|
||||
api('/analytics/hash-sizes', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/rf', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/topology', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/channels', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
]);
|
||||
_analyticsData = { hashData, rfData, topoData, chanData };
|
||||
renderTab('overview');
|
||||
@@ -142,10 +150,14 @@
|
||||
el.innerHTML = `
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${rf.totalPackets.toLocaleString()}</div>
|
||||
<div class="stat-label">Total Packets</div>
|
||||
<div class="stat-value">${(rf.totalTransmissions || rf.totalAllPackets || rf.totalPackets).toLocaleString()}</div>
|
||||
<div class="stat-label">Total Transmissions</div>
|
||||
<div class="stat-spark">${sparkSvg(rf.packetsPerHour.map(h=>h.count), 'var(--accent)')}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${rf.totalPackets.toLocaleString()}</div>
|
||||
<div class="stat-label">Observations with Signal</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${topo.uniqueNodes}</div>
|
||||
<div class="stat-label">Unique Nodes</div>
|
||||
@@ -747,7 +759,7 @@
|
||||
</div>
|
||||
`;
|
||||
let allNodes = [];
|
||||
try { const nd = await api('/nodes?limit=2000'); allNodes = nd.nodes || []; } catch {}
|
||||
try { const nd = await api('/nodes?limit=2000', { ttl: CLIENT_TTL.nodeList }); allNodes = nd.nodes || []; } catch {}
|
||||
renderHashMatrix(data.topHops, allNodes);
|
||||
renderCollisions(data.topHops, allNodes);
|
||||
}
|
||||
@@ -938,10 +950,10 @@
|
||||
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">Analyzing route patterns…</div>';
|
||||
try {
|
||||
const [d2, d3, d4, d5] = await Promise.all([
|
||||
api('/analytics/subpaths?minLen=2&maxLen=2&limit=50'),
|
||||
api('/analytics/subpaths?minLen=3&maxLen=3&limit=30'),
|
||||
api('/analytics/subpaths?minLen=4&maxLen=4&limit=20'),
|
||||
api('/analytics/subpaths?minLen=5&maxLen=8&limit=15')
|
||||
api('/analytics/subpaths?minLen=2&maxLen=2&limit=50', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/subpaths?minLen=3&maxLen=3&limit=30', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/subpaths?minLen=4&maxLen=4&limit=20', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/subpaths?minLen=5&maxLen=8&limit=15', { ttl: CLIENT_TTL.analyticsRF })
|
||||
]);
|
||||
|
||||
function renderTable(data, title) {
|
||||
@@ -1032,7 +1044,7 @@
|
||||
panel.classList.remove('collapsed');
|
||||
panel.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
|
||||
try {
|
||||
const data = await api('/analytics/subpath-detail?hops=' + encodeURIComponent(hopsStr));
|
||||
const data = await api('/analytics/subpath-detail?hops=' + encodeURIComponent(hopsStr), { ttl: CLIENT_TTL.analyticsRF });
|
||||
renderSubpathDetail(panel, data);
|
||||
} catch (e) {
|
||||
panel.innerHTML = `<div class="text-muted">Error: ${e.message}</div>`;
|
||||
@@ -1117,7 +1129,7 @@
|
||||
// Render minimap
|
||||
if (hasMap && typeof L !== 'undefined') {
|
||||
const map = L.map('subpathMap', { zoomControl: false, attributionControl: false });
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 18 }).addTo(map);
|
||||
L.tileLayer(getTileUrl(), { maxZoom: 18 }).addTo(map);
|
||||
|
||||
const latlngs = [];
|
||||
nodesWithLoc.forEach((n, i) => {
|
||||
@@ -1141,9 +1153,9 @@
|
||||
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Loading node analytics…</div>';
|
||||
try {
|
||||
const [nodesResp, bulkHealth, netStatus] = await Promise.all([
|
||||
api('/nodes?limit=200&sortBy=lastSeen'),
|
||||
api('/nodes/bulk-health?limit=50'),
|
||||
api('/nodes/network-status')
|
||||
api('/nodes?limit=200&sortBy=lastSeen', { ttl: CLIENT_TTL.nodeList }),
|
||||
api('/nodes/bulk-health?limit=50', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/nodes/network-status', { ttl: CLIENT_TTL.analyticsRF })
|
||||
]);
|
||||
const nodes = nodesResp.nodes || nodesResp;
|
||||
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
|
||||
@@ -1155,7 +1167,7 @@
|
||||
const enriched = nodes.filter(n => healthMap[n.public_key]).map(n => ({ ...n, health: { stats: healthMap[n.public_key].stats, observers: healthMap[n.public_key].observers } }));
|
||||
|
||||
// Compute rankings
|
||||
const byPackets = [...enriched].sort((a, b) => (b.health.stats.totalPackets || 0) - (a.health.stats.totalPackets || 0));
|
||||
const byPackets = [...enriched].sort((a, b) => (b.health.stats.totalTransmissions || b.health.stats.totalPackets || 0) - (a.health.stats.totalTransmissions || a.health.stats.totalPackets || 0));
|
||||
const bySnr = [...enriched].filter(n => n.health.stats.avgSnr != null).sort((a, b) => b.health.stats.avgSnr - a.health.stats.avgSnr);
|
||||
const byObservers = [...enriched].sort((a, b) => (b.health.observers?.length || 0) - (a.health.observers?.length || 0));
|
||||
const byRecent = [...enriched].filter(n => n.health.stats.lastHeard).sort((a, b) => new Date(b.health.stats.lastHeard) - new Date(a.health.stats.lastHeard));
|
||||
@@ -1170,7 +1182,7 @@
|
||||
return myKeys.has(n.public_key) ? ' <span style="color:var(--accent);font-size:10px">★ MINE</span>' : '';
|
||||
}
|
||||
|
||||
const ROLE_COLORS = { repeater: '#dc2626', companion: '#2563eb', room: '#16a34a', sensor: '#d97706' };
|
||||
// ROLE_COLORS from shared roles.js
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="analytics-section">
|
||||
@@ -1211,7 +1223,7 @@
|
||||
return `<tr>
|
||||
<td>${nodeLink(n)}</td>
|
||||
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
|
||||
<td>${s.totalPackets || 0}</td>
|
||||
<td>${s.totalTransmissions || s.totalPackets || 0}</td>
|
||||
<td>${s.avgSnr != null ? s.avgSnr.toFixed(1) + ' dB' : '—'}</td>
|
||||
<td>${n.health.observers?.length || 0}</td>
|
||||
<td>${s.lastHeard ? timeAgo(s.lastHeard) : '—'}</td>
|
||||
@@ -1228,7 +1240,7 @@
|
||||
<td>${i + 1}</td>
|
||||
<td>${nodeLink(n)}${claimedBadge(n)}</td>
|
||||
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
|
||||
<td>${n.health.stats.totalPackets || 0}</td>
|
||||
<td>${n.health.stats.totalTransmissions || n.health.stats.totalPackets || 0}</td>
|
||||
<td>${n.health.stats.packetsToday || 0}</td>
|
||||
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">📊</a></td>
|
||||
</tr>`).join('')}
|
||||
|
||||
+101
-10
@@ -11,12 +11,81 @@ function payloadTypeName(n) { return PAYLOAD_TYPES[n] || 'UNKNOWN'; }
|
||||
function payloadTypeColor(n) { return PAYLOAD_COLORS[n] || 'unknown'; }
|
||||
|
||||
// --- Utilities ---
|
||||
async function api(path) {
|
||||
const res = await fetch('/api' + path);
|
||||
if (!res.ok) throw new Error(`API ${res.status}: ${path}`);
|
||||
return res.json();
|
||||
const _apiPerf = { calls: 0, totalMs: 0, log: [], cacheHits: 0 };
|
||||
const _apiCache = new Map();
|
||||
const _inflight = new Map();
|
||||
// Client-side TTLs (ms) — loaded from server config, with defaults
|
||||
const CLIENT_TTL = {
|
||||
stats: 10000, nodeDetail: 240000, nodeHealth: 240000, nodeList: 90000,
|
||||
bulkHealth: 300000, networkStatus: 300000, observers: 120000,
|
||||
channels: 15000, channelMessages: 10000, analyticsRF: 300000,
|
||||
analyticsTopology: 300000, analyticsChannels: 300000, analyticsHashSizes: 300000,
|
||||
analyticsSubpaths: 300000, analyticsSubpathDetail: 300000,
|
||||
nodeAnalytics: 60000, nodeSearch: 10000
|
||||
};
|
||||
// Fetch server cache config and use as client TTLs (server values are in seconds)
|
||||
fetch('/api/config/cache').then(r => r.json()).then(cfg => {
|
||||
for (const [k, v] of Object.entries(cfg)) {
|
||||
if (k in CLIENT_TTL && typeof v === 'number') CLIENT_TTL[k] = v * 1000;
|
||||
}
|
||||
}).catch(() => {});
|
||||
async function api(path, { ttl = 0, bust = false } = {}) {
|
||||
const t0 = performance.now();
|
||||
if (!bust && ttl > 0) {
|
||||
const cached = _apiCache.get(path);
|
||||
if (cached && Date.now() < cached.expires) {
|
||||
_apiPerf.calls++;
|
||||
_apiPerf.cacheHits++;
|
||||
_apiPerf.log.push({ path, ms: 0, time: Date.now(), cached: true });
|
||||
if (_apiPerf.log.length > 200) _apiPerf.log.shift();
|
||||
return cached.data;
|
||||
}
|
||||
}
|
||||
// Deduplicate in-flight requests
|
||||
if (_inflight.has(path)) return _inflight.get(path);
|
||||
const promise = (async () => {
|
||||
const res = await fetch('/api' + path);
|
||||
if (!res.ok) throw new Error(`API ${res.status}: ${path}`);
|
||||
const data = await res.json();
|
||||
const ms = performance.now() - t0;
|
||||
_apiPerf.calls++;
|
||||
_apiPerf.totalMs += ms;
|
||||
_apiPerf.log.push({ path, ms: Math.round(ms), time: Date.now() });
|
||||
if (_apiPerf.log.length > 200) _apiPerf.log.shift();
|
||||
if (ms > 500) console.warn(`[SLOW API] ${path} took ${Math.round(ms)}ms`);
|
||||
if (ttl > 0) _apiCache.set(path, { data, expires: Date.now() + ttl });
|
||||
return data;
|
||||
})();
|
||||
_inflight.set(path, promise);
|
||||
promise.finally(() => _inflight.delete(path));
|
||||
return promise;
|
||||
}
|
||||
|
||||
function invalidateApiCache(prefix) {
|
||||
for (const key of _apiCache.keys()) {
|
||||
if (key.startsWith(prefix || '')) _apiCache.delete(key);
|
||||
}
|
||||
}
|
||||
// Expose for console debugging: apiPerf()
|
||||
window.apiPerf = function() {
|
||||
const byPath = {};
|
||||
_apiPerf.log.forEach(e => {
|
||||
if (!byPath[e.path]) byPath[e.path] = { count: 0, totalMs: 0, maxMs: 0 };
|
||||
byPath[e.path].count++;
|
||||
byPath[e.path].totalMs += e.ms;
|
||||
if (e.ms > byPath[e.path].maxMs) byPath[e.path].maxMs = e.ms;
|
||||
});
|
||||
const rows = Object.entries(byPath).map(([p, s]) => ({
|
||||
path: p, count: s.count, avgMs: Math.round(s.totalMs / s.count), maxMs: s.maxMs,
|
||||
totalMs: Math.round(s.totalMs)
|
||||
})).sort((a, b) => b.totalMs - a.totalMs);
|
||||
console.table(rows);
|
||||
const hitRate = _apiPerf.calls ? Math.round(_apiPerf.cacheHits / _apiPerf.calls * 100) : 0;
|
||||
const misses = _apiPerf.calls - _apiPerf.cacheHits;
|
||||
console.log(`Cache: ${_apiPerf.cacheHits} hits / ${misses} misses (${hitRate}% hit rate)`);
|
||||
return { calls: _apiPerf.calls, avgMs: Math.round(_apiPerf.totalMs / (misses || 1)), cacheHits: _apiPerf.cacheHits, cacheMisses: misses, cacheHitRate: hitRate, endpoints: rows };
|
||||
};
|
||||
|
||||
function timeAgo(iso) {
|
||||
if (!iso) return '—';
|
||||
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
@@ -140,6 +209,15 @@ function connectWS() {
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
// Debounce cache invalidation — don't nuke on every packet
|
||||
if (!api._invalidateTimer) {
|
||||
api._invalidateTimer = setTimeout(() => {
|
||||
api._invalidateTimer = null;
|
||||
invalidateApiCache('/stats');
|
||||
invalidateApiCache('/nodes');
|
||||
invalidateApiCache('/channels');
|
||||
}, 5000);
|
||||
}
|
||||
wsListeners.forEach(fn => fn(msg));
|
||||
} catch {}
|
||||
};
|
||||
@@ -150,8 +228,8 @@ function offWS(fn) { wsListeners = wsListeners.filter(f => f !== fn); }
|
||||
|
||||
/* Global escapeHtml — used by multiple pages */
|
||||
function escapeHtml(s) {
|
||||
if (!s) return '';
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
/* Global debounce */
|
||||
@@ -205,6 +283,16 @@ function navigate() {
|
||||
basePage = 'node-analytics';
|
||||
}
|
||||
|
||||
// Special route: packet/123 → standalone packet detail page
|
||||
if (basePage === 'packet' && routeParam) {
|
||||
basePage = 'packet-detail';
|
||||
}
|
||||
|
||||
// Special route: observers/ID → observer detail page
|
||||
if (basePage === 'observers' && routeParam) {
|
||||
basePage = 'observer-detail';
|
||||
}
|
||||
|
||||
// Update nav active state
|
||||
document.querySelectorAll('.nav-link[data-route]').forEach(el => {
|
||||
el.classList.toggle('active', el.dataset.route === basePage);
|
||||
@@ -217,7 +305,10 @@ function navigate() {
|
||||
|
||||
const app = document.getElementById('app');
|
||||
if (pages[basePage]?.init) {
|
||||
const t0 = performance.now();
|
||||
pages[basePage].init(app, routeParam);
|
||||
const ms = performance.now() - t0;
|
||||
if (ms > 100) console.warn(`[SLOW PAGE] ${basePage} init took ${Math.round(ms)}ms`);
|
||||
app.classList.remove('page-enter'); void app.offsetWidth; app.classList.add('page-enter');
|
||||
} else {
|
||||
app.innerHTML = `<div style="padding:40px;text-align:center;color:#6b7280"><h2>${route}</h2><p>Page not yet implemented.</p></div>`;
|
||||
@@ -290,9 +381,9 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
favDropdown.innerHTML = '<div class="fav-dd-loading">Loading...</div>';
|
||||
const items = await Promise.all(favs.map(async (pk) => {
|
||||
try {
|
||||
const h = await api('/nodes/' + pk + '/health');
|
||||
const h = await api('/nodes/' + pk + '/health', { ttl: CLIENT_TTL.nodeHealth });
|
||||
const age = h.stats.lastHeard ? Date.now() - new Date(h.stats.lastHeard).getTime() : null;
|
||||
const status = age === null ? '🔴' : age < 3600000 ? '🟢' : age < 86400000 ? '🟡' : '🔴';
|
||||
const status = age === null ? '🔴' : age < HEALTH_THRESHOLDS.nodeDegradedMs ? '🟢' : age < HEALTH_THRESHOLDS.nodeSilentMs ? '🟡' : '🔴';
|
||||
return '<a href="#/nodes/' + pk + '" class="fav-dd-item" data-key="' + pk + '">'
|
||||
+ '<span class="fav-dd-status">' + status + '</span>'
|
||||
+ '<span class="fav-dd-name">' + (h.node.name || truncate(pk, 12)) + '</span>'
|
||||
@@ -378,7 +469,7 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
const chList = Array.isArray(channels) ? channels : [];
|
||||
for (const c of chList) {
|
||||
if (c.name && c.name.toLowerCase().includes(q.toLowerCase())) {
|
||||
html += `<div class="search-result-item" onclick="location.hash='#/channels?ch=${c.channel_hash}';document.getElementById('searchOverlay').classList.add('hidden')">
|
||||
html += `<div class="search-result-item" onclick="location.hash='#/channels/${c.channel_hash}';document.getElementById('searchOverlay').classList.add('hidden')">
|
||||
<span class="search-result-type">Channel</span>${c.name}</div>`;
|
||||
}
|
||||
}
|
||||
@@ -394,7 +485,7 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
// --- Nav Stats ---
|
||||
async function updateNavStats() {
|
||||
try {
|
||||
const stats = await api('/stats');
|
||||
const stats = await api('/stats', { ttl: CLIENT_TTL.stats });
|
||||
const el = document.getElementById('navStats');
|
||||
if (el) {
|
||||
el.innerHTML = `<span class="stat-val">${stats.totalPackets}</span> pkts · <span class="stat-val">${stats.totalNodes}</span> nodes · <span class="stat-val">${stats.totalObservers}</span> obs`;
|
||||
|
||||
+14
-9
@@ -18,7 +18,7 @@
|
||||
if (cached && !cached.fetchedAt) return cached; // legacy null entries
|
||||
}
|
||||
try {
|
||||
const data = await api('/nodes/search?q=' + encodeURIComponent(name));
|
||||
const data = await api('/nodes/search?q=' + encodeURIComponent(name), { ttl: CLIENT_TTL.channelMessages });
|
||||
// Try exact match first, then case-insensitive, then contains
|
||||
const nodes = data.nodes || [];
|
||||
const match = nodes.find(n => n.name === name)
|
||||
@@ -40,7 +40,8 @@
|
||||
tip.id = 'chNodeTooltip';
|
||||
tip.className = 'ch-node-tooltip';
|
||||
tip.setAttribute('role', 'tooltip');
|
||||
const role = node.is_repeater ? '📡 Repeater' : node.is_room ? '🏠 Room' : node.is_sensor ? '🌡 Sensor' : '📻 Companion';
|
||||
const roleKey = node.role || (node.is_repeater ? 'repeater' : node.is_room ? 'room' : node.is_sensor ? 'sensor' : 'companion');
|
||||
const role = (ROLE_EMOJI[roleKey] || '●') + ' ' + (ROLE_LABELS[roleKey] || roleKey);
|
||||
const lastSeen = node.last_seen ? timeAgo(node.last_seen) : 'unknown';
|
||||
tip.innerHTML = `<div class="ch-tooltip-name">${escapeHtml(node.name)}</div>
|
||||
<div class="ch-tooltip-role">${role}</div>
|
||||
@@ -110,10 +111,11 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const detail = await api('/nodes/' + encodeURIComponent(node.public_key));
|
||||
const detail = await api('/nodes/' + encodeURIComponent(node.public_key), { ttl: CLIENT_TTL.nodeDetail });
|
||||
const n = detail.node;
|
||||
const adverts = detail.recentAdverts || [];
|
||||
const role = n.is_repeater ? '📡 Repeater' : n.is_room ? '🏠 Room' : n.is_sensor ? '🌡 Sensor' : '📻 Companion';
|
||||
const roleKey = n.role || (n.is_repeater ? 'repeater' : n.is_room ? 'room' : n.is_sensor ? 'sensor' : 'companion');
|
||||
const role = (ROLE_EMOJI[roleKey] || '●') + ' ' + (ROLE_LABELS[roleKey] || roleKey);
|
||||
const lastSeen = n.last_seen ? timeAgo(n.last_seen) : 'unknown';
|
||||
|
||||
panel.innerHTML = `<div class="ch-node-panel-header">
|
||||
@@ -211,7 +213,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
function init(app) {
|
||||
function init(app, routeParam) {
|
||||
app.innerHTML = `<div class="ch-layout">
|
||||
<div class="ch-sidebar" aria-label="Channel list">
|
||||
<div class="ch-sidebar-header">
|
||||
@@ -235,7 +237,9 @@
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
loadChannels();
|
||||
loadChannels().then(() => {
|
||||
if (routeParam) selectChannel(routeParam);
|
||||
});
|
||||
|
||||
// #89: Sidebar resize handle
|
||||
(function () {
|
||||
@@ -389,7 +393,7 @@
|
||||
|
||||
async function loadChannels(silent) {
|
||||
try {
|
||||
const data = await api('/channels');
|
||||
const data = await api('/channels', { ttl: CLIENT_TTL.channels });
|
||||
channels = (data.channels || []).sort((a, b) => (b.lastActivity || '').localeCompare(a.lastActivity || ''));
|
||||
renderChannelList();
|
||||
} catch (e) {
|
||||
@@ -438,6 +442,7 @@
|
||||
|
||||
async function selectChannel(hash) {
|
||||
selectedHash = hash;
|
||||
history.replaceState(null, '', `#/channels/${hash}`);
|
||||
renderChannelList();
|
||||
const ch = channels.find(c => c.hash === hash);
|
||||
const name = ch?.name || `Channel ${hash}`;
|
||||
@@ -451,7 +456,7 @@
|
||||
msgEl.innerHTML = '<div class="ch-loading">Loading messages…</div>';
|
||||
|
||||
try {
|
||||
const data = await api(`/channels/${hash}/messages?limit=200`);
|
||||
const data = await api(`/channels/${hash}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
|
||||
messages = data.messages || [];
|
||||
renderMessages();
|
||||
scrollToBottom();
|
||||
@@ -466,7 +471,7 @@
|
||||
if (!msgEl) return;
|
||||
const wasAtBottom = msgEl.scrollHeight - msgEl.scrollTop - msgEl.clientHeight < 60;
|
||||
try {
|
||||
const data = await api(`/channels/${selectedHash}/messages?limit=200`);
|
||||
const data = await api(`/channels/${selectedHash}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
|
||||
const newMsgs = data.messages || [];
|
||||
// #92: Use message ID/hash for change detection instead of count + timestamp
|
||||
var _getLastId = function (arr) { var m = arr.length ? arr[arr.length - 1] : null; return m ? (m.id || m.packetId || m.timestamp || '') : ''; };
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#1a1a2e"/>
|
||||
<circle cx="16" cy="8" r="3" fill="#00d4ff"/>
|
||||
<circle cx="7" cy="22" r="3" fill="#00d4ff"/>
|
||||
<circle cx="25" cy="22" r="3" fill="#00d4ff"/>
|
||||
<circle cx="16" cy="18" r="2" fill="#00ff88"/>
|
||||
<line x1="16" y1="8" x2="7" y2="22" stroke="#00d4ff" stroke-width="1" opacity="0.6"/>
|
||||
<line x1="16" y1="8" x2="25" y2="22" stroke="#00d4ff" stroke-width="1" opacity="0.6"/>
|
||||
<line x1="7" y1="22" x2="25" y2="22" stroke="#00d4ff" stroke-width="1" opacity="0.6"/>
|
||||
<line x1="16" y1="8" x2="16" y2="18" stroke="#00ff88" stroke-width="1" opacity="0.5"/>
|
||||
<line x1="7" y1="22" x2="16" y2="18" stroke="#00ff88" stroke-width="1" opacity="0.5"/>
|
||||
<line x1="25" y1="22" x2="16" y2="18" stroke="#00ff88" stroke-width="1" opacity="0.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 851 B |
+8
-8
@@ -146,7 +146,7 @@
|
||||
if (!q) { suggest.classList.remove('open'); input.setAttribute('aria-expanded', 'false'); input.setAttribute('aria-activedescendant', ''); return; }
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const data = await api('/nodes/search?q=' + encodeURIComponent(q));
|
||||
const data = await api('/nodes/search?q=' + encodeURIComponent(q), { ttl: CLIENT_TTL.nodeSearch });
|
||||
const nodes = data.nodes || [];
|
||||
if (!nodes.length) {
|
||||
suggest.innerHTML = '<div class="suggest-empty">No nodes found</div>';
|
||||
@@ -247,13 +247,13 @@
|
||||
|
||||
const cards = await Promise.all(myNodes.map(async (mn) => {
|
||||
try {
|
||||
const h = await api('/nodes/' + encodeURIComponent(mn.pubkey) + '/health');
|
||||
const h = await api('/nodes/' + encodeURIComponent(mn.pubkey) + '/health', { ttl: CLIENT_TTL.nodeHealth });
|
||||
const node = h.node || {};
|
||||
const stats = h.stats || {};
|
||||
const obs = h.observers || [];
|
||||
|
||||
const age = stats.lastHeard ? Date.now() - new Date(stats.lastHeard).getTime() : null;
|
||||
const status = age === null ? 'silent' : age < 3600000 ? 'healthy' : age < 86400000 ? 'degraded' : 'silent';
|
||||
const status = age === null ? 'silent' : age < HEALTH_THRESHOLDS.nodeDegradedMs ? 'healthy' : age < HEALTH_THRESHOLDS.nodeSilentMs ? 'degraded' : 'silent';
|
||||
const statusDot = status === 'healthy' ? '🟢' : status === 'degraded' ? '🟡' : '🔴';
|
||||
const statusText = status === 'healthy' ? 'Active' : status === 'degraded' ? 'Degraded' : 'Silent';
|
||||
const name = node.name || mn.name || truncate(mn.pubkey, 12);
|
||||
@@ -369,11 +369,11 @@
|
||||
// ==================== STATS ====================
|
||||
async function loadStats() {
|
||||
try {
|
||||
const s = await api('/stats');
|
||||
const s = await api('/stats', { ttl: CLIENT_TTL.nodeSearch });
|
||||
const el = document.getElementById('homeStats');
|
||||
if (!el) return;
|
||||
el.innerHTML = `
|
||||
<div class="home-stat"><div class="val">${s.totalPackets ?? '—'}</div><div class="lbl">Packets</div></div>
|
||||
<div class="home-stat"><div class="val">${s.totalTransmissions ?? s.totalPackets ?? '—'}</div><div class="lbl">Transmissions</div></div>
|
||||
<div class="home-stat"><div class="val">${s.totalNodes ?? '—'}</div><div class="lbl">Nodes</div></div>
|
||||
<div class="home-stat"><div class="val">${s.totalObservers ?? '—'}</div><div class="lbl">Observers</div></div>
|
||||
<div class="home-stat"><div class="val">${s.packetsLast24h ?? '—'}</div><div class="lbl">Last 24h</div></div>
|
||||
@@ -391,7 +391,7 @@
|
||||
if (journey) journey.classList.remove('visible');
|
||||
|
||||
try {
|
||||
const h = await api('/nodes/' + encodeURIComponent(pubkey) + '/health');
|
||||
const h = await api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeHealth });
|
||||
const node = h.node || {};
|
||||
const stats = h.stats || {};
|
||||
const packets = h.recentPackets || [];
|
||||
@@ -403,8 +403,8 @@
|
||||
if (stats.lastHeard) {
|
||||
const ageMs = Date.now() - new Date(stats.lastHeard).getTime();
|
||||
const ago = timeAgo(stats.lastHeard);
|
||||
if (ageMs < 3600000) { status = 'healthy'; color = 'green'; statusMsg = `Last heard ${ago}`; }
|
||||
else if (ageMs < 86400000) { status = 'degraded'; color = 'yellow'; statusMsg = `Last heard ${ago}`; }
|
||||
if (ageMs < HEALTH_THRESHOLDS.nodeDegradedMs) { status = 'healthy'; color = 'green'; statusMsg = `Last heard ${ago}`; }
|
||||
else if (ageMs < HEALTH_THRESHOLDS.nodeSilentMs) { status = 'degraded'; color = 'yellow'; statusMsg = `Last heard ${ago}`; }
|
||||
else { statusMsg = `Last heard ${ago}`; }
|
||||
}
|
||||
|
||||
|
||||
+19
-13
@@ -2,6 +2,8 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" href="favicon.svg" type="image/svg+xml">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>MeshCore Analyzer</title>
|
||||
|
||||
@@ -20,9 +22,9 @@
|
||||
<meta name="twitter:title" content="MeshCore Analyzer">
|
||||
<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/meshcore-analyzer/master/public/og-image.png">
|
||||
<link rel="stylesheet" href="style.css?v=1773963867">
|
||||
<link rel="stylesheet" href="style.css?v=1774042199">
|
||||
<link rel="stylesheet" href="home.css">
|
||||
<link rel="stylesheet" href="live.css?v=1773966856">
|
||||
<link rel="stylesheet" href="live.css?v=1774034490">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="anonymous">
|
||||
@@ -51,6 +53,7 @@
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
@@ -76,16 +79,19 @@
|
||||
<main id="app" role="main"></main>
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="app.js?v=1774079160"></script>
|
||||
<script src="home.js?v=1774079160"></script>
|
||||
<script src="packets.js?v=1773961784"></script>
|
||||
<script src="map.js?v=1774079160" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774079160" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1773961950" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774079160" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1773961035" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1773964458" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774079160" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1773961276" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="roles.js?v=1774028201"></script>
|
||||
<script src="app.js?v=1774034748"></script>
|
||||
<script src="home.js?v=1774042199"></script>
|
||||
<script src="packets.js?v=1774044174"></script>
|
||||
<script src="map.js?v=1774028201" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774028201" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774042199" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1773972187" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774042199" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774046040" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774018095" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1774028201" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1774042199" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1773985649" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -100,6 +100,26 @@
|
||||
background: color-mix(in srgb, var(--text) 14%, transparent);
|
||||
}
|
||||
|
||||
/* ---- Node Detail Panel ---- */
|
||||
.live-node-detail {
|
||||
top: 60px;
|
||||
right: 12px;
|
||||
width: 320px;
|
||||
max-height: calc(100vh - 140px);
|
||||
overflow-y: auto;
|
||||
background: color-mix(in srgb, var(--surface-1) 95%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
.live-node-detail.hidden {
|
||||
transform: translateX(340px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- Feed ---- */
|
||||
.live-feed {
|
||||
bottom: 12px;
|
||||
|
||||
+112
-22
@@ -30,10 +30,7 @@
|
||||
timelineFetchedScope: 0, // last fetched scope to avoid redundant fetches
|
||||
};
|
||||
|
||||
const ROLE_COLORS = {
|
||||
repeater: '#3b82f6', companion: '#06b6d4', room: '#a855f7',
|
||||
sensor: '#f59e0b', unknown: '#6b7280'
|
||||
};
|
||||
// ROLE_COLORS loaded from shared roles.js (includes 'unknown')
|
||||
|
||||
const TYPE_COLORS = {
|
||||
ADVERT: '#22c55e', GRP_TXT: '#3b82f6', TXT_MSG: '#f59e0b', ACK: '#6b7280',
|
||||
@@ -601,6 +598,10 @@
|
||||
<button class="feed-hide-btn" id="feedHideBtn" title="Hide feed">✕</button>
|
||||
</div>
|
||||
<button class="feed-show-btn hidden" id="feedShowBtn" title="Show feed">📋</button>
|
||||
<div class="live-overlay live-node-detail hidden" id="liveNodeDetail">
|
||||
<button class="feed-hide-btn" id="nodeDetailClose" title="Close">✕</button>
|
||||
<div id="nodeDetailContent"></div>
|
||||
</div>
|
||||
<button class="legend-toggle-btn hidden" id="legendToggleBtn" aria-label="Show legend" title="Show legend">🎨</button>
|
||||
<div class="live-overlay live-legend" id="liveLegend" role="region" aria-label="Map legend">
|
||||
<h3 class="legend-title">PACKET TYPES</h3>
|
||||
@@ -612,12 +613,7 @@
|
||||
<li><span class="live-dot" style="background:#ec4899" aria-hidden="true"></span> Trace — Route trace</li>
|
||||
</ul>
|
||||
<h3 class="legend-title" style="margin-top:8px">NODE ROLES</h3>
|
||||
<ul class="legend-list">
|
||||
<li><span class="live-dot" style="background:#3b82f6" aria-hidden="true"></span> Repeater</li>
|
||||
<li><span class="live-dot" style="background:#06b6d4" aria-hidden="true"></span> Companion</li>
|
||||
<li><span class="live-dot" style="background:#a855f7" aria-hidden="true"></span> Room</li>
|
||||
<li><span class="live-dot" style="background:#f59e0b" aria-hidden="true"></span> Sensor</li>
|
||||
</ul>
|
||||
<ul class="legend-list" id="roleLegendList"></ul>
|
||||
</div>
|
||||
|
||||
<!-- VCR Bar -->
|
||||
@@ -656,15 +652,13 @@
|
||||
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const DARK_TILES = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
||||
const LIGHT_TILES = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
||||
let tileLayer = L.tileLayer(isDark ? DARK_TILES : LIGHT_TILES, { maxZoom: 19 }).addTo(map);
|
||||
let tileLayer = L.tileLayer(isDark ? TILE_DARK : TILE_LIGHT, { maxZoom: 19 }).addTo(map);
|
||||
|
||||
// Swap tiles when theme changes
|
||||
const _themeObs = new MutationObserver(function () {
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
tileLayer.setUrl(dark ? DARK_TILES : LIGHT_TILES);
|
||||
tileLayer.setUrl(dark ? TILE_DARK : TILE_LIGHT);
|
||||
});
|
||||
_themeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
L.control.zoom({ position: 'topright' }).addTo(map);
|
||||
@@ -749,6 +743,23 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Populate role legend from shared roles.js
|
||||
const roleLegendList = document.getElementById('roleLegendList');
|
||||
if (roleLegendList) {
|
||||
for (const role of (window.ROLE_SORT || ['repeater', 'companion', 'room', 'sensor', 'observer'])) {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<span class="live-dot" style="background:${ROLE_COLORS[role] || '#6b7280'}" aria-hidden="true"></span> ${(ROLE_LABELS[role] || role).replace(/s$/, '')}`;
|
||||
roleLegendList.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
// Node detail panel
|
||||
const nodeDetailPanel = document.getElementById('liveNodeDetail');
|
||||
const nodeDetailContent = document.getElementById('nodeDetailContent');
|
||||
document.getElementById('nodeDetailClose').addEventListener('click', () => {
|
||||
nodeDetailPanel.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Feed panel resize handle (#27)
|
||||
const savedFeedWidth = localStorage.getItem('live-feed-width');
|
||||
if (savedFeedWidth) feedEl.style.width = savedFeedWidth + 'px';
|
||||
@@ -908,8 +919,8 @@
|
||||
const topNav = document.querySelector('.top-nav');
|
||||
if (topNav) { topNav.style.position = 'fixed'; topNav.style.width = '100%'; topNav.style.zIndex = '1100'; }
|
||||
_navCleanup = { timeout: null, fn: null, pinned: false };
|
||||
// Add pin button to nav
|
||||
if (topNav) {
|
||||
// Add pin button to nav (guard against duplicate)
|
||||
if (topNav && !document.getElementById('navPinBtn')) {
|
||||
const pinBtn = document.createElement('button');
|
||||
pinBtn.id = 'navPinBtn';
|
||||
pinBtn.className = 'nav-pin-btn';
|
||||
@@ -966,6 +977,80 @@
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async function showNodeDetail(pubkey) {
|
||||
const panel = document.getElementById('liveNodeDetail');
|
||||
const content = document.getElementById('nodeDetailContent');
|
||||
panel.classList.remove('hidden');
|
||||
content.innerHTML = '<div style="padding:20px;color:var(--text-muted)">Loading…</div>';
|
||||
try {
|
||||
const [data, healthData] = await Promise.all([
|
||||
api('/nodes/' + encodeURIComponent(pubkey), { ttl: 30 }),
|
||||
api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: 30 }).catch(() => null)
|
||||
]);
|
||||
const n = data.node;
|
||||
const h = healthData || {};
|
||||
const stats = h.stats || {};
|
||||
const observers = h.observers || [];
|
||||
const recent = h.recentPackets || [];
|
||||
const roleColor = ROLE_COLORS[n.role] || '#6b7280';
|
||||
const roleLabel = (ROLE_LABELS[n.role] || n.role || 'unknown').replace(/s$/, '');
|
||||
const hasLoc = n.lat != null && n.lon != null;
|
||||
const lastSeen = n.last_seen ? timeAgo(n.last_seen) : '—';
|
||||
const thresholds = window.getHealthThresholds ? getHealthThresholds(n.role) : { degradedMs: 3600000, silentMs: 86400000 };
|
||||
const ageMs = n.last_seen ? Date.now() - new Date(n.last_seen).getTime() : Infinity;
|
||||
const statusDot = ageMs < thresholds.degradedMs ? 'health-green' : ageMs < thresholds.silentMs ? 'health-yellow' : 'health-red';
|
||||
const statusLabel = ageMs < thresholds.degradedMs ? 'Online' : ageMs < thresholds.silentMs ? 'Degraded' : 'Offline';
|
||||
|
||||
let html = `
|
||||
<div style="padding:16px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<span class="${statusDot}" style="font-size:18px">●</span>
|
||||
<h3 style="margin:0;font-size:16px;font-weight:700;">${escapeHtml(n.name || 'Unknown')}</h3>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<span style="display:inline-block;padding:2px 10px;border-radius:12px;font-size:11px;font-weight:600;background:${roleColor};color:#fff;">${roleLabel.toUpperCase()}</span>
|
||||
<span style="color:var(--text-muted);font-size:12px;margin-left:8px;">${statusLabel}</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px;">
|
||||
<code style="font-size:10px;word-break:break-all;">${escapeHtml(n.public_key)}</code>
|
||||
</div>
|
||||
<table style="font-size:12px;width:100%;border-collapse:collapse;">
|
||||
<tr><td style="color:var(--text-muted);padding:4px 8px 4px 0;">Last Seen</td><td>${lastSeen}</td></tr>
|
||||
<tr><td style="color:var(--text-muted);padding:4px 8px 4px 0;">Adverts</td><td>${n.advert_count || 0}</td></tr>
|
||||
${hasLoc ? `<tr><td style="color:var(--text-muted);padding:4px 8px 4px 0;">Location</td><td>${n.lat.toFixed(5)}, ${n.lon.toFixed(5)}</td></tr>` : ''}
|
||||
${stats.avgSnr != null ? `<tr><td style="color:var(--text-muted);padding:4px 8px 4px 0;">Avg SNR</td><td>${stats.avgSnr.toFixed(1)} dB</td></tr>` : ''}
|
||||
${stats.avgHops != null ? `<tr><td style="color:var(--text-muted);padding:4px 8px 4px 0;">Avg Hops</td><td>${stats.avgHops.toFixed(1)}</td></tr>` : ''}
|
||||
${stats.totalTransmissions || stats.totalPackets ? `<tr><td style="color:var(--text-muted);padding:4px 8px 4px 0;">Total Packets</td><td>${stats.totalTransmissions || stats.totalPackets}</td></tr>` : ''}
|
||||
</table>`;
|
||||
|
||||
if (observers.length) {
|
||||
html += `<h4 style="font-size:12px;margin:12px 0 6px;color:var(--text-muted);">Heard By</h4>
|
||||
<div style="font-size:11px;">` +
|
||||
observers.map(o => `<div style="padding:2px 0;"><a href="#/observers/${encodeURIComponent(o.observer_id)}" style="color:var(--accent);text-decoration:none;">${escapeHtml(o.observer_name || o.observer_id.slice(0, 12))}</a> — ${o.packetCount || o.count || 0} pkts</div>`).join('') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
if (recent.length) {
|
||||
html += `<h4 style="font-size:12px;margin:12px 0 6px;color:var(--text-muted);">Recent Packets</h4>
|
||||
<div style="font-size:11px;max-height:200px;overflow-y:auto;">` +
|
||||
recent.slice(0, 10).map(p => `<div style="padding:2px 0;display:flex;justify-content:space-between;">
|
||||
<a href="#/packets/${encodeURIComponent(p.hash || '')}" style="color:var(--accent);text-decoration:none;">${escapeHtml(p.payload_type || '?')}${p.observation_count > 1 ? ' <span class="badge badge-obs" style="font-size:9px">👁 ' + p.observation_count + '</span>' : ''}</a>
|
||||
<span style="color:var(--text-muted)">${p.timestamp ? timeAgo(p.timestamp) : '—'}</span>
|
||||
</div>`).join('') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
html += `<div style="margin-top:12px;display:flex;gap:8px;">
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}" style="font-size:12px;color:var(--accent);">Full Detail →</a>
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" style="font-size:12px;color:var(--accent);">📊 Analytics</a>
|
||||
</div></div>`;
|
||||
|
||||
content.innerHTML = html;
|
||||
} catch (e) {
|
||||
content.innerHTML = `<div style="padding:20px;color:var(--text-muted);">Error: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNodes(beforeTs) {
|
||||
try {
|
||||
const url = beforeTs
|
||||
@@ -980,7 +1065,7 @@
|
||||
addNodeMarker(n);
|
||||
}
|
||||
});
|
||||
document.getElementById('liveNodeCount').textContent = Object.keys(nodeMarkers).length;
|
||||
const _el2 = document.getElementById('liveNodeCount'); if (_el2) _el2.textContent = Object.keys(nodeMarkers).length;
|
||||
} catch (e) { console.error('Failed to load nodes:', e); }
|
||||
}
|
||||
|
||||
@@ -1014,6 +1099,8 @@
|
||||
permanent: false, direction: 'top', offset: [0, -10], className: 'live-tooltip'
|
||||
});
|
||||
|
||||
marker.on('click', () => showNodeDetail(n.public_key));
|
||||
|
||||
marker._glowMarker = glow;
|
||||
marker._baseColor = color;
|
||||
marker._baseSize = size;
|
||||
@@ -1059,14 +1146,14 @@
|
||||
if (msg.type === 'packet') bufferPacket(msg.data);
|
||||
} catch {}
|
||||
};
|
||||
ws.onclose = () => setTimeout(connectWS, 3000);
|
||||
ws.onclose = () => setTimeout(connectWS, WS_RECONNECT_MS);
|
||||
ws.onerror = () => {};
|
||||
}
|
||||
|
||||
function animatePacket(pkt) {
|
||||
packetCount++;
|
||||
pktTimestamps.push(Date.now());
|
||||
document.getElementById('livePktCount').textContent = packetCount;
|
||||
const _el = document.getElementById('livePktCount'); if (_el) _el.textContent = packetCount;
|
||||
|
||||
const decoded = pkt.decoded || {};
|
||||
const header = decoded.header || {};
|
||||
@@ -1086,7 +1173,7 @@
|
||||
const n = { public_key: key, name: payload.name || key.slice(0,8), role: payload.role || 'unknown', lat: payload.lat, lon: payload.lon };
|
||||
nodeData[key] = n;
|
||||
addNodeMarker(n);
|
||||
document.getElementById('liveNodeCount').textContent = Object.keys(nodeMarkers).length;
|
||||
const _el2 = document.getElementById('liveNodeCount'); if (_el2) _el2.textContent = Object.keys(nodeMarkers).length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1154,7 +1241,7 @@
|
||||
|
||||
// Sanity check: drop hops that are impossibly far from both neighbors (>200km ≈ 1.8°)
|
||||
// These are almost certainly 1-byte prefix collisions with distant nodes
|
||||
const MAX_HOP_DIST = 1.8;
|
||||
// MAX_HOP_DIST from shared roles.js
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
if (!raw[i].known || !raw[i].pos) continue;
|
||||
const prev = i > 0 && raw[i-1].known && raw[i-1].pos ? raw[i-1].pos : null;
|
||||
@@ -1387,6 +1474,7 @@
|
||||
const text = payload.text || payload.name || '';
|
||||
const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
|
||||
const hopStr = hops.length ? `<span class="feed-hops">${hops.length}⇢</span>` : '';
|
||||
const obsBadge = pkt.observation_count > 1 ? `<span class="badge badge-obs" style="font-size:10px;margin-left:4px">👁 ${pkt.observation_count}</span>` : '';
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'live-feed-item live-feed-enter';
|
||||
@@ -1396,7 +1484,7 @@
|
||||
item.innerHTML = `
|
||||
<span class="feed-icon" style="color:${color}">${icon}</span>
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
${hopStr}
|
||||
${hopStr}${obsBadge}
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time">${new Date(pkt._ts || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}</span>
|
||||
`;
|
||||
@@ -1466,6 +1554,8 @@
|
||||
if (appEl) appEl.style.height = '';
|
||||
const topNav = document.querySelector('.top-nav');
|
||||
if (topNav) { topNav.classList.remove('nav-autohide'); topNav.style.position = ''; topNav.style.width = ''; topNav.style.zIndex = ''; }
|
||||
const existingPin = document.getElementById('navPinBtn');
|
||||
if (existingPin) existingPin.remove();
|
||||
if (_navCleanup) {
|
||||
clearTimeout(_navCleanup.timeout);
|
||||
const livePage = document.querySelector('.live-page');
|
||||
|
||||
+79
-24
@@ -8,7 +8,7 @@
|
||||
let clusterGroup = null;
|
||||
let nodes = [];
|
||||
let observers = [];
|
||||
let filters = { repeater: true, companion: true, room: true, sensor: true, lastHeard: '30d', mqttOnly: false, neighbors: false, clusters: false };
|
||||
let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clusters: false };
|
||||
let wsHandler = null;
|
||||
let heatLayer = null;
|
||||
let userHasMoved = false;
|
||||
@@ -17,16 +17,7 @@
|
||||
// Safe escape — falls back to identity if app.js hasn't loaded yet
|
||||
const safeEsc = (typeof esc === 'function') ? esc : function (s) { return s; };
|
||||
|
||||
// Distinct shapes + high-contrast WCAG AA colors for each role
|
||||
const ROLE_STYLE = {
|
||||
repeater: { color: '#dc2626', shape: 'diamond', radius: 10, weight: 2 }, // red diamond
|
||||
companion: { color: '#2563eb', shape: 'circle', radius: 8, weight: 2 }, // blue circle
|
||||
room: { color: '#16a34a', shape: 'square', radius: 9, weight: 2 }, // green square
|
||||
sensor: { color: '#d97706', shape: 'triangle', radius: 8, weight: 2 }, // amber triangle
|
||||
};
|
||||
|
||||
const ROLE_LABELS = { repeater: 'Repeaters', companion: 'Companions', room: 'Room Servers', sensor: 'Sensors' };
|
||||
const ROLE_COLORS = { repeater: '#dc2626', companion: '#2563eb', room: '#16a34a', sensor: '#d97706' };
|
||||
// Roles loaded from shared roles.js (ROLE_STYLE, ROLE_LABELS, ROLE_COLORS globals)
|
||||
|
||||
function makeMarkerIcon(role) {
|
||||
const s = ROLE_STYLE[role] || ROLE_STYLE.companion;
|
||||
@@ -43,6 +34,19 @@
|
||||
case 'triangle':
|
||||
path = `<polygon points="${c},2 ${size-2},${size-2} 2,${size-2}" fill="${s.color}" stroke="#fff" stroke-width="2"/>`;
|
||||
break;
|
||||
case 'star': {
|
||||
// 5-pointed star
|
||||
const cx = c, cy = c, outer = c - 1, inner = outer * 0.4;
|
||||
let pts = '';
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const aOuter = (i * 72 - 90) * Math.PI / 180;
|
||||
const aInner = ((i * 72) + 36 - 90) * Math.PI / 180;
|
||||
pts += `${cx + outer * Math.cos(aOuter)},${cy + outer * Math.sin(aOuter)} `;
|
||||
pts += `${cx + inner * Math.cos(aInner)},${cy + inner * Math.sin(aInner)} `;
|
||||
}
|
||||
path = `<polygon points="${pts.trim()}" fill="${s.color}" stroke="#fff" stroke-width="1.5"/>`;
|
||||
break;
|
||||
}
|
||||
default: // circle
|
||||
path = `<circle cx="${c}" cy="${c}" r="${c-2}" fill="${s.color}" stroke="#fff" stroke-width="2"/>`;
|
||||
}
|
||||
@@ -74,7 +78,6 @@
|
||||
</fieldset>
|
||||
<fieldset class="mc-section">
|
||||
<legend class="mc-label">Filters</legend>
|
||||
<label for="mcMqtt"><input type="checkbox" id="mcMqtt"> MQTT Connected Only</label>
|
||||
<label for="mcNeighbors"><input type="checkbox" id="mcNeighbors"> Show direct neighbors</label>
|
||||
</fieldset>
|
||||
<fieldset class="mc-section">
|
||||
@@ -105,10 +108,19 @@
|
||||
try { const v = JSON.parse(savedView); initCenter = [v.lat, v.lng]; initZoom = v.zoom; } catch {}
|
||||
}
|
||||
map = L.map('leaflet-map', { zoomControl: true }).setView(initCenter, initZoom);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap',
|
||||
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const tileLayer = L.tileLayer(isDark ? TILE_DARK : TILE_LIGHT, {
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
const _mapThemeObs = new MutationObserver(function () {
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
tileLayer.setUrl(dark ? TILE_DARK : TILE_LIGHT);
|
||||
});
|
||||
_mapThemeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
|
||||
// Save position on move
|
||||
map.on('moveend', () => {
|
||||
@@ -141,7 +153,6 @@
|
||||
// Bind controls
|
||||
document.getElementById('mcClusters').addEventListener('change', e => { filters.clusters = e.target.checked; renderMarkers(); });
|
||||
document.getElementById('mcHeatmap').addEventListener('change', e => { toggleHeatmap(e.target.checked); });
|
||||
document.getElementById('mcMqtt').addEventListener('change', e => { filters.mqttOnly = e.target.checked; renderMarkers(); });
|
||||
document.getElementById('mcNeighbors').addEventListener('change', e => { filters.neighbors = e.target.checked; renderMarkers(); });
|
||||
document.getElementById('mcLastHeard').addEventListener('change', e => { filters.lastHeard = e.target.value; loadNodes(); });
|
||||
|
||||
@@ -245,13 +256,17 @@
|
||||
|
||||
async function loadNodes() {
|
||||
try {
|
||||
const data = await api(`/nodes?limit=10000&lastHeard=${filters.lastHeard}`);
|
||||
nodes = data.nodes || [];
|
||||
buildRoleChecks(data.counts || {});
|
||||
// Load regions from config + observed IATAs
|
||||
try { REGION_NAMES = await api('/config/regions', { ttl: 3600 }); } catch {}
|
||||
|
||||
// Load observers for jump buttons
|
||||
const obsData = await api('/observers');
|
||||
const data = await api(`/nodes?limit=10000&lastHeard=${filters.lastHeard}`, { ttl: CLIENT_TTL.nodeList });
|
||||
nodes = data.nodes || [];
|
||||
|
||||
// Load observers for jump buttons + map markers
|
||||
const obsData = await api('/observers', { ttl: CLIENT_TTL.observers });
|
||||
observers = obsData.observers || [];
|
||||
|
||||
buildRoleChecks(data.counts || {});
|
||||
buildJumpButtons();
|
||||
|
||||
renderMarkers();
|
||||
@@ -266,12 +281,14 @@
|
||||
const el = document.getElementById('mcRoleChecks');
|
||||
if (!el) return;
|
||||
el.innerHTML = '';
|
||||
for (const role of ['repeater', 'companion', 'room', 'sensor']) {
|
||||
const count = counts[role + 's'] || 0;
|
||||
const obsCount = observers.filter(o => o.lat && o.lon).length;
|
||||
const roles = ['repeater', 'companion', 'room', 'sensor', 'observer'];
|
||||
const shapeMap = { repeater: '◆', companion: '●', room: '■', sensor: '▲', observer: '★' };
|
||||
for (const role of roles) {
|
||||
const count = role === 'observer' ? obsCount : (counts[role + 's'] || 0);
|
||||
const cbId = 'mcRole_' + role;
|
||||
const lbl = document.createElement('label');
|
||||
lbl.setAttribute('for', cbId);
|
||||
const shapeMap = { repeater: '◆', companion: '●', room: '■', sensor: '▲' };
|
||||
const shape = shapeMap[role] || '●';
|
||||
lbl.innerHTML = `<input type="checkbox" id="${cbId}" data-role="${role}" ${filters[role] ? 'checked' : ''}> <span style="color:${ROLE_COLORS[role]};font-weight:600;" aria-hidden="true">${shape}</span> ${ROLE_LABELS[role]} <span style="color:var(--text-muted)">(${count})</span>`;
|
||||
lbl.querySelector('input').addEventListener('change', e => {
|
||||
@@ -282,7 +299,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
const REGION_NAMES = { SJC: 'San Jose', SFO: 'San Francisco', OAK: 'Oakland', MTV: 'Mountain View', SCZ: 'Santa Cruz', MRY: 'Monterey', PAO: 'Palo Alto' };
|
||||
let REGION_NAMES = {};
|
||||
|
||||
function buildJumpButtons() {
|
||||
const el = document.getElementById('mcJumps');
|
||||
@@ -347,6 +364,44 @@
|
||||
marker.bindPopup(buildPopup(node), { maxWidth: 280 });
|
||||
markerLayer.addLayer(marker);
|
||||
}
|
||||
|
||||
// Add observer markers
|
||||
if (filters.observer) {
|
||||
for (const obs of observers) {
|
||||
if (!obs.lat || !obs.lon) continue;
|
||||
const icon = makeMarkerIcon('observer');
|
||||
const marker = L.marker([obs.lat, obs.lon], {
|
||||
icon,
|
||||
alt: `${obs.name || obs.id} (observer)`,
|
||||
});
|
||||
marker.bindPopup(buildObserverPopup(obs), { maxWidth: 280 });
|
||||
markerLayer.addLayer(marker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildObserverPopup(obs) {
|
||||
const name = safeEsc(obs.name || obs.id || 'Unknown');
|
||||
const iata = obs.iata ? `<span class="badge-region">${safeEsc(obs.iata)}</span>` : '';
|
||||
const lastSeen = obs.last_seen ? timeAgo(obs.last_seen) : '—';
|
||||
const packets = (obs.packet_count || 0).toLocaleString();
|
||||
const loc = `${obs.lat.toFixed(5)}, ${obs.lon.toFixed(5)}`;
|
||||
const roleBadge = `<span style="display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600;background:${ROLE_COLORS.observer};color:#fff;">OBSERVER</span>`;
|
||||
|
||||
return `
|
||||
<div class="map-popup" style="font-family:var(--font);min-width:180px;">
|
||||
<h3 style="font-weight:700;font-size:14px;margin:0 0 4px;">${name}</h3>
|
||||
${roleBadge} ${iata}
|
||||
<dl style="margin-top:8px;font-size:12px;">
|
||||
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Location</dt>
|
||||
<dd style="margin-left:88px;padding:2px 0;">${loc}</dd>
|
||||
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Last Seen</dt>
|
||||
<dd style="margin-left:88px;padding:2px 0;">${lastSeen}</dd>
|
||||
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Packets</dt>
|
||||
<dd style="margin-left:88px;padding:2px 0;">${packets}</dd>
|
||||
</dl>
|
||||
<a href="#/observers/${encodeURIComponent(obs.id || obs.observer_id)}" style="display:block;margin-top:8px;font-size:12px;color:var(--accent);">View Detail →</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function buildPopup(node) {
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await api('/nodes/' + encodeURIComponent(pubkey) + '/analytics?days=' + days);
|
||||
data = await api('/nodes/' + encodeURIComponent(pubkey) + '/analytics?days=' + days, { ttl: CLIENT_TTL.nodeAnalytics });
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div style="padding:40px;text-align:center;color:#ff6b6b">Failed to load analytics: ' + escapeHtml(e.message) + '</div>';
|
||||
return;
|
||||
@@ -55,7 +55,7 @@
|
||||
<div style="margin-bottom:12px">
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}" style="color:var(--accent);text-decoration:none;font-size:12px">← Back to ${nodeName}</a>
|
||||
<h2 style="margin:4px 0 2px;font-size:18px">📊 ${nodeName} — Analytics</h2>
|
||||
<div style="color:var(--text-muted);font-size:11px">${n.role || 'Unknown role'} · ${s.totalPackets} packets in ${days}d window</div>
|
||||
<div style="color:var(--text-muted);font-size:11px">${n.role || 'Unknown role'} · ${s.totalTransmissions || s.totalPackets} packets in ${days}d window</div>
|
||||
</div>
|
||||
|
||||
<div class="analytics-time-range" id="timeRangeBtns">
|
||||
|
||||
+14
-16
@@ -24,7 +24,7 @@
|
||||
let wsHandler = null;
|
||||
let detailMap = null;
|
||||
|
||||
const ROLE_COLORS = { repeater: '#3b82f6', room: '#6b7280', companion: '#22c55e', sensor: '#f59e0b' };
|
||||
// ROLE_COLORS loaded from shared roles.js
|
||||
const TABS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'repeater', label: 'Repeaters' },
|
||||
@@ -85,8 +85,8 @@
|
||||
const body = document.getElementById('nodeFullBody');
|
||||
try {
|
||||
const [nodeData, healthData] = await Promise.all([
|
||||
api('/nodes/' + encodeURIComponent(pubkey)),
|
||||
api('/nodes/' + encodeURIComponent(pubkey) + '/health').catch(() => null)
|
||||
api('/nodes/' + encodeURIComponent(pubkey), { ttl: CLIENT_TTL.nodeDetail }),
|
||||
api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeDetail }).catch(() => null)
|
||||
]);
|
||||
const n = nodeData.node;
|
||||
const adverts = nodeData.recentAdverts || [];
|
||||
@@ -107,9 +107,7 @@
|
||||
// Repeaters/rooms: flood advert every 12-24h, so degraded after 24h, silent after 72h
|
||||
// Companions/sensors: user-initiated adverts, shorter thresholds
|
||||
const role = (n.role || '').toLowerCase();
|
||||
const isInfra = role === 'repeater' || role === 'room';
|
||||
const degradedMs = isInfra ? 86400000 : 3600000; // 24h : 1h
|
||||
const silentMs = isInfra ? 259200000 : 86400000; // 72h : 24h
|
||||
const { degradedMs, silentMs } = getHealthThresholds(role);
|
||||
const statusLabel = statusAge < degradedMs ? '🟢 Active' : statusAge < silentMs ? '🟡 Degraded' : '🔴 Silent';
|
||||
|
||||
body.innerHTML = `
|
||||
@@ -130,7 +128,7 @@
|
||||
<dl class="detail-meta">
|
||||
<dt>Last Heard</dt><dd>${lastHeard ? timeAgo(lastHeard) : (n.last_seen ? timeAgo(n.last_seen) : '—')}</dd>
|
||||
<dt>First Seen</dt><dd>${n.first_seen ? new Date(n.first_seen).toLocaleString() : '—'}</dd>
|
||||
<dt>Total Packets</dt><dd>${stats.totalPackets || n.advert_count || 0}</dd>
|
||||
<dt>Total Packets</dt><dd>${stats.totalTransmissions || stats.totalPackets || n.advert_count || 0}${stats.totalObservations && stats.totalObservations !== (stats.totalTransmissions || stats.totalPackets) ? ' <span class="text-muted" style="font-size:0.85em">(seen ' + stats.totalObservations + '×)</span>' : ''}</dd>
|
||||
<dt>Packets Today</dt><dd>${stats.packetsToday || 0}</dd>
|
||||
${stats.avgSnr != null ? `<dt>Avg SNR</dt><dd>${stats.avgSnr.toFixed(1)} dB</dd>` : ''}
|
||||
${stats.avgHops ? `<dt>Avg Hops</dt><dd>${stats.avgHops}</dd>` : ''}
|
||||
@@ -163,9 +161,10 @@
|
||||
const obs = p.observer_name || p.observer_id;
|
||||
const snr = p.snr != null ? ` · SNR ${p.snr}dB` : '';
|
||||
const rssi = p.rssi != null ? ` · RSSI ${p.rssi}dBm` : '';
|
||||
const obsBadge = p.observation_count > 1 ? ` <span class="badge badge-obs" title="Seen ${p.observation_count} times">👁 ${p.observation_count}</span>` : '';
|
||||
return `<div class="node-activity-item">
|
||||
<span class="node-activity-time">${timeAgo(p.timestamp)}</span>
|
||||
<span>${typeLabel}${detail}${obs ? ' via ' + escapeHtml(obs) : ''}${snr}${rssi}</span>
|
||||
<span>${typeLabel}${detail}${obsBadge}${obs ? ' via ' + escapeHtml(obs) : ''}${snr}${rssi}</span>
|
||||
<a href="#/packets/id/${p.id}" class="ch-analyze-link" style="margin-left:8px;font-size:0.8em">Analyze →</a>
|
||||
</div>`;
|
||||
}).join('') : '<div class="text-muted">No recent packets</div>'}
|
||||
@@ -228,7 +227,7 @@
|
||||
if (activeTab !== 'all') params.set('role', activeTab);
|
||||
if (search) params.set('search', search);
|
||||
if (lastHeard) params.set('lastHeard', lastHeard);
|
||||
const data = await api('/nodes?' + params);
|
||||
const data = await api('/nodes?' + params, { ttl: CLIENT_TTL.nodeList });
|
||||
nodes = data.nodes || [];
|
||||
counts = data.counts || {};
|
||||
|
||||
@@ -238,7 +237,7 @@
|
||||
const missing = myNodes.filter(mn => !existingKeys.has(mn.pubkey));
|
||||
if (missing.length) {
|
||||
const fetched = await Promise.allSettled(
|
||||
missing.map(mn => api('/nodes/' + encodeURIComponent(mn.pubkey)))
|
||||
missing.map(mn => api('/nodes/' + encodeURIComponent(mn.pubkey), { ttl: CLIENT_TTL.nodeDetail }))
|
||||
);
|
||||
fetched.forEach(r => {
|
||||
if (r.status === 'fulfilled' && r.value && r.value.public_key) nodes.push(r.value);
|
||||
@@ -401,8 +400,8 @@
|
||||
|
||||
try {
|
||||
const [data, healthData] = await Promise.all([
|
||||
api('/nodes/' + encodeURIComponent(pubkey)),
|
||||
api('/nodes/' + encodeURIComponent(pubkey) + '/health').catch(() => null)
|
||||
api('/nodes/' + encodeURIComponent(pubkey), { ttl: CLIENT_TTL.nodeDetail }),
|
||||
api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeDetail }).catch(() => null)
|
||||
]);
|
||||
data.healthData = healthData;
|
||||
renderDetail(panel, data);
|
||||
@@ -426,11 +425,9 @@
|
||||
const lastHeard = stats.lastHeard;
|
||||
const statusAge = lastHeard ? (Date.now() - new Date(lastHeard).getTime()) : Infinity;
|
||||
const role = (n.role || '').toLowerCase();
|
||||
const isInfra = role === 'repeater' || role === 'room';
|
||||
const degradedMs = isInfra ? 86400000 : 3600000;
|
||||
const silentMs = isInfra ? 259200000 : 86400000;
|
||||
const { degradedMs, silentMs } = getHealthThresholds(role);
|
||||
const statusLabel = statusAge < degradedMs ? '🟢 Active' : statusAge < silentMs ? '🟡 Degraded' : '🔴 Silent';
|
||||
const totalPackets = stats.totalPackets || n.advert_count || 0;
|
||||
const totalPackets = stats.totalTransmissions || stats.totalPackets || n.advert_count || 0;
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="node-detail">
|
||||
@@ -484,6 +481,7 @@
|
||||
<span class="advert-dot" style="background:${roleColor}"></span>
|
||||
<div class="advert-info">
|
||||
<strong>${timeAgo(a.timestamp)}</strong> ${icon} ${pType}${detail}
|
||||
${a.observation_count > 1 ? ' <span class="badge badge-obs">👁 ' + a.observation_count + '</span>' : ''}
|
||||
${obs ? ' via ' + escapeHtml(obs) : ''}
|
||||
${a.snr != null ? ` · SNR ${a.snr}dB` : ''}${a.rssi != null ? ` · RSSI ${a.rssi}dBm` : ''}
|
||||
<br><a href="#/packets/id/${a.id}" class="ch-analyze-link">Analyze →</a>
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
/* === MeshCore Analyzer — observer-detail.js === */
|
||||
'use strict';
|
||||
(function () {
|
||||
const PAYLOAD_LABELS = { 0: 'Request', 1: 'Response', 2: 'Direct Msg', 3: 'ACK', 4: 'Advert', 5: 'Channel Msg', 7: 'Anon Req', 8: 'Path', 9: 'Trace', 11: 'Control' };
|
||||
const CHART_COLORS = ['#4a9eff', '#ff6b6b', '#51cf66', '#fcc419', '#cc5de8', '#20c997', '#ff922b', '#845ef7', '#f06595', '#339af0'];
|
||||
|
||||
let charts = [];
|
||||
let currentDays = 7;
|
||||
let currentId = null;
|
||||
|
||||
function destroyCharts() {
|
||||
charts.forEach(c => { try { c.destroy(); } catch {} });
|
||||
charts = [];
|
||||
}
|
||||
|
||||
function chartDefaults() {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
Chart.defaults.color = style.getPropertyValue('--text-muted').trim() || '#6b7280';
|
||||
Chart.defaults.borderColor = style.getPropertyValue('--border').trim() || '#e2e5ea';
|
||||
}
|
||||
|
||||
function formatDuration(secs) {
|
||||
if (!secs) return '—';
|
||||
const d = Math.floor(secs / 86400);
|
||||
const h = Math.floor((secs % 86400) / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
if (d > 0) return d + 'd ' + h + 'h';
|
||||
if (h > 0) return h + 'h ' + m + 'm';
|
||||
return m + 'm';
|
||||
}
|
||||
|
||||
function init(app, routeParam) {
|
||||
currentId = routeParam;
|
||||
if (!currentId) {
|
||||
app.innerHTML = '<div class="text-center text-muted" style="padding:40px">No observer ID specified.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="observer-detail-page" style="overflow-y:auto;height:calc(100vh - 56px);padding:16px">
|
||||
<div class="page-header" style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
|
||||
<a href="#/observers" class="btn-icon" title="Back to Observers" aria-label="Back">←</a>
|
||||
<h2 style="margin:0" id="obsTitle">Observer Detail</h2>
|
||||
<div style="margin-left:auto;display:flex;gap:8px">
|
||||
<select id="obsDaysSelect" class="time-range-select" aria-label="Time range">
|
||||
<option value="1">24 Hours</option>
|
||||
<option value="3">3 Days</option>
|
||||
<option value="7" selected>7 Days</option>
|
||||
<option value="30">30 Days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="obsDetailContent"><div class="text-center text-muted" style="padding:40px">Loading…</div></div>
|
||||
</div>`;
|
||||
|
||||
document.getElementById('obsDaysSelect').addEventListener('change', function (e) {
|
||||
currentDays = parseInt(e.target.value);
|
||||
loadDetail();
|
||||
});
|
||||
|
||||
loadDetail();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
destroyCharts();
|
||||
currentId = null;
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
try {
|
||||
destroyCharts();
|
||||
chartDefaults();
|
||||
const [obs, analytics] = await Promise.all([
|
||||
api('/observers/' + encodeURIComponent(currentId)),
|
||||
api('/observers/' + encodeURIComponent(currentId) + '/analytics?days=' + currentDays),
|
||||
]);
|
||||
renderDetail(obs, analytics);
|
||||
} catch (e) {
|
||||
document.getElementById('obsDetailContent').innerHTML =
|
||||
'<div class="text-muted" style="padding:40px">Error: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDetail(obs, analytics) {
|
||||
const el = document.getElementById('obsDetailContent');
|
||||
if (!el) return;
|
||||
|
||||
const title = document.getElementById('obsTitle');
|
||||
if (title) title.textContent = obs.name || obs.id.substring(0, 16) + '…';
|
||||
|
||||
// Parse radio string
|
||||
let radioHtml = '—';
|
||||
if (obs.radio) {
|
||||
const rp = obs.radio.split(',');
|
||||
radioHtml = rp[0] + ' MHz · SF' + (rp[2] || '?') + ' · BW' + (rp[1] || '?') + ' · CR' + (rp[3] || '?');
|
||||
}
|
||||
|
||||
// Health status
|
||||
const ago = obs.last_seen ? Date.now() - new Date(obs.last_seen).getTime() : Infinity;
|
||||
const statusCls = ago < 600000 ? 'health-green' : ago < HEALTH_THRESHOLDS.nodeDegradedMs ? 'health-yellow' : 'health-red';
|
||||
const statusLabel = ago < 600000 ? 'Online' : ago < HEALTH_THRESHOLDS.nodeDegradedMs ? 'Stale' : 'Offline';
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="obs-info-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-bottom:20px">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Status</div>
|
||||
<div class="stat-value"><span class="health-dot ${statusCls}">●</span> ${statusLabel}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Region</div>
|
||||
<div class="stat-value">${obs.iata ? '<span class="badge-region">' + obs.iata + '</span>' : '—'}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Model</div>
|
||||
<div class="stat-value">${obs.model || '—'}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Firmware</div>
|
||||
<div class="stat-value" style="font-size:0.8em;word-break:break-all">${obs.firmware || '—'}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Client</div>
|
||||
<div class="stat-value" style="font-size:0.8em;word-break:break-all">${obs.client_version || '—'}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Radio</div>
|
||||
<div class="stat-value" style="font-size:0.85em">${radioHtml}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Battery</div>
|
||||
<div class="stat-value">${obs.battery_mv ? obs.battery_mv + ' mV' : '—'}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Uptime</div>
|
||||
<div class="stat-value">${formatDuration(obs.uptime_secs)}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Noise Floor</div>
|
||||
<div class="stat-value">${obs.noise_floor != null ? obs.noise_floor + ' dBm' : '—'}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total Packets</div>
|
||||
<div class="stat-value">${(obs.packet_count || 0).toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Packets/Hour</div>
|
||||
<div class="stat-value">${(obs.packetsLastHour || 0).toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">First Seen</div>
|
||||
<div class="stat-value" style="font-size:0.85em">${obs.first_seen ? new Date(obs.first_seen).toLocaleDateString() : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mono" style="font-size:0.75em;color:var(--text-muted);margin-bottom:20px;word-break:break-all">
|
||||
ID: ${obs.id}
|
||||
</div>
|
||||
<div class="obs-charts" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(400px,1fr));gap:16px">
|
||||
<div class="chart-card" style="padding:12px">
|
||||
<h3 style="margin:0 0 8px;font-size:0.95em">Packets Over Time</h3>
|
||||
<canvas id="obsTimeChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-card" style="padding:12px">
|
||||
<h3 style="margin:0 0 8px;font-size:0.95em">Packet Types</h3>
|
||||
<div style="max-width:280px;margin:0 auto"><canvas id="obsTypeChart"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card" style="padding:12px">
|
||||
<h3 style="margin:0 0 8px;font-size:0.95em">Unique Nodes Heard</h3>
|
||||
<canvas id="obsNodesChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-card" style="padding:12px">
|
||||
<h3 style="margin:0 0 8px;font-size:0.95em">SNR Distribution</h3>
|
||||
<canvas id="obsSnrChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:20px">
|
||||
<h3 style="font-size:0.95em">Recent Packets</h3>
|
||||
<div id="obsRecentPackets"><div class="text-muted">Loading…</div></div>
|
||||
</div>`;
|
||||
|
||||
// Render charts
|
||||
if (analytics.timeline && analytics.timeline.length > 0) {
|
||||
renderTimelineChart(analytics.timeline);
|
||||
}
|
||||
if (analytics.packetTypes) {
|
||||
renderTypeChart(analytics.packetTypes);
|
||||
}
|
||||
if (analytics.nodesTimeline && analytics.nodesTimeline.length > 0) {
|
||||
renderNodesChart(analytics.nodesTimeline);
|
||||
}
|
||||
if (analytics.snrDistribution && analytics.snrDistribution.length > 0) {
|
||||
renderSnrChart(analytics.snrDistribution);
|
||||
}
|
||||
if (analytics.recentPackets) {
|
||||
renderRecentPackets(analytics.recentPackets);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTimelineChart(timeline) {
|
||||
const ctx = document.getElementById('obsTimeChart');
|
||||
if (!ctx) return;
|
||||
const c = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: timeline.map(t => t.label),
|
||||
datasets: [{
|
||||
label: 'Packets',
|
||||
data: timeline.map(t => t.count),
|
||||
backgroundColor: CHART_COLORS[0] + '80',
|
||||
borderColor: CHART_COLORS[0],
|
||||
borderWidth: 1,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { maxRotation: 45, autoSkip: true, maxTicksLimit: 12 } },
|
||||
y: { beginAtZero: true, ticks: { precision: 0 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
charts.push(c);
|
||||
}
|
||||
|
||||
function renderTypeChart(types) {
|
||||
const ctx = document.getElementById('obsTypeChart');
|
||||
if (!ctx) return;
|
||||
const labels = Object.keys(types).map(k => PAYLOAD_LABELS[k] || 'Type ' + k);
|
||||
const values = Object.values(types);
|
||||
const c = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{ data: values, backgroundColor: CHART_COLORS.slice(0, labels.length) }]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: true,
|
||||
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12 } } }
|
||||
}
|
||||
});
|
||||
charts.push(c);
|
||||
}
|
||||
|
||||
function renderNodesChart(timeline) {
|
||||
const ctx = document.getElementById('obsNodesChart');
|
||||
if (!ctx) return;
|
||||
const c = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: timeline.map(t => t.label),
|
||||
datasets: [{
|
||||
label: 'Unique Nodes',
|
||||
data: timeline.map(t => t.count),
|
||||
borderColor: CHART_COLORS[2],
|
||||
backgroundColor: CHART_COLORS[2] + '20',
|
||||
fill: true, tension: 0.3, pointRadius: 2,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { maxRotation: 45, autoSkip: true, maxTicksLimit: 12 } },
|
||||
y: { beginAtZero: true, ticks: { precision: 0 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
charts.push(c);
|
||||
}
|
||||
|
||||
function renderSnrChart(distribution) {
|
||||
const ctx = document.getElementById('obsSnrChart');
|
||||
if (!ctx) return;
|
||||
const c = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: distribution.map(d => d.range),
|
||||
datasets: [{
|
||||
label: 'Packets',
|
||||
data: distribution.map(d => d.count),
|
||||
backgroundColor: CHART_COLORS[3] + '80',
|
||||
borderColor: CHART_COLORS[3],
|
||||
borderWidth: 1,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { title: { display: true, text: 'SNR (dB)' } },
|
||||
y: { beginAtZero: true, ticks: { precision: 0 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
charts.push(c);
|
||||
}
|
||||
|
||||
function renderRecentPackets(packets) {
|
||||
const el = document.getElementById('obsRecentPackets');
|
||||
if (!el || !packets.length) { if (el) el.innerHTML = '<div class="text-muted">No recent packets.</div>'; return; }
|
||||
el.innerHTML = `<table class="data-table" style="font-size:0.85em">
|
||||
<thead><tr><th>Time</th><th>Type</th><th>Hash</th><th>SNR</th><th>RSSI</th><th>Hops</th></tr></thead>
|
||||
<tbody>${packets.map(p => {
|
||||
const decoded = typeof p.decoded_json === 'string' ? JSON.parse(p.decoded_json) : (p.decoded_json || {});
|
||||
const hops = typeof p.path_json === 'string' ? JSON.parse(p.path_json) : (p.path_json || []);
|
||||
const typeName = PAYLOAD_LABELS[p.payload_type] || 'Type ' + p.payload_type;
|
||||
return `<tr style="cursor:pointer" onclick="location.hash='#/packet/${p.id}'">
|
||||
<td>${timeAgo(p.timestamp)}</td>
|
||||
<td>${typeName}</td>
|
||||
<td class="mono" style="font-size:0.85em">${(p.hash || '').substring(0, 10)}</td>
|
||||
<td>${p.snr != null ? p.snr.toFixed(1) : '—'}</td>
|
||||
<td>${p.rssi != null ? p.rssi : '—'}</td>
|
||||
<td>${hops.length}</td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
registerPage('observer-detail', { init, destroy });
|
||||
})();
|
||||
+4
-5
@@ -38,7 +38,7 @@
|
||||
|
||||
async function loadObservers() {
|
||||
try {
|
||||
const data = await api('/observers');
|
||||
const data = await api('/observers', { ttl: CLIENT_TTL.observers });
|
||||
observers = data.observers || [];
|
||||
render();
|
||||
} catch (e) {
|
||||
@@ -69,10 +69,9 @@
|
||||
}
|
||||
|
||||
function sparkBar(count, max) {
|
||||
const aria = `role="meter" aria-valuenow="${count}" aria-valuemin="0" aria-valuemax="${max}" aria-label="Packet rate"`;
|
||||
if (max === 0) return `<div class="spark-bar" ${aria}><div class="spark-fill" style="width:0"></div></div>`;
|
||||
if (max === 0) return `<span class="text-muted">0/hr</span>`;
|
||||
const pct = Math.min(100, Math.round((count / max) * 100));
|
||||
return `<div class="spark-bar" ${aria}><div class="spark-fill" style="width:${pct}%"></div><span class="spark-label">${count}/hr</span></div>`;
|
||||
return `<span style="display:inline-flex;align-items:center;gap:6px;white-space:nowrap"><span style="display:inline-block;width:60px;height:12px;background:var(--border);border-radius:3px;overflow:hidden;vertical-align:middle"><span style="display:block;height:100%;width:${pct}%;background:linear-gradient(90deg,#3b82f6,#60a5fa);border-radius:3px"></span></span><span style="font-size:11px">${count}/hr</span></span>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
@@ -107,7 +106,7 @@
|
||||
<tbody>${observers.map(o => {
|
||||
const h = healthStatus(o.last_seen);
|
||||
const shape = h.cls === 'health-green' ? '●' : h.cls === 'health-yellow' ? '▲' : '✕';
|
||||
return `<tr>
|
||||
return `<tr style="cursor:pointer" onclick="location.hash='#/observers/${encodeURIComponent(o.id)}'">
|
||||
<td><span class="health-dot ${h.cls}" title="${h.label}">${shape}</span> ${h.label}</td>
|
||||
<td class="mono">${o.name || o.id}</td>
|
||||
<td>${o.iata ? `<span class="badge-region">${o.iata}</span>` : '—'}</td>
|
||||
|
||||
+189
-38
@@ -3,6 +3,13 @@
|
||||
|
||||
(function () {
|
||||
let packets = [];
|
||||
|
||||
// Resolve observer_id to friendly name from loaded observers list
|
||||
function obsName(id) {
|
||||
if (!id) return '—';
|
||||
const o = observers.find(ob => ob.id === id);
|
||||
return o?.name || id;
|
||||
}
|
||||
let selectedId = null;
|
||||
let groupByHash = true;
|
||||
let filters = {};
|
||||
@@ -182,9 +189,81 @@
|
||||
} catch {}
|
||||
}
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
if (msgs.some(function (m) { return m.type === 'packet'; })) {
|
||||
loadPackets();
|
||||
const newPkts = msgs
|
||||
.filter(m => m.type === 'packet' && m.data?.packet)
|
||||
.map(m => m.data.packet);
|
||||
if (!newPkts.length) return;
|
||||
|
||||
// Check if new packets pass current filters
|
||||
const filtered = newPkts.filter(p => {
|
||||
if (filters.type !== undefined && filters.type !== '' && p.payload_type !== Number(filters.type)) return false;
|
||||
if (filters.observer && p.observer_id !== filters.observer) return false;
|
||||
if (filters.hash && p.hash !== filters.hash) return false;
|
||||
if (filters.region) {
|
||||
const obs = observers.find(o => o.id === p.observer_id);
|
||||
if (!obs || obs.iata !== filters.region) return false;
|
||||
}
|
||||
if (filters.node && !(p.decoded_json || '').includes(filters.node)) return false;
|
||||
return true;
|
||||
});
|
||||
if (!filtered.length) return;
|
||||
|
||||
// Resolve any new hops, then update and re-render
|
||||
const newHops = new Set();
|
||||
for (const p of filtered) {
|
||||
try { JSON.parse(p.path_json || '[]').forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
|
||||
}
|
||||
(newHops.size ? resolveHops([...newHops]) : Promise.resolve()).then(() => {
|
||||
if (groupByHash) {
|
||||
// Update existing groups or create new ones
|
||||
for (const p of filtered) {
|
||||
const h = p.hash;
|
||||
const existing = packets.find(g => g.hash === h);
|
||||
if (existing) {
|
||||
existing.count = (existing.count || 1) + 1;
|
||||
existing.observation_count = (existing.observation_count || 1) + 1;
|
||||
existing.latest = p.timestamp > existing.latest ? p.timestamp : existing.latest;
|
||||
// Track unique observers
|
||||
if (p.observer_id && p.observer_id !== existing.observer_id) {
|
||||
existing.observer_count = (existing.observer_count || 1) + 1;
|
||||
}
|
||||
// Keep longest path
|
||||
if (p.path_json && (!existing.path_json || p.path_json.length > existing.path_json.length)) {
|
||||
existing.path_json = p.path_json;
|
||||
existing.raw_hex = p.raw_hex;
|
||||
}
|
||||
// Update decoded_json to latest
|
||||
if (p.decoded_json) existing.decoded_json = p.decoded_json;
|
||||
// Update expanded children if this group is expanded
|
||||
if (expandedHashes.has(h) && existing._children) {
|
||||
existing._children.unshift(p);
|
||||
}
|
||||
} else {
|
||||
// New group
|
||||
packets.unshift({
|
||||
hash: h,
|
||||
count: 1,
|
||||
observer_count: 1,
|
||||
latest: p.timestamp,
|
||||
observer_id: p.observer_id,
|
||||
observer_name: p.observer_name,
|
||||
path_json: p.path_json,
|
||||
payload_type: p.payload_type,
|
||||
raw_hex: p.raw_hex,
|
||||
decoded_json: p.decoded_json,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Re-sort by latest DESC, cap size
|
||||
packets.sort((a, b) => (b.latest || '').localeCompare(a.latest || ''));
|
||||
packets = packets.slice(0, 200);
|
||||
} else {
|
||||
// Flat mode: prepend
|
||||
packets = filtered.concat(packets).slice(0, 200);
|
||||
}
|
||||
totalCount += filtered.length;
|
||||
renderTableRows();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -207,7 +286,7 @@
|
||||
|
||||
async function loadObservers() {
|
||||
try {
|
||||
const data = await api('/observers');
|
||||
const data = await api('/observers', { ttl: CLIENT_TTL.observers });
|
||||
observers = data.observers || [];
|
||||
} catch {}
|
||||
}
|
||||
@@ -278,6 +357,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-bar" id="pktFilters">
|
||||
<button class="btn filter-toggle-btn" id="filterToggleBtn">Filters ▾</button>
|
||||
<input type="text" placeholder="Packet hash…" id="fHash" aria-label="Filter by packet hash">
|
||||
<div class="node-filter-wrap" style="position:relative">
|
||||
<input type="text" placeholder="Node name…" id="fNode" autocomplete="off" role="combobox" aria-expanded="false" aria-owns="fNodeDropdown" aria-activedescendant="" aria-autocomplete="list">
|
||||
@@ -310,7 +390,7 @@
|
||||
|
||||
const obsSel = document.getElementById('fObserver');
|
||||
for (const o of observers) {
|
||||
obsSel.innerHTML += `<option value="${o.id}" ${filters.observer === o.id ? 'selected' : ''}>${o.id}</option>`;
|
||||
obsSel.innerHTML += `<option value="${o.id}" ${filters.observer === o.id ? 'selected' : ''}>${o.name || o.id}</option>`;
|
||||
}
|
||||
|
||||
const typeSel = document.getElementById('fType');
|
||||
@@ -318,6 +398,13 @@
|
||||
typeSel.innerHTML += `<option value="${k}" ${String(filters.type) === k ? 'selected' : ''}>${v}</option>`;
|
||||
}
|
||||
|
||||
// Filter toggle button for mobile
|
||||
document.getElementById('filterToggleBtn').addEventListener('click', function() {
|
||||
const bar = document.getElementById('pktFilters');
|
||||
bar.classList.toggle('filters-expanded');
|
||||
this.textContent = bar.classList.contains('filters-expanded') ? 'Filters ▴' : 'Filters ▾';
|
||||
});
|
||||
|
||||
// Filter event listeners
|
||||
document.getElementById('fHash').value = filters.hash || '';
|
||||
document.getElementById('fHash').addEventListener('input', debounce((e) => { filters.hash = e.target.value || undefined; loadPackets(); }, 300));
|
||||
@@ -343,7 +430,8 @@
|
||||
{ key: 'rpt', label: 'Rpt' },
|
||||
{ key: 'details', label: 'Details' },
|
||||
];
|
||||
const defaultHidden = ['region'];
|
||||
const isMobile = window.innerWidth <= 640;
|
||||
const defaultHidden = isMobile ? ['region', 'hash', 'observer', 'path', 'rpt', 'size'] : ['region'];
|
||||
let visibleCols;
|
||||
try {
|
||||
visibleCols = JSON.parse(localStorage.getItem('packets-visible-cols'));
|
||||
@@ -490,7 +578,7 @@
|
||||
makeColumnsResizable('#pktTable', 'meshcore-pkt-col-widths');
|
||||
}
|
||||
|
||||
function renderTableRows() {
|
||||
async function renderTableRows() {
|
||||
const tbody = document.getElementById('pktBody');
|
||||
if (!tbody) return;
|
||||
|
||||
@@ -500,24 +588,21 @@
|
||||
const groupBtn = document.getElementById('fGroup');
|
||||
if (groupBtn) groupBtn.classList.toggle('active', groupByHash);
|
||||
|
||||
// Filter to claimed/favorited nodes if toggle is on
|
||||
// Filter to claimed/favorited nodes if toggle is on — use server-side multi-node lookup
|
||||
let displayPackets = packets;
|
||||
if (filters.myNodes) {
|
||||
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
|
||||
const myKeys = new Set(myNodes.map(n => n.pubkey));
|
||||
const myKeys = myNodes.map(n => n.pubkey).filter(Boolean);
|
||||
const favs = getFavorites();
|
||||
const allKeys = new Set([...myKeys, ...favs]);
|
||||
displayPackets = packets.filter(p => {
|
||||
const allKeys = [...new Set([...myKeys, ...favs])];
|
||||
if (allKeys.length > 0) {
|
||||
try {
|
||||
const d = JSON.parse(p.decoded_json || '{}');
|
||||
const pathHops = JSON.parse(p.path_json || '[]');
|
||||
// Check if any node key in decoded data or path matches
|
||||
return (d.pubkey && allKeys.has(d.pubkey)) ||
|
||||
(d.to && allKeys.has(d.to)) ||
|
||||
(d.from && allKeys.has(d.from)) ||
|
||||
pathHops.some(h => allKeys.has(h));
|
||||
} catch { return false; }
|
||||
});
|
||||
const myData = await api('/packets?nodes=' + allKeys.join(',') + '&limit=500');
|
||||
displayPackets = myData.packets || [];
|
||||
} catch { displayPackets = []; }
|
||||
} else {
|
||||
displayPackets = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!displayPackets.length) {
|
||||
@@ -544,9 +629,9 @@
|
||||
<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(p.observer_name || p.observer_id || '—', 16) : truncate(p.observer_name || p.observer_id || '—', 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
|
||||
<td class="col-observer">${isSingle ? truncate(obsName(p.observer_id), 16) : truncate(obsName(p.observer_id), 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">${isSingle ? '' : p.count}</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)
|
||||
@@ -565,7 +650,7 @@
|
||||
<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(c.observer_name || c.observer_id || '—', 16)}</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>
|
||||
@@ -595,7 +680,7 @@
|
||||
<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(p.observer_name || p.observer_id || '—', 16)}</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>
|
||||
@@ -632,11 +717,35 @@
|
||||
|
||||
async function selectPacket(id) {
|
||||
selectedId = id;
|
||||
history.replaceState(null, '', `#/packet/${id}`);
|
||||
renderTableRows();
|
||||
const panel = document.getElementById('pktRight');
|
||||
panel.classList.remove('empty');
|
||||
panel.innerHTML = '<div class="panel-resize-handle" id="pktResizeHandle"></div><div class="text-center text-muted" style="padding:40px">Loading…</div>';
|
||||
initPanelResize();
|
||||
const isMobileNow = window.innerWidth <= 640;
|
||||
let panel;
|
||||
if (isMobileNow) {
|
||||
// Use mobile bottom sheet
|
||||
let sheet = document.getElementById('mobileDetailSheet');
|
||||
if (!sheet) {
|
||||
sheet = document.createElement('div');
|
||||
sheet.id = 'mobileDetailSheet';
|
||||
sheet.className = 'mobile-detail-sheet';
|
||||
sheet.innerHTML = '<div class="mobile-sheet-handle"></div><button class="mobile-sheet-close" id="mobileSheetClose">✕</button><div class="mobile-sheet-content"></div>';
|
||||
document.body.appendChild(sheet);
|
||||
sheet.querySelector('#mobileSheetClose').addEventListener('click', () => {
|
||||
sheet.classList.remove('open');
|
||||
});
|
||||
sheet.querySelector('.mobile-sheet-handle').addEventListener('click', () => {
|
||||
sheet.classList.remove('open');
|
||||
});
|
||||
}
|
||||
panel = sheet.querySelector('.mobile-sheet-content');
|
||||
panel.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
|
||||
sheet.classList.add('open');
|
||||
} else {
|
||||
panel = document.getElementById('pktRight');
|
||||
panel.classList.remove('empty');
|
||||
panel.innerHTML = '<div class="panel-resize-handle" id="pktResizeHandle"></div><div class="text-center text-muted" style="padding:40px">Loading…</div>';
|
||||
initPanelResize();
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api(`/packets/${id}`);
|
||||
@@ -647,11 +756,11 @@
|
||||
const newHops = hops.filter(h => !(h in hopNameCache));
|
||||
if (newHops.length) await resolveHops(newHops);
|
||||
} catch {}
|
||||
panel.innerHTML = '<div class="panel-resize-handle" id="pktResizeHandle"></div>';
|
||||
panel.innerHTML = isMobileNow ? '' : '<div class="panel-resize-handle" id="pktResizeHandle"></div>';
|
||||
const content = document.createElement('div');
|
||||
panel.appendChild(content);
|
||||
renderDetail(content, data);
|
||||
initPanelResize();
|
||||
if (!isMobileNow) initPanelResize();
|
||||
} catch (e) {
|
||||
panel.innerHTML = `<div class="text-muted">Error: ${e.message}</div>`;
|
||||
}
|
||||
@@ -691,7 +800,7 @@
|
||||
<div class="detail-hash">${pkt.hash || 'Packet #' + pkt.id}</div>
|
||||
${messageHtml}
|
||||
<dl class="detail-meta">
|
||||
<dt>Observer</dt><dd>${pkt.observer_name || pkt.observer_id || '—'}</dd>
|
||||
<dt>Observer</dt><dd>${obsName(pkt.observer_id)}</dd>
|
||||
<dt>SNR / RSSI</dt><dd>${snr != null ? snr + ' dB' : '—'} / ${rssi != null ? rssi + ' dBm' : '—'}</dd>
|
||||
<dt>Route Type</dt><dd>${routeTypeName(pkt.route_type)}</dd>
|
||||
<dt>Payload Type</dt><dd><span class="badge badge-${payloadTypeColor(pkt.payload_type)}">${typeName}</span></dd>
|
||||
@@ -699,6 +808,7 @@
|
||||
<dt>Path</dt><dd>${pathHops.length ? renderPath(pathHops) : '—'}</dd>
|
||||
</dl>
|
||||
<div class="detail-actions">
|
||||
<button class="copy-link-btn" data-packet-id="${pkt.id}" title="Copy link to this packet">🔗 Copy Link</button>
|
||||
${pathHops.length ? `<button class="detail-map-link" id="viewRouteBtn">🗺️ View route on map</button>` : ''}
|
||||
<button class="replay-live-btn" title="Replay this packet on the live map">▶ Replay</button>
|
||||
</div>
|
||||
@@ -709,6 +819,20 @@
|
||||
${hasRawHex ? buildFieldTable(pkt, decoded, pathHops, ranges) : buildDecodedTable(decoded)}
|
||||
`;
|
||||
|
||||
// Wire up copy link button
|
||||
const copyLinkBtn = panel.querySelector('.copy-link-btn');
|
||||
if (copyLinkBtn) {
|
||||
copyLinkBtn.addEventListener('click', () => {
|
||||
const url = `${location.origin}/#/packet/${copyLinkBtn.dataset.packetId}`;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
copyLinkBtn.textContent = '✅ Copied!';
|
||||
setTimeout(() => { copyLinkBtn.textContent = '🔗 Copy Link'; }, 1500);
|
||||
}).catch(() => {
|
||||
prompt('Copy this link:', url);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Wire up replay button
|
||||
const replayBtn = panel.querySelector('.replay-live-btn');
|
||||
if (replayBtn) {
|
||||
@@ -717,7 +841,7 @@
|
||||
id: pkt.id, hash: pkt.hash,
|
||||
_ts: new Date(pkt.timestamp).getTime(),
|
||||
decoded: { header: { payloadTypeName: typeName }, payload: decoded, path: { hops: pathHops } },
|
||||
snr: pkt.snr, rssi: pkt.rssi, observer: pkt.observer_name
|
||||
snr: pkt.snr, rssi: pkt.rssi, observer: obsName(pkt.observer_id)
|
||||
};
|
||||
sessionStorage.setItem('replay-packet', JSON.stringify(livePkt));
|
||||
window.location.hash = '#/live';
|
||||
@@ -729,7 +853,7 @@
|
||||
if (routeBtn && pathHops.length) {
|
||||
routeBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const obsId = pkt.observer_name || pkt.observer_id || '';
|
||||
const obsId = obsName(pkt.observer_id);
|
||||
const observerParam = obsId ? '&observer=' + encodeURIComponent(obsId) : '';
|
||||
const resp = await fetch('/api/resolve-hops?hops=' + encodeURIComponent(pathHops.join(',')) + observerParam);
|
||||
const data = await resp.json();
|
||||
@@ -967,11 +1091,10 @@
|
||||
return '<div class="byop-row"><span class="byop-key">' + key + '</span><span class="byop-val">' + val + '</span></div>';
|
||||
}
|
||||
|
||||
// Load regions from config
|
||||
// Load regions from config API
|
||||
(async () => {
|
||||
try {
|
||||
// We'll use a simple approach - hardcode from config
|
||||
regionMap = {"SJC":"San Jose, US","SFO":"San Francisco, US","OAK":"Oakland, US","MRY":"Monterey, US","LAR":"Los Angeles, US"};
|
||||
regionMap = await api('/config/regions', { ttl: 3600 });
|
||||
} catch {}
|
||||
})();
|
||||
|
||||
@@ -982,11 +1105,12 @@
|
||||
renderTableRows();
|
||||
return;
|
||||
}
|
||||
// Load children for this hash
|
||||
// Load children (observations) for this hash
|
||||
try {
|
||||
const data = await api(`/packets?hash=${hash}&limit=20`);
|
||||
const data = await api(`/packets?hash=${hash}&limit=1&expand=observations`);
|
||||
const pkt = (data.packets || [])[0];
|
||||
const group = packets.find(p => p.hash === hash);
|
||||
if (group) group._children = data.packets || [];
|
||||
if (group && pkt) group._children = (pkt.observations || []).map(o => ({...pkt, ...o, _isObservation: true}));
|
||||
// Resolve any new hops from children
|
||||
const childHops = new Set();
|
||||
for (const c of (group?._children || [])) {
|
||||
@@ -1007,4 +1131,31 @@
|
||||
}
|
||||
|
||||
registerPage('packets', { init, destroy });
|
||||
|
||||
// Standalone packet detail page: #/packet/123
|
||||
registerPage('packet-detail', {
|
||||
init: async (app, routeParam) => {
|
||||
const id = Number(routeParam);
|
||||
app.innerHTML = `<div style="max-width:800px;margin:0 auto;padding:20px"><div class="text-center text-muted" style="padding:40px">Loading packet #${id}…</div></div>`;
|
||||
try {
|
||||
const data = await api(`/packets/${id}`);
|
||||
if (!data?.packet) { app.innerHTML = `<div style="max-width:800px;margin:0 auto;padding:40px;text-align:center"><h2>Packet not found</h2><p>Packet #${id} doesn't exist.</p><a href="#/packets">← Back to packets</a></div>`; return; }
|
||||
const hops = [];
|
||||
try { const ph = JSON.parse(data.packet.path_json || '[]'); hops.push(...ph); } catch {}
|
||||
const newHops = hops.filter(h => !(h in hopNameCache));
|
||||
if (newHops.length) await resolveHops(newHops);
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = 'max-width:800px;margin:0 auto;padding:20px';
|
||||
container.innerHTML = `<div style="margin-bottom:16px"><a href="#/packets" style="color:var(--primary);text-decoration:none">← Back to packets</a></div>`;
|
||||
const detail = document.createElement('div');
|
||||
container.appendChild(detail);
|
||||
renderDetail(detail, data);
|
||||
app.innerHTML = '';
|
||||
app.appendChild(container);
|
||||
} catch (e) {
|
||||
app.innerHTML = `<div style="max-width:800px;margin:0 auto;padding:40px;text-align:center"><h2>Error</h2><p>${e.message}</p><a href="#/packets">← Back to packets</a></div>`;
|
||||
}
|
||||
},
|
||||
destroy: () => {}
|
||||
});
|
||||
})();
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/* === MeshCore Analyzer — perf.js === */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
let interval = null;
|
||||
|
||||
async function render(app) {
|
||||
app.innerHTML = '<div style="height:100%;overflow-y:auto;padding:16px 24px;"><h2>⚡ Performance Dashboard</h2><div id="perfContent">Loading...</div></div>';
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const el = document.getElementById('perfContent');
|
||||
if (!el) return;
|
||||
try {
|
||||
const [server, client] = await Promise.all([
|
||||
fetch('/api/perf').then(r => r.json()),
|
||||
Promise.resolve(window.apiPerf ? window.apiPerf() : null)
|
||||
]);
|
||||
|
||||
// Also fetch health telemetry
|
||||
const health = await fetch('/api/health').then(r => r.json()).catch(() => null);
|
||||
|
||||
let html = '';
|
||||
|
||||
// Server overview
|
||||
html += `<div style="display:flex;gap:16px;flex-wrap:wrap;margin:16px 0;">
|
||||
<div class="perf-card"><div class="perf-num">${server.totalRequests}</div><div class="perf-label">Total Requests</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${server.avgMs}ms</div><div class="perf-label">Avg Response</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${health ? health.uptimeHuman : Math.round(server.uptime / 60) + 'm'}</div><div class="perf-label">Uptime</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${server.slowQueries.length}</div><div class="perf-label">Slow (>100ms)</div></div>
|
||||
</div>`;
|
||||
|
||||
// System health (memory, event loop, WS)
|
||||
if (health) {
|
||||
const m = health.memory, el = health.eventLoop;
|
||||
const elColor = el.p95Ms > 500 ? '#ef4444' : el.p95Ms > 100 ? '#f59e0b' : '#22c55e';
|
||||
const memColor = m.heapUsed > m.heapTotal * 0.85 ? '#ef4444' : m.heapUsed > m.heapTotal * 0.7 ? '#f59e0b' : '#22c55e';
|
||||
html += `<h3>System Health</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||||
<div class="perf-card"><div class="perf-num" style="color:${memColor}">${m.heapUsed}MB</div><div class="perf-label">Heap Used / ${m.heapTotal}MB</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${m.rss}MB</div><div class="perf-label">RSS</div></div>
|
||||
<div class="perf-card"><div class="perf-num" style="color:${elColor}">${el.p95Ms}ms</div><div class="perf-label">Event Loop p95</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${el.maxLagMs}ms</div><div class="perf-label">EL Max Lag</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${el.currentLagMs}ms</div><div class="perf-label">EL Current</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${health.websocket.clients}</div><div class="perf-label">WS Clients</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Cache stats
|
||||
if (server.cache) {
|
||||
const c = server.cache;
|
||||
const clientCache = _apiCache ? _apiCache.size : 0;
|
||||
html += `<h3>Cache</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||||
<div class="perf-card"><div class="perf-num">${c.size}</div><div class="perf-label">Server Entries</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${c.hits}</div><div class="perf-label">Server Hits</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${c.misses}</div><div class="perf-label">Server Misses</div></div>
|
||||
<div class="perf-card"><div class="perf-num" style="color:${c.hitRate > 50 ? '#22c55e' : c.hitRate > 20 ? '#f59e0b' : '#ef4444'}">${c.hitRate}%</div><div class="perf-label">Server Hit Rate</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${c.staleHits || 0}</div><div class="perf-label">Stale Hits (SWR)</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${c.recomputes || 0}</div><div class="perf-label">Recomputes</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${clientCache}</div><div class="perf-label">Client Entries</div></div>
|
||||
</div>`;
|
||||
if (client) {
|
||||
html += `<div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||||
<div class="perf-card"><div class="perf-num">${client.cacheHits || 0}</div><div class="perf-label">Client Hits</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${client.cacheMisses || 0}</div><div class="perf-label">Client Misses</div></div>
|
||||
<div class="perf-card"><div class="perf-num" style="color:${(client.cacheHitRate||0) > 50 ? '#22c55e' : '#f59e0b'}">${client.cacheHitRate || 0}%</div><div class="perf-label">Client Hit Rate</div></div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Packet Store stats
|
||||
if (server.packetStore) {
|
||||
const ps = server.packetStore;
|
||||
html += `<h3>In-Memory Packet Store</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||||
<div class="perf-card"><div class="perf-num">${ps.inMemory.toLocaleString()}</div><div class="perf-label">Packets in RAM</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.estimatedMB}MB</div><div class="perf-label">Memory Used</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.maxMB}MB</div><div class="perf-label">Memory Limit</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.queries.toLocaleString()}</div><div class="perf-label">Queries Served</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.inserts.toLocaleString()}</div><div class="perf-label">Live Inserts</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.evicted.toLocaleString()}</div><div class="perf-label">Evicted</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.indexes.byHash.toLocaleString()}</div><div class="perf-label">Unique Hashes</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.indexes.byObserver}</div><div class="perf-label">Observers</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.indexes.byNode.toLocaleString()}</div><div class="perf-label">Indexed Nodes</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Server endpoints table
|
||||
const eps = Object.entries(server.endpoints);
|
||||
if (eps.length) {
|
||||
html += '<h3>Server Endpoints (sorted by total time)</h3>';
|
||||
html += '<div style="overflow-x:auto"><table class="perf-table"><thead><tr><th>Endpoint</th><th>Count</th><th>Avg</th><th>P50</th><th>P95</th><th>Max</th><th>Total</th></tr></thead><tbody>';
|
||||
for (const [path, s] of eps) {
|
||||
const total = Math.round(s.count * s.avgMs);
|
||||
const cls = s.p95Ms > 200 ? ' class="perf-slow"' : s.p95Ms > 50 ? ' class="perf-warn"' : '';
|
||||
html += `<tr${cls}><td><code>${path}</code></td><td>${s.count}</td><td>${s.avgMs}ms</td><td>${s.p50Ms}ms</td><td>${s.p95Ms}ms</td><td>${s.maxMs}ms</td><td>${total}ms</td></tr>`;
|
||||
}
|
||||
html += '</tbody></table></div>';
|
||||
}
|
||||
|
||||
// Client API calls
|
||||
if (client && client.endpoints.length) {
|
||||
html += '<h3>Client API Calls (this session)</h3>';
|
||||
html += '<div style="overflow-x:auto"><table class="perf-table"><thead><tr><th>Endpoint</th><th>Count</th><th>Avg</th><th>Max</th><th>Total</th></tr></thead><tbody>';
|
||||
for (const s of client.endpoints) {
|
||||
const cls = s.maxMs > 500 ? ' class="perf-slow"' : s.avgMs > 200 ? ' class="perf-warn"' : '';
|
||||
html += `<tr${cls}><td><code>${s.path}</code></td><td>${s.count}</td><td>${s.avgMs}ms</td><td>${s.maxMs}ms</td><td>${s.totalMs}ms</td></tr>`;
|
||||
}
|
||||
html += '</tbody></table></div>';
|
||||
}
|
||||
|
||||
// Slow queries
|
||||
if (server.slowQueries.length) {
|
||||
html += '<h3>Recent Slow Queries (>100ms)</h3>';
|
||||
html += '<div style="overflow-x:auto"><table class="perf-table"><thead><tr><th>Time</th><th>Path</th><th>Duration</th><th>Status</th></tr></thead><tbody>';
|
||||
for (const q of server.slowQueries.slice().reverse()) {
|
||||
html += `<tr class="perf-slow"><td>${new Date(q.time).toLocaleTimeString()}</td><td><code>${q.path}</code></td><td>${q.ms}ms</td><td>${q.status}</td></tr>`;
|
||||
}
|
||||
html += '</tbody></table></div>';
|
||||
}
|
||||
|
||||
html += `<div style="margin-top:16px"><button id="perfReset" style="padding:8px 16px;cursor:pointer">Reset Stats</button> <button id="perfRefresh" style="padding:8px 16px;cursor:pointer">Refresh</button></div>`;
|
||||
el.innerHTML = html;
|
||||
|
||||
document.getElementById('perfReset')?.addEventListener('click', async () => {
|
||||
await fetch('/api/perf/reset', { method: 'POST' });
|
||||
if (window._apiPerf) { window._apiPerf = { calls: 0, totalMs: 0, log: [] }; }
|
||||
refresh();
|
||||
});
|
||||
document.getElementById('perfRefresh')?.addEventListener('click', refresh);
|
||||
} catch (err) {
|
||||
el.innerHTML = `<p style="color:red">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
registerPage('perf', {
|
||||
init(app) {
|
||||
render(app);
|
||||
interval = setInterval(refresh, 5000);
|
||||
},
|
||||
destroy() {
|
||||
if (interval) { clearInterval(interval); interval = null; }
|
||||
}
|
||||
});
|
||||
})();
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/* === MeshCore Analyzer — roles.js (shared config module) === */
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* Centralized roles, thresholds, tile URLs, and UI constants.
|
||||
* Loaded BEFORE all page scripts via index.html.
|
||||
* Defaults are set synchronously; server config overrides arrive via fetch.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
// ─── Role definitions ───
|
||||
window.ROLE_COLORS = {
|
||||
repeater: '#dc2626', companion: '#2563eb', room: '#16a34a',
|
||||
sensor: '#d97706', observer: '#8b5cf6', unknown: '#6b7280'
|
||||
};
|
||||
|
||||
window.ROLE_LABELS = {
|
||||
repeater: 'Repeaters', companion: 'Companions', room: 'Room Servers',
|
||||
sensor: 'Sensors', observer: 'Observers'
|
||||
};
|
||||
|
||||
window.ROLE_STYLE = {
|
||||
repeater: { color: '#dc2626', shape: 'diamond', radius: 10, weight: 2 },
|
||||
companion: { color: '#2563eb', shape: 'circle', radius: 8, weight: 2 },
|
||||
room: { color: '#16a34a', shape: 'square', radius: 9, weight: 2 },
|
||||
sensor: { color: '#d97706', shape: 'triangle', radius: 8, weight: 2 },
|
||||
observer: { color: '#8b5cf6', shape: 'star', radius: 11, weight: 2 }
|
||||
};
|
||||
|
||||
window.ROLE_EMOJI = {
|
||||
repeater: '◆', companion: '●', room: '■', sensor: '▲', observer: '★'
|
||||
};
|
||||
|
||||
window.ROLE_SORT = ['repeater', 'companion', 'room', 'sensor', 'observer'];
|
||||
|
||||
// ─── Health thresholds (ms) ───
|
||||
window.HEALTH_THRESHOLDS = {
|
||||
infraDegradedMs: 86400000, // 24h
|
||||
infraSilentMs: 259200000, // 72h
|
||||
nodeDegradedMs: 3600000, // 1h
|
||||
nodeSilentMs: 86400000 // 24h
|
||||
};
|
||||
|
||||
// Helper: get degraded/silent thresholds for a role
|
||||
window.getHealthThresholds = function (role) {
|
||||
var isInfra = role === 'repeater' || role === 'room';
|
||||
return {
|
||||
degradedMs: isInfra ? HEALTH_THRESHOLDS.infraDegradedMs : HEALTH_THRESHOLDS.nodeDegradedMs,
|
||||
silentMs: isInfra ? HEALTH_THRESHOLDS.infraSilentMs : HEALTH_THRESHOLDS.nodeSilentMs
|
||||
};
|
||||
};
|
||||
|
||||
// ─── Tile URLs ───
|
||||
window.TILE_DARK = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
||||
window.TILE_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
||||
|
||||
window.getTileUrl = function () {
|
||||
var isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
return isDark ? TILE_DARK : TILE_LIGHT;
|
||||
};
|
||||
|
||||
// ─── SNR thresholds ───
|
||||
window.SNR_THRESHOLDS = { excellent: 6, good: 0 };
|
||||
|
||||
// ─── Distance thresholds (km) ───
|
||||
window.DIST_THRESHOLDS = { local: 50, regional: 200 };
|
||||
|
||||
// ─── MAX_HOP_DIST (degrees, ~200km ≈ 1.8°) ───
|
||||
window.MAX_HOP_DIST = 1.8;
|
||||
|
||||
// ─── Result limits ───
|
||||
window.LIMITS = {
|
||||
topNodes: 15,
|
||||
topPairs: 12,
|
||||
topRingNodes: 8,
|
||||
topSenders: 10,
|
||||
topCollisionNodes: 10,
|
||||
recentReplay: 8,
|
||||
feedMax: 25
|
||||
};
|
||||
|
||||
// ─── Performance thresholds ───
|
||||
window.PERF_SLOW_MS = 100;
|
||||
|
||||
// ─── WebSocket reconnect delay (ms) ───
|
||||
window.WS_RECONNECT_MS = 3000;
|
||||
|
||||
// ─── Cache invalidation debounce (ms) ───
|
||||
window.CACHE_INVALIDATE_MS = 5000;
|
||||
|
||||
// ─── External URLs ───
|
||||
window.EXTERNAL_URLS = {
|
||||
flasher: 'https://flasher.meshcore.co.uk/'
|
||||
};
|
||||
|
||||
// ─── Fetch server overrides ───
|
||||
window.MeshConfigReady = fetch('/api/config/client').then(function (r) { return r.json(); }).then(function (cfg) {
|
||||
if (cfg.roles) {
|
||||
if (cfg.roles.colors) Object.assign(ROLE_COLORS, cfg.roles.colors);
|
||||
if (cfg.roles.labels) Object.assign(ROLE_LABELS, cfg.roles.labels);
|
||||
if (cfg.roles.style) {
|
||||
for (var k in cfg.roles.style) ROLE_STYLE[k] = Object.assign(ROLE_STYLE[k] || {}, cfg.roles.style[k]);
|
||||
}
|
||||
if (cfg.roles.emoji) Object.assign(ROLE_EMOJI, cfg.roles.emoji);
|
||||
if (cfg.roles.sort) window.ROLE_SORT = cfg.roles.sort;
|
||||
}
|
||||
if (cfg.healthThresholds) Object.assign(HEALTH_THRESHOLDS, cfg.healthThresholds);
|
||||
if (cfg.tiles) {
|
||||
if (cfg.tiles.dark) window.TILE_DARK = cfg.tiles.dark;
|
||||
if (cfg.tiles.light) window.TILE_LIGHT = cfg.tiles.light;
|
||||
}
|
||||
if (cfg.snrThresholds) Object.assign(SNR_THRESHOLDS, cfg.snrThresholds);
|
||||
if (cfg.distThresholds) Object.assign(DIST_THRESHOLDS, cfg.distThresholds);
|
||||
if (cfg.maxHopDist != null) window.MAX_HOP_DIST = cfg.maxHopDist;
|
||||
if (cfg.limits) Object.assign(LIMITS, cfg.limits);
|
||||
if (cfg.perfSlowMs != null) window.PERF_SLOW_MS = cfg.perfSlowMs;
|
||||
if (cfg.wsReconnectMs != null) window.WS_RECONNECT_MS = cfg.wsReconnectMs;
|
||||
if (cfg.cacheInvalidateMs != null) window.CACHE_INVALIDATE_MS = cfg.cacheInvalidateMs;
|
||||
if (cfg.externalUrls) Object.assign(EXTERNAL_URLS, cfg.externalUrls);
|
||||
// Sync ROLE_STYLE colors with ROLE_COLORS
|
||||
for (var role in ROLE_STYLE) {
|
||||
if (ROLE_COLORS[role]) ROLE_STYLE[role].color = ROLE_COLORS[role];
|
||||
}
|
||||
}).catch(function () { /* use defaults */ });
|
||||
})();
|
||||
+77
-14
@@ -226,6 +226,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
max-width: 0; /* forces td to respect table width instead of expanding to content */
|
||||
}
|
||||
.data-table td.col-details { white-space: normal; word-break: break-word; }
|
||||
.data-table td:has(.spark-bar), .data-table td.col-spark { max-width: none; overflow: visible; min-width: 80px; }
|
||||
.data-table tbody tr:nth-child(even) { background: var(--row-stripe); }
|
||||
.data-table tbody tr:hover { background: var(--row-hover); cursor: pointer; }
|
||||
.data-table tbody tr.selected { background: var(--selected-bg); }
|
||||
@@ -262,6 +263,11 @@ 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: #fff; letter-spacing: .5px;
|
||||
}
|
||||
.badge-obs {
|
||||
display: inline-block; padding: 1px 6px; border-radius: 10px;
|
||||
font-size: 10px; font-weight: 600;
|
||||
background: #ede9fe; color: #6d28d9;
|
||||
}
|
||||
|
||||
/* === Monospace === */
|
||||
.mono { font-family: var(--mono); font-size: 12px; }
|
||||
@@ -681,7 +687,7 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
|
||||
/* === Observers Page === */
|
||||
.observers-page { padding: 20px; max-width: 1200px; margin: 0 auto; }
|
||||
.observers-page { padding: 20px; max-width: 1200px; margin: 0 auto; overflow-y: auto; height: calc(100vh - 56px); }
|
||||
.obs-summary { display: flex; gap: 20px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.obs-stat { display: flex; align-items: center; gap: 6px; font-size: 14px; color: var(--text-muted); }
|
||||
.health-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
@@ -689,6 +695,8 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
.health-dot.health-yellow { background: #eab308; box-shadow: 0 0 6px #eab30880; }
|
||||
.health-dot.health-red { background: #ef4444; box-shadow: 0 0 6px #ef444480; }
|
||||
.obs-table td:first-child { white-space: nowrap; }
|
||||
.obs-table td:nth-child(6) { max-width: none; overflow: visible; }
|
||||
.col-observer { min-width: 70px; max-width: none; }
|
||||
.spark-bar { position: relative; min-width: 60px; max-width: 100px; flex: 1; height: 18px; background: var(--border); border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; }
|
||||
@media (max-width: 640px) { .spark-bar { max-width: 60px; } }
|
||||
.spark-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #60a5fa); border-radius: 4px; transition: width 0.3s; }
|
||||
@@ -811,8 +819,8 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
|
||||
/* Layouts: stack instead of side-by-side */
|
||||
.split-layout { flex-direction: column; overflow-y: auto; }
|
||||
.panel-left { padding: 10px; flex: none; min-height: 50vh; overflow-x: auto; }
|
||||
.panel-right { width: 100%; min-width: 0; border-left: none; border-top: 1px solid var(--border); max-height: none; flex: none; }
|
||||
.panel-left { padding: 6px; flex: 1; min-height: 0; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
.panel-right { display: none; }
|
||||
|
||||
/* Channels: Discord-style full screen toggle */
|
||||
.ch-layout { flex-direction: row; position: relative; }
|
||||
@@ -830,18 +838,21 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
.ch-back-btn { display: flex; }
|
||||
.ch-main-header { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* Tables: smaller text, allow horizontal scroll */
|
||||
.data-table { font-size: 12px; }
|
||||
.data-table td { padding: 6px 6px; max-width: 120px; }
|
||||
.data-table th { padding: 6px 6px; font-size: 11px; }
|
||||
.panel-left { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
.data-table { min-width: 500px; }
|
||||
/* Tables: smaller text for mobile */
|
||||
.data-table { font-size: 11px; min-width: 0; }
|
||||
.data-table td { padding: 5px 4px; max-width: 100px; }
|
||||
.data-table th { padding: 5px 4px; font-size: 10px; }
|
||||
.panel-left { overflow-x: auto; }
|
||||
|
||||
/* Filters: full width */
|
||||
.filter-bar { flex-direction: column; gap: 4px; }
|
||||
.filter-bar input { width: 100%; }
|
||||
.filter-bar select { width: 100%; }
|
||||
.filter-bar .btn { min-height: 44px; }
|
||||
/* Filters: collapse on mobile */
|
||||
.filter-bar { flex-direction: row; flex-wrap: wrap; gap: 4px; }
|
||||
.filter-toggle-btn { display: inline-flex !important; }
|
||||
.filter-bar > *:not(.filter-toggle-btn):not(.col-toggle-wrap) { display: none; }
|
||||
.filter-bar.filters-expanded > * { display: inline-flex; }
|
||||
.filter-bar.filters-expanded > .col-toggle-wrap { display: inline-block; }
|
||||
.filter-bar.filters-expanded input { width: 100%; }
|
||||
.filter-bar.filters-expanded select { width: 100%; }
|
||||
.filter-bar .btn { min-height: 36px; }
|
||||
.node-filter-wrap { width: 100%; }
|
||||
|
||||
/* Nodes */
|
||||
@@ -1174,6 +1185,19 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
}
|
||||
.detail-map-link:hover { background: rgba(245, 158, 11, 0.25); }
|
||||
|
||||
.copy-link-btn {
|
||||
padding: 5px 12px;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
border: 1px solid rgba(59, 130, 246, 0.25);
|
||||
color: var(--primary, #3b82f6);
|
||||
border-radius: 6px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.copy-link-btn:hover { background: rgba(59, 130, 246, 0.25); }
|
||||
|
||||
/* Route tooltip on map */
|
||||
.route-tooltip {
|
||||
background: rgba(0,0,0,0.8) !important;
|
||||
@@ -1384,3 +1408,42 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
.claimed-row { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; border-left: 3px solid var(--accent); }
|
||||
.claimed-row:hover { background: color-mix(in srgb, var(--accent) 14%, transparent) !important; }
|
||||
.claimed-badge { color: var(--accent); font-size: 13px; margin-right: 2px; }
|
||||
|
||||
/* Filter toggle button — hidden on desktop */
|
||||
.filter-toggle-btn { display: none; }
|
||||
|
||||
/* Mobile detail bottom sheet */
|
||||
.mobile-detail-sheet {
|
||||
display: none;
|
||||
position: fixed; bottom: 0; left: 0; right: 0;
|
||||
max-height: 70vh; background: var(--detail-bg);
|
||||
border-top-left-radius: 16px; border-top-right-radius: 16px;
|
||||
box-shadow: 0 -4px 24px rgba(0,0,0,.3);
|
||||
z-index: 200; overflow-y: auto; padding: 8px 16px 24px;
|
||||
transform: translateY(100%); transition: transform .25s ease;
|
||||
}
|
||||
.mobile-detail-sheet.open { display: block; transform: translateY(0); }
|
||||
.mobile-sheet-handle {
|
||||
width: 40px; height: 4px; background: var(--border);
|
||||
border-radius: 2px; margin: 4px auto 8px; cursor: pointer;
|
||||
}
|
||||
.mobile-sheet-close {
|
||||
position: absolute; top: 8px; right: 12px;
|
||||
background: none; border: none; font-size: 20px;
|
||||
color: var(--text-muted); cursor: pointer; z-index: 1;
|
||||
}
|
||||
.mobile-sheet-close:hover { color: var(--text); }
|
||||
.mobile-sheet-content { padding-top: 4px; }
|
||||
|
||||
/* Perf dashboard */
|
||||
.perf-card { background: var(--surface-1); border: 1px solid var(--border); border-radius: 8px; padding: 12px 20px; min-width: 120px; text-align: center; }
|
||||
.perf-num { font-size: 24px; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.perf-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; }
|
||||
.perf-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.perf-table th { text-align: left; padding: 6px 10px; border-bottom: 2px solid var(--border); color: var(--text-muted); font-size: 11px; text-transform: uppercase; }
|
||||
.perf-table td { padding: 5px 10px; border-bottom: 1px solid var(--border); font-variant-numeric: tabular-nums; }
|
||||
.perf-table code { font-size: 12px; color: var(--text); }
|
||||
.perf-table .perf-slow { background: rgba(239, 68, 68, 0.08); }
|
||||
.perf-table .perf-slow td { color: #ef4444; }
|
||||
.perf-table .perf-warn { background: rgba(251, 191, 36, 0.06); }
|
||||
.perf-table .perf-warn td { color: #f59e0b; }
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Milestone 1: Packet Dedup Schema Migration
|
||||
*
|
||||
* Creates `transmissions` and `observations` tables from the existing `packets` table.
|
||||
* Idempotent — drops and recreates new tables on each run.
|
||||
* Does NOT touch the original `packets` table.
|
||||
*
|
||||
* Usage: node scripts/migrate-dedup.js <path-to-meshcore.db>
|
||||
*/
|
||||
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = process.argv[2];
|
||||
if (!dbPath) {
|
||||
console.error('Usage: node scripts/migrate-dedup.js <path-to-meshcore.db>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// --- Drop existing new tables (idempotent) ---
|
||||
console.log('Dropping existing transmissions/observations tables if they exist...');
|
||||
db.exec('DROP TABLE IF EXISTS observations');
|
||||
db.exec('DROP TABLE IF EXISTS transmissions');
|
||||
|
||||
// --- Create new tables ---
|
||||
console.log('Creating transmissions and observations tables...');
|
||||
db.exec(`
|
||||
CREATE TABLE 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 observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
|
||||
hash TEXT NOT NULL,
|
||||
observer_id TEXT,
|
||||
observer_name TEXT,
|
||||
direction TEXT,
|
||||
snr REAL,
|
||||
rssi REAL,
|
||||
score INTEGER,
|
||||
path_json TEXT,
|
||||
timestamp TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_transmissions_hash ON transmissions(hash);
|
||||
CREATE INDEX idx_transmissions_first_seen ON transmissions(first_seen);
|
||||
CREATE INDEX idx_transmissions_payload_type ON transmissions(payload_type);
|
||||
CREATE INDEX idx_observations_hash ON observations(hash);
|
||||
CREATE INDEX idx_observations_transmission_id ON observations(transmission_id);
|
||||
CREATE INDEX idx_observations_observer_id ON observations(observer_id);
|
||||
CREATE INDEX idx_observations_timestamp ON observations(timestamp);
|
||||
`);
|
||||
|
||||
// --- Read all packets ordered by timestamp ---
|
||||
console.log('Reading packets...');
|
||||
const packets = db.prepare('SELECT * FROM packets ORDER BY timestamp ASC').all();
|
||||
const totalPackets = packets.length;
|
||||
console.log(`Total packets: ${totalPackets}`);
|
||||
|
||||
// --- Group by hash and migrate ---
|
||||
const insertTransmission = db.prepare(`
|
||||
INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const insertObservation = db.prepare(`
|
||||
INSERT INTO observations (transmission_id, hash, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const hashToTransmissionId = new Map();
|
||||
let transmissionCount = 0;
|
||||
|
||||
const migrate = db.transaction(() => {
|
||||
for (const pkt of packets) {
|
||||
let txId = hashToTransmissionId.get(pkt.hash);
|
||||
if (txId === undefined) {
|
||||
const result = insertTransmission.run(
|
||||
pkt.raw_hex, pkt.hash, pkt.timestamp,
|
||||
pkt.route_type, pkt.payload_type, pkt.payload_version, pkt.decoded_json
|
||||
);
|
||||
txId = result.lastInsertRowid;
|
||||
hashToTransmissionId.set(pkt.hash, txId);
|
||||
transmissionCount++;
|
||||
}
|
||||
insertObservation.run(
|
||||
txId, pkt.hash, pkt.observer_id, pkt.observer_name, pkt.direction,
|
||||
pkt.snr, pkt.rssi, pkt.score, pkt.path_json, pkt.timestamp
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
migrate();
|
||||
|
||||
// --- Verify ---
|
||||
const obsCount = db.prepare('SELECT COUNT(*) as c FROM observations').get().c;
|
||||
const txCount = db.prepare('SELECT COUNT(*) as c FROM transmissions').get().c;
|
||||
const distinctHash = db.prepare('SELECT COUNT(DISTINCT hash) as c FROM packets').get().c;
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(2);
|
||||
|
||||
console.log('\n=== Migration Stats ===');
|
||||
console.log(`Total packets (source): ${totalPackets}`);
|
||||
console.log(`Unique transmissions created: ${transmissionCount}`);
|
||||
console.log(`Observations created: ${obsCount}`);
|
||||
console.log(`Dedup ratio: ${(totalPackets / transmissionCount).toFixed(2)}x`);
|
||||
console.log(`Time taken: ${elapsed}s`);
|
||||
|
||||
console.log('\n=== Verification ===');
|
||||
const obsOk = obsCount === totalPackets;
|
||||
const txOk = txCount === distinctHash;
|
||||
console.log(`observations (${obsCount}) = packets (${totalPackets}): ${obsOk ? 'PASS ✓' : 'FAIL ✗'}`);
|
||||
console.log(`transmissions (${txCount}) = distinct hashes (${distinctHash}): ${txOk ? 'PASS ✓' : 'FAIL ✗'}`);
|
||||
|
||||
if (!obsOk || !txOk) {
|
||||
console.error('\nVerification FAILED!');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\nMigration complete!');
|
||||
db.close();
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
# Pre-push validation — catches common JS errors before they hit prod
|
||||
set -e
|
||||
|
||||
echo "=== Syntax check ==="
|
||||
node -c server.js
|
||||
for f in public/*.js; do node -c "$f"; done
|
||||
echo "✅ All JS files parse OK"
|
||||
|
||||
echo "=== Checking for undefined common references ==="
|
||||
ERRORS=0
|
||||
|
||||
# esc() should only exist inside IIFEs that define it, not in files that don't
|
||||
for f in public/live.js public/map.js public/home.js public/nodes.js public/channels.js public/observers.js; do
|
||||
if grep -q '\besc(' "$f" 2>/dev/null && ! grep -q 'function esc' "$f" 2>/dev/null; then
|
||||
REFS=$(grep -n '\besc(' "$f" | grep -v escapeHtml | grep -v "desc\|Esc\|resc\|safeEsc" || true)
|
||||
if [ -n "$REFS" ]; then
|
||||
echo "❌ $f uses esc() but doesn't define it:"
|
||||
echo "$REFS"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "❌ $ERRORS validation error(s) found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Validation passed"
|
||||
Reference in New Issue
Block a user