mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-14 14:21:37 +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"]
|
||||
@@ -48,12 +48,16 @@ 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.0)
|
||||
### ⚡ 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.
|
||||
|
||||
@@ -69,7 +73,62 @@ 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
|
||||
@@ -93,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"
|
||||
},
|
||||
@@ -108,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 |
|
||||
|
||||
@@ -172,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
|
||||
@@ -190,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
|
||||
|
||||
+30
-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,8 +49,7 @@
|
||||
"SJC": "San Jose, US",
|
||||
"SFO": "San Francisco, US",
|
||||
"OAK": "Oakland, US",
|
||||
"MRY": "Monterey, US",
|
||||
"LAR": "Los Angeles, US"
|
||||
"MRY": "Monterey, US"
|
||||
},
|
||||
"cacheTTL": {
|
||||
"stats": 10,
|
||||
|
||||
@@ -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
|
||||
+365
-107
@@ -1,8 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* In-memory packet store — loads all packets from SQLite on startup,
|
||||
* 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 {
|
||||
@@ -16,16 +17,22 @@ class PacketStore {
|
||||
// SQLite-only mode: skip RAM loading, all reads go to DB
|
||||
this.sqliteOnly = process.env.NO_MEMORY_STORE === '1';
|
||||
|
||||
// Core storage: array sorted by timestamp DESC (newest first)
|
||||
// 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();
|
||||
this.byHash = new Map(); // hash → [packet, ...]
|
||||
this.byObserver = new Map(); // observer_id → [packet, ...]
|
||||
this.byNode = new Map(); // pubkey → [packet, ...]
|
||||
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, evicted: 0, inserts: 0, queries: 0 };
|
||||
this.stats = { totalLoaded: 0, totalObservations: 0, evicted: 0, inserts: 0, queries: 0 };
|
||||
}
|
||||
|
||||
/** Load all packets from SQLite into memory */
|
||||
@@ -35,74 +42,223 @@ class PacketStore {
|
||||
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._index(row);
|
||||
this.packets.push(row);
|
||||
this._indexLegacy(row);
|
||||
}
|
||||
|
||||
this.stats.totalLoaded = this.packets.length;
|
||||
this.loaded = true;
|
||||
const elapsed = Date.now() - t0;
|
||||
console.log(`[PacketStore] Loaded ${this.packets.length} packets in ${elapsed}ms (${Math.round(this.packets.length * this.estPacketBytes / 1024 / 1024)}MB est)`);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Index a packet into all lookup maps */
|
||||
_index(pkt) {
|
||||
this.byId.set(pkt.id, pkt);
|
||||
|
||||
if (pkt.hash) {
|
||||
if (!this.byHash.has(pkt.hash)) this.byHash.set(pkt.hash, []);
|
||||
this.byHash.get(pkt.hash).push(pkt);
|
||||
/** 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(pkt);
|
||||
this.byObserver.get(pkt.observer_id).push(obs);
|
||||
}
|
||||
|
||||
// Index by node pubkeys mentioned in decoded_json
|
||||
this._indexByNode(pkt);
|
||||
this.stats.totalObservations++;
|
||||
}
|
||||
|
||||
/** Extract node pubkeys/names from decoded_json and index */
|
||||
_indexByNode(pkt) {
|
||||
if (!pkt.decoded_json) return;
|
||||
/** Extract node pubkeys from decoded_json and index transmission in byNode */
|
||||
_indexByNode(tx) {
|
||||
if (!tx.decoded_json) return;
|
||||
try {
|
||||
const decoded = JSON.parse(pkt.decoded_json);
|
||||
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(pkt);
|
||||
this.byNode.get(k).push(tx);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Remove oldest packets when over memory limit */
|
||||
/** Remove oldest transmissions when over memory limit */
|
||||
_evict() {
|
||||
while (this.packets.length > this.maxPackets) {
|
||||
const old = this.packets.pop();
|
||||
this.byId.delete(old.id);
|
||||
// Remove from hash index
|
||||
if (old.hash && this.byHash.has(old.hash)) {
|
||||
const arr = this.byHash.get(old.hash).filter(p => p.id !== old.id);
|
||||
if (arr.length) this.byHash.set(old.hash, arr); else this.byHash.delete(old.hash);
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Remove from observer index
|
||||
if (old.observer_id && this.byObserver.has(old.observer_id)) {
|
||||
const arr = this.byObserver.get(old.observer_id).filter(p => p.id !== old.id);
|
||||
if (arr.length) this.byObserver.set(old.observer_id, arr); else this.byObserver.delete(old.observer_id);
|
||||
}
|
||||
// Skip node index cleanup for eviction (expensive, low value)
|
||||
// Skip node index cleanup (expensive, low value)
|
||||
this.stats.evicted++;
|
||||
}
|
||||
}
|
||||
@@ -111,15 +267,119 @@ class PacketStore {
|
||||
insert(packetData) {
|
||||
const id = this.dbModule.insertPacket(packetData);
|
||||
const row = this.dbModule.getPacket(id);
|
||||
if (row) {
|
||||
this.packets.unshift(row); // newest first
|
||||
this._index(row);
|
||||
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++;
|
||||
@@ -130,11 +390,13 @@ class PacketStore {
|
||||
|
||||
// Use indexes for single-key filters when possible
|
||||
if (hash && !type && !route && !region && !observer && !since && !until && !node) {
|
||||
results = this.byHash.get(hash) || [];
|
||||
const tx = this.byHash.get(hash);
|
||||
results = tx ? [tx] : [];
|
||||
} else if (observer && !type && !route && !region && !hash && !since && !until && !node) {
|
||||
results = this.byObserver.get(observer) || [];
|
||||
// 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.byNode.get(node) || [];
|
||||
results = this.findPacketsForNode(node).packets;
|
||||
} else {
|
||||
// Apply filters sequentially
|
||||
if (type !== undefined) {
|
||||
@@ -145,31 +407,25 @@ class PacketStore {
|
||||
const r = Number(route);
|
||||
results = results.filter(p => p.route_type === r);
|
||||
}
|
||||
if (observer) results = results.filter(p => p.observer_id === observer);
|
||||
if (hash) results = results.filter(p => p.hash === hash);
|
||||
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) {
|
||||
// Need to look up observers for this 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 => regionObservers.has(p.observer_id));
|
||||
results = results.filter(p =>
|
||||
p.observations.some(o => regionObservers.has(o.observer_id))
|
||||
);
|
||||
}
|
||||
if (node) {
|
||||
// Check indexed results first, fall back to text search
|
||||
const indexed = this.byNode.get(node);
|
||||
if (indexed) {
|
||||
const idSet = new Set(indexed.map(p => p.id));
|
||||
results = results.filter(p => idSet.has(p.id));
|
||||
} else {
|
||||
// Text search fallback (node name)
|
||||
results = results.filter(p =>
|
||||
p.decoded_json && p.decoded_json.includes(node)
|
||||
);
|
||||
}
|
||||
results = this.findPacketsForNode(node, results).packets;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,52 +446,52 @@ class PacketStore {
|
||||
return { packets: paginated, total };
|
||||
}
|
||||
|
||||
/** Query with groupByHash — aggregate packets by content hash */
|
||||
/** 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 results first
|
||||
// Get filtered transmissions
|
||||
const { packets: filtered, total: filteredTotal } = this.query({
|
||||
limit: 999999, offset: 0, type, route, region, observer, hash, since, until, node
|
||||
});
|
||||
|
||||
// Group by hash
|
||||
const groups = new Map();
|
||||
for (const p of filtered) {
|
||||
const h = p.hash || p.id.toString();
|
||||
if (!groups.has(h)) {
|
||||
groups.set(h, {
|
||||
hash: p.hash,
|
||||
observer_count: new Set(),
|
||||
count: 0,
|
||||
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,
|
||||
});
|
||||
}
|
||||
const g = groups.get(h);
|
||||
g.count++;
|
||||
if (p.observer_id) g.observer_count.add(p.observer_id);
|
||||
if (p.timestamp > g.latest) {
|
||||
g.latest = p.timestamp;
|
||||
}
|
||||
// Keep longest path
|
||||
if (p.path_json && (!g.path_json || p.path_json.length > g.path_json.length)) {
|
||||
g.path_json = p.path_json;
|
||||
g.raw_hex = p.raw_hex;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by latest DESC, paginate
|
||||
const sorted = [...groups.values()]
|
||||
.map(g => ({ ...g, observer_count: g.observer_count.size }))
|
||||
.sort((a, b) => b.latest.localeCompare(a.latest));
|
||||
// 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));
|
||||
@@ -255,25 +511,26 @@ class PacketStore {
|
||||
return results.reverse();
|
||||
}
|
||||
|
||||
/** Get a single packet by ID */
|
||||
/** 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) */
|
||||
/** 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);
|
||||
return this.byHash.get(hash) || [];
|
||||
const tx = this.byTransmission.get(hash);
|
||||
return tx ? tx.observations : [];
|
||||
}
|
||||
|
||||
/** Get all packets (raw array reference — do not mutate) */
|
||||
/** 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 packets matching a filter function */
|
||||
/** 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);
|
||||
@@ -292,6 +549,7 @@ class PacketStore {
|
||||
byHash: this.byHash.size,
|
||||
byObserver: this.byObserver.size,
|
||||
byNode: this.byNode.size,
|
||||
byTransmission: this.byTransmission.size,
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -306,7 +564,7 @@ class PacketStore {
|
||||
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) { where.push('decoded_json LIKE ?'); params.push(`%${node}%`); }
|
||||
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);
|
||||
@@ -323,7 +581,7 @@ class PacketStore {
|
||||
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) { where.push('decoded_json LIKE ?'); params.push(`%${node}%`); }
|
||||
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,
|
||||
|
||||
+11
-7
@@ -150,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>
|
||||
@@ -1125,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) => {
|
||||
@@ -1163,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));
|
||||
@@ -1178,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">
|
||||
@@ -1219,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>
|
||||
@@ -1236,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('')}
|
||||
|
||||
+14
-4
@@ -228,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 */
|
||||
@@ -283,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);
|
||||
@@ -373,7 +383,7 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
try {
|
||||
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>'
|
||||
@@ -459,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>`;
|
||||
}
|
||||
}
|
||||
|
||||
+9
-4
@@ -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>
|
||||
@@ -113,7 +114,8 @@
|
||||
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 () {
|
||||
@@ -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}`;
|
||||
|
||||
+4
-4
@@ -253,7 +253,7 @@
|
||||
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);
|
||||
@@ -373,7 +373,7 @@
|
||||
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>
|
||||
@@ -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}`; }
|
||||
}
|
||||
|
||||
|
||||
+14
-12
@@ -22,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=1773976827">
|
||||
<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">
|
||||
@@ -79,17 +79,19 @@
|
||||
<main id="app" role="main"></main>
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="app.js?v=1773977027"></script>
|
||||
<script src="home.js?v=1773977027"></script>
|
||||
<script src="packets.js?v=1773977027"></script>
|
||||
<script src="map.js?v=1773977027" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1773977027" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1773977027" 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=1773977027" 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=1773977027" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1773977027" 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;
|
||||
|
||||
+109
-19
@@ -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
|
||||
@@ -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,7 +1146,7 @@
|
||||
if (msg.type === 'packet') bufferPacket(msg.data);
|
||||
} catch {}
|
||||
};
|
||||
ws.onclose = () => setTimeout(connectWS, 3000);
|
||||
ws.onclose = () => setTimeout(connectWS, WS_RECONNECT_MS);
|
||||
ws.onerror = () => {};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
+76
-21
@@ -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 {
|
||||
// Load regions from config + observed IATAs
|
||||
try { REGION_NAMES = await api('/config/regions', { ttl: 3600 }); } catch {}
|
||||
|
||||
const data = await api(`/nodes?limit=10000&lastHeard=${filters.lastHeard}`, { ttl: CLIENT_TTL.nodeList });
|
||||
nodes = data.nodes || [];
|
||||
buildRoleChecks(data.counts || {});
|
||||
|
||||
// Load observers for jump buttons
|
||||
// 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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
+8
-10
@@ -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' },
|
||||
@@ -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>'}
|
||||
@@ -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 });
|
||||
})();
|
||||
+3
-4
@@ -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>
|
||||
|
||||
+76
-28
@@ -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 = {};
|
||||
@@ -214,6 +221,7 @@
|
||||
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) {
|
||||
@@ -382,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');
|
||||
@@ -570,7 +578,7 @@
|
||||
makeColumnsResizable('#pktTable', 'meshcore-pkt-col-widths');
|
||||
}
|
||||
|
||||
function renderTableRows() {
|
||||
async function renderTableRows() {
|
||||
const tbody = document.getElementById('pktBody');
|
||||
if (!tbody) return;
|
||||
|
||||
@@ -580,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) {
|
||||
@@ -624,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)
|
||||
@@ -645,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>
|
||||
@@ -675,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>
|
||||
@@ -712,6 +717,7 @@
|
||||
|
||||
async function selectPacket(id) {
|
||||
selectedId = id;
|
||||
history.replaceState(null, '', `#/packet/${id}`);
|
||||
renderTableRows();
|
||||
const isMobileNow = window.innerWidth <= 640;
|
||||
let panel;
|
||||
@@ -794,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>
|
||||
@@ -802,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>
|
||||
@@ -812,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) {
|
||||
@@ -820,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';
|
||||
@@ -832,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();
|
||||
@@ -1070,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 {}
|
||||
})();
|
||||
|
||||
@@ -1085,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 || [])) {
|
||||
@@ -1110,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: () => {}
|
||||
});
|
||||
})();
|
||||
|
||||
+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 */ });
|
||||
})();
|
||||
+22
-1
@@ -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; }
|
||||
@@ -1177,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;
|
||||
|
||||
@@ -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"
|
||||
@@ -7,6 +7,23 @@ const mqtt = require('mqtt');
|
||||
const path = require('path');
|
||||
const config = require('./config.json');
|
||||
const decoder = require('./decoder');
|
||||
|
||||
// Health thresholds — configurable with sensible defaults
|
||||
const _ht = config.healthThresholds || {};
|
||||
const HEALTH = {
|
||||
infraDegradedMs: _ht.infraDegradedMs || 86400000,
|
||||
infraSilentMs: _ht.infraSilentMs || 259200000,
|
||||
nodeDegradedMs: _ht.nodeDegradedMs || 3600000,
|
||||
nodeSilentMs: _ht.nodeSilentMs || 86400000
|
||||
};
|
||||
function getHealthMs(role) {
|
||||
const isInfra = role === 'repeater' || role === 'room';
|
||||
return {
|
||||
degradedMs: isInfra ? HEALTH.infraDegradedMs : HEALTH.nodeDegradedMs,
|
||||
silentMs: isInfra ? HEALTH.infraSilentMs : HEALTH.nodeSilentMs
|
||||
};
|
||||
}
|
||||
const MAX_HOP_DIST_SERVER = config.maxHopDist || 1.8;
|
||||
const crypto = require('crypto');
|
||||
const PacketStore = require('./packet-store');
|
||||
|
||||
@@ -30,6 +47,31 @@ const db = require('./db');
|
||||
const pktStore = new PacketStore(db, config.packetStore || {}).load();
|
||||
const channelKeys = require("./config.json").channelKeys || {};
|
||||
|
||||
// Auto-derive keys for hashtag channels: SHA256("#name") first 16 bytes
|
||||
// Any channel starting with # can be decrypted algorithmically
|
||||
function deriveHashtagKey(name) {
|
||||
return crypto.createHash('sha256').update(name).digest('hex').substring(0, 32);
|
||||
}
|
||||
// Seed with known hashtag channels from already-decrypted data
|
||||
const knownHashtags = new Set();
|
||||
try {
|
||||
const rows = db.db.prepare("SELECT DISTINCT json_extract(decoded_json, '$.channel') as ch FROM packets WHERE json_extract(decoded_json, '$.type') = 'CHAN'").all();
|
||||
for (const r of rows) {
|
||||
if (r.ch && r.ch.startsWith('#')) knownHashtags.add(r.ch);
|
||||
}
|
||||
} catch {}
|
||||
// Add any hashtag keys from config that we might not have seen yet
|
||||
for (const name of Object.keys(channelKeys)) {
|
||||
if (name.startsWith('#')) knownHashtags.add(name);
|
||||
}
|
||||
// Derive and add keys for all known hashtags
|
||||
for (const name of knownHashtags) {
|
||||
if (!channelKeys[name]) {
|
||||
channelKeys[name] = deriveHashtagKey(name);
|
||||
}
|
||||
}
|
||||
console.log(`[channels] ${Object.keys(channelKeys).length} channel keys (${knownHashtags.size} auto-derived hashtags)`);
|
||||
|
||||
// --- Cache TTL config (seconds → ms) ---
|
||||
const _ttlCfg = config.cacheTTL || {};
|
||||
const TTL = {
|
||||
@@ -163,6 +205,35 @@ app.get('/api/config/cache', (req, res) => {
|
||||
res.json(config.cacheTTL || {});
|
||||
});
|
||||
|
||||
// Expose all client-side config (roles, thresholds, tiles, limits, etc.)
|
||||
app.get('/api/config/client', (req, res) => {
|
||||
res.json({
|
||||
roles: config.roles || null,
|
||||
healthThresholds: config.healthThresholds || null,
|
||||
tiles: config.tiles || null,
|
||||
snrThresholds: config.snrThresholds || null,
|
||||
distThresholds: config.distThresholds || null,
|
||||
maxHopDist: config.maxHopDist || null,
|
||||
limits: config.limits || null,
|
||||
perfSlowMs: config.perfSlowMs || null,
|
||||
wsReconnectMs: config.wsReconnectMs || null,
|
||||
cacheInvalidateMs: config.cacheInvalidateMs || null,
|
||||
externalUrls: config.externalUrls || null
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/config/regions', (req, res) => {
|
||||
// Merge config regions with any IATA codes seen from observers
|
||||
const regions = { ...(config.regions || {}) };
|
||||
try {
|
||||
const rows = db.db.prepare("SELECT DISTINCT iata FROM observers WHERE iata IS NOT NULL").all();
|
||||
for (const r of rows) {
|
||||
if (r.iata && !regions[r.iata]) regions[r.iata] = r.iata; // fallback to code itself
|
||||
}
|
||||
} catch {}
|
||||
res.json(regions);
|
||||
});
|
||||
|
||||
app.get('/api/perf', (req, res) => {
|
||||
const summary = {};
|
||||
for (const [path, ep] of Object.entries(perfStats.endpoints)) {
|
||||
@@ -275,7 +346,7 @@ function geoDist(lat1, lon1, lat2, lon2) { return Math.sqrt((lat1 - lat2) ** 2 +
|
||||
// Sequential hop disambiguation: resolve 1-byte prefixes to best-matching nodes
|
||||
// Returns array of {hop, name, lat, lon, pubkey, ambiguous, unreliable} per hop
|
||||
function disambiguateHops(hops, allNodes) {
|
||||
const MAX_HOP_DIST = 1.8; // ~200km
|
||||
const MAX_HOP_DIST = MAX_HOP_DIST_SERVER;
|
||||
|
||||
// Build prefix index on first call (cached on allNodes array)
|
||||
if (!allNodes._prefixIdx) {
|
||||
@@ -362,28 +433,80 @@ function autoLearnHopNodes(hops, now) {
|
||||
}
|
||||
|
||||
// --- MQTT ---
|
||||
try {
|
||||
const mqttClient = mqtt.connect(config.mqtt.broker, { reconnectPeriod: 5000 });
|
||||
mqttClient.on('connect', () => {
|
||||
console.log(`MQTT connected to ${config.mqtt.broker}`);
|
||||
// Subscribe to both packet-logging format and companion bridge format
|
||||
mqttClient.subscribe(config.mqtt.topic, (err) => {
|
||||
if (err) console.error('MQTT subscribe error:', err);
|
||||
else console.log(`MQTT subscribed to ${config.mqtt.topic}`);
|
||||
});
|
||||
mqttClient.subscribe('meshcore/#', (err) => {
|
||||
if (err) console.error('MQTT subscribe error (bridge):', err);
|
||||
else console.log('MQTT subscribed to meshcore/#');
|
||||
});
|
||||
// Build list of MQTT sources: supports single config.mqtt (legacy) or config.mqttSources array
|
||||
const mqttSources = [];
|
||||
if (config.mqttSources && Array.isArray(config.mqttSources)) {
|
||||
mqttSources.push(...config.mqttSources);
|
||||
} else if (config.mqtt && config.mqtt.broker) {
|
||||
// Legacy single-broker config
|
||||
mqttSources.push({
|
||||
name: 'default',
|
||||
broker: config.mqtt.broker,
|
||||
topics: [config.mqtt.topic, 'meshcore/#'],
|
||||
});
|
||||
mqttClient.on('error', () => {}); // MQTT errors are expected when broker is offline
|
||||
mqttClient.on('offline', () => console.log('MQTT offline'));
|
||||
mqttClient.on('message', (topic, message) => {
|
||||
}
|
||||
|
||||
for (const source of mqttSources) {
|
||||
try {
|
||||
const opts = { reconnectPeriod: 5000 };
|
||||
if (source.username) opts.username = source.username;
|
||||
if (source.password) opts.password = source.password;
|
||||
if (source.rejectUnauthorized === false) opts.rejectUnauthorized = false;
|
||||
|
||||
const client = mqtt.connect(source.broker, opts);
|
||||
const tag = source.name || source.broker;
|
||||
|
||||
client.on('connect', () => {
|
||||
console.log(`MQTT [${tag}] connected to ${source.broker}`);
|
||||
const topics = Array.isArray(source.topics) ? source.topics : [source.topics || 'meshcore/#'];
|
||||
for (const t of topics) {
|
||||
client.subscribe(t, (err) => {
|
||||
if (err) console.error(`MQTT [${tag}] subscribe error for ${t}:`, err);
|
||||
else console.log(`MQTT [${tag}] subscribed to ${t}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
client.on('error', (e) => console.error(`MQTT [${tag}] error:`, e.message));
|
||||
client.on('offline', () => console.log(`MQTT [${tag}] offline`));
|
||||
client.on('message', (topic, message) => {
|
||||
try {
|
||||
const msg = JSON.parse(message.toString());
|
||||
const parts = topic.split('/');
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// IATA filter: if source has iataFilter, only accept matching regions
|
||||
const region = parts[1] || null;
|
||||
if (source.iataFilter && Array.isArray(source.iataFilter) && region) {
|
||||
if (!source.iataFilter.includes(region)) return;
|
||||
}
|
||||
|
||||
// --- Status topic: meshcore/<region>/<observer_id>/status ---
|
||||
if (parts[3] === 'status' && parts[2]) {
|
||||
const observerId = parts[2];
|
||||
const name = msg.origin || null;
|
||||
const iata = region;
|
||||
// Parse radio string: "freq,bw,sf,cr"
|
||||
let radioInfo = null;
|
||||
if (msg.radio) {
|
||||
const rp = msg.radio.split(',');
|
||||
radioInfo = { freq: parseFloat(rp[0]), bw: parseFloat(rp[1]), sf: parseInt(rp[2]), cr: parseInt(rp[3]) };
|
||||
}
|
||||
db.updateObserverStatus({
|
||||
id: observerId,
|
||||
name: name,
|
||||
iata: iata,
|
||||
model: msg.model || null,
|
||||
firmware: msg.firmware_version || null,
|
||||
client_version: msg.client_version || null,
|
||||
radio: msg.radio || null,
|
||||
battery_mv: msg.stats?.battery_mv || null,
|
||||
uptime_secs: msg.stats?.uptime_secs || null,
|
||||
noise_floor: msg.stats?.noise_floor || null,
|
||||
});
|
||||
console.log(`MQTT [${tag}] status: ${name || observerId} (${iata}) - ${msg.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Format 1: Raw packet logging (meshcoretomqtt / Cisien format) ---
|
||||
// Topic: meshcore/<region>/<observer>/packets, payload: { raw, SNR, RSSI, hash }
|
||||
if (msg.raw && typeof msg.raw === 'string') {
|
||||
@@ -391,10 +514,11 @@ try {
|
||||
const observerId = parts[2] || null;
|
||||
const region = parts[1] || null;
|
||||
|
||||
const packetId = pktStore.insert({
|
||||
const pktData = {
|
||||
raw_hex: msg.raw,
|
||||
timestamp: now,
|
||||
observer_id: observerId,
|
||||
observer_name: msg.origin || null,
|
||||
snr: msg.SNR ?? null,
|
||||
rssi: msg.RSSI ?? null,
|
||||
hash: computeContentHash(msg.raw),
|
||||
@@ -403,7 +527,9 @@ try {
|
||||
payload_version: decoded.header.payloadVersion,
|
||||
path_json: JSON.stringify(decoded.path.hops),
|
||||
decoded_json: JSON.stringify(decoded.payload),
|
||||
});
|
||||
};
|
||||
const packetId = pktStore.insert(pktData);
|
||||
try { db.insertTransmission(pktData); } catch (e) { console.error('[dual-write] transmission insert error:', e.message); }
|
||||
|
||||
if (decoded.path.hops.length > 0) {
|
||||
db.insertPath(packetId, decoded.path.hops);
|
||||
@@ -419,10 +545,16 @@ try {
|
||||
cache.invalidate('node:' + p.pubKey);
|
||||
cache.invalidate('health:' + p.pubKey);
|
||||
cache.invalidate('bulk-health');
|
||||
|
||||
// Cross-reference: if this node's pubkey matches an existing observer, backfill observer name
|
||||
if (p.name && p.pubKey) {
|
||||
const existingObs = db.db.prepare('SELECT id FROM observers WHERE id = ?').get(p.pubKey);
|
||||
if (existingObs) db.updateObserverStatus({ id: p.pubKey, name: p.name });
|
||||
}
|
||||
}
|
||||
|
||||
if (observerId) {
|
||||
db.upsertObserver({ id: observerId, iata: region });
|
||||
db.upsertObserver({ id: observerId, name: msg.origin || null, iata: region });
|
||||
}
|
||||
|
||||
|
||||
@@ -430,7 +562,9 @@ try {
|
||||
cache.debouncedInvalidateAll();
|
||||
|
||||
const fullPacket = pktStore.getById(packetId);
|
||||
const broadcastData = { id: packetId, raw: msg.raw, decoded, snr: msg.SNR, rssi: msg.RSSI, hash: msg.hash, observer: observerId, packet: fullPacket };
|
||||
const tx = pktStore.byTransmission.get(pktData.hash);
|
||||
const observation_count = tx ? tx.observation_count : 1;
|
||||
const broadcastData = { id: packetId, raw: msg.raw, decoded, snr: msg.SNR, rssi: msg.RSSI, hash: msg.hash, observer: observerId, packet: fullPacket, observation_count };
|
||||
broadcast({ type: 'packet', data: broadcastData });
|
||||
|
||||
if (decoded.header.payloadTypeName === 'GRP_TXT') {
|
||||
@@ -468,7 +602,7 @@ try {
|
||||
const role = advert.role || (advert.flags?.repeater ? 'repeater' : advert.flags?.room ? 'room' : 'companion');
|
||||
db.upsertNode({ public_key: pubKey, name, role, lat, lon, last_seen: now });
|
||||
|
||||
const packetId = pktStore.insert({
|
||||
const advertPktData = {
|
||||
raw_hex: null,
|
||||
timestamp: now,
|
||||
observer_id: 'companion',
|
||||
@@ -481,7 +615,9 @@ try {
|
||||
payload_version: 0,
|
||||
path_json: JSON.stringify([]),
|
||||
decoded_json: JSON.stringify(advert),
|
||||
});
|
||||
};
|
||||
const packetId = pktStore.insert(advertPktData);
|
||||
try { db.insertTransmission(advertPktData); } catch (e) { console.error('[dual-write] transmission insert error:', e.message); }
|
||||
broadcast({ type: 'packet', data: { id: packetId, decoded: { header: { payloadTypeName: 'ADVERT' }, payload: advert } } });
|
||||
}
|
||||
return;
|
||||
@@ -499,7 +635,7 @@ try {
|
||||
const senderKey = `sender-${senderName.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
|
||||
db.upsertNode({ public_key: senderKey, name: senderName, role: 'companion', lat: null, lon: null, last_seen: now });
|
||||
}
|
||||
const packetId = pktStore.insert({
|
||||
const chPktData = {
|
||||
raw_hex: null,
|
||||
timestamp: now,
|
||||
observer_id: 'companion',
|
||||
@@ -512,7 +648,9 @@ try {
|
||||
payload_version: 0,
|
||||
path_json: JSON.stringify([]),
|
||||
decoded_json: JSON.stringify(channelMsg),
|
||||
});
|
||||
};
|
||||
const packetId = pktStore.insert(chPktData);
|
||||
try { db.insertTransmission(chPktData); } catch (e) { console.error('[dual-write] transmission insert error:', e.message); }
|
||||
broadcast({ type: 'packet', data: { id: packetId, decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: channelMsg } } });
|
||||
broadcast({ type: 'message', data: { id: packetId, decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: channelMsg } } });
|
||||
return;
|
||||
@@ -521,7 +659,7 @@ try {
|
||||
// Handle direct messages
|
||||
if (topic.startsWith('meshcore/message/direct/')) {
|
||||
const dm = msg.payload || msg;
|
||||
const packetId = pktStore.insert({
|
||||
const dmPktData = {
|
||||
raw_hex: null,
|
||||
timestamp: dm.timestamp || now,
|
||||
observer_id: 'companion',
|
||||
@@ -533,7 +671,9 @@ try {
|
||||
payload_version: 0,
|
||||
path_json: JSON.stringify(dm.hops || []),
|
||||
decoded_json: JSON.stringify(dm),
|
||||
});
|
||||
};
|
||||
const packetId = pktStore.insert(dmPktData);
|
||||
try { db.insertTransmission(dmPktData); } catch (e) { console.error('[dual-write] transmission insert error:', e.message); }
|
||||
broadcast({ type: 'packet', data: { id: packetId, decoded: { header: { payloadTypeName: 'TXT_MSG' }, payload: dm } } });
|
||||
return;
|
||||
}
|
||||
@@ -541,7 +681,7 @@ try {
|
||||
// Handle traceroute
|
||||
if (topic.startsWith('meshcore/traceroute/')) {
|
||||
const trace = msg.payload || msg;
|
||||
const packetId = pktStore.insert({
|
||||
const tracePktData = {
|
||||
raw_hex: null,
|
||||
timestamp: now,
|
||||
observer_id: 'companion',
|
||||
@@ -553,20 +693,23 @@ try {
|
||||
payload_version: 0,
|
||||
path_json: JSON.stringify(trace.hops || trace.path || []),
|
||||
decoded_json: JSON.stringify(trace),
|
||||
});
|
||||
};
|
||||
const packetId = pktStore.insert(tracePktData);
|
||||
try { db.insertTransmission(tracePktData); } catch (e) { console.error('[dual-write] transmission insert error:', e.message); }
|
||||
broadcast({ type: 'packet', data: { id: packetId, decoded: { header: { payloadTypeName: 'TRACE' }, payload: trace } } });
|
||||
return;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
if (topic !== 'meshcore/status' && topic !== 'meshcore/events/connection') {
|
||||
console.error(`MQTT handler error [${topic}]:`, e.message);
|
||||
console.error(`MQTT [${tag}] handler error [${topic}]:`, e.message);
|
||||
try { console.error(' payload:', message.toString().substring(0, 200)); } catch {}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('MQTT connection failed (non-fatal):', e.message);
|
||||
} catch (e) {
|
||||
console.error(`MQTT [${source.name || source.broker}] connection failed (non-fatal):`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Express ---
|
||||
@@ -586,14 +729,45 @@ app.get('/api/stats', (req, res) => {
|
||||
});
|
||||
|
||||
app.get('/api/packets', (req, res) => {
|
||||
const { limit = 50, offset = 0, type, route, region, observer, hash, since, until, groupByHash, node } = req.query;
|
||||
const { limit = 50, offset = 0, type, route, region, observer, hash, since, until, groupByHash, node, nodes } = req.query;
|
||||
const order = req.query.order === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
// Multi-node filter: comma-separated pubkeys
|
||||
if (nodes) {
|
||||
const pubkeys = nodes.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const allPackets = new Map();
|
||||
for (const pk of pubkeys) {
|
||||
const { packets: found } = pktStore.findPacketsForNode(pk);
|
||||
for (const p of found) allPackets.set(p.id, p);
|
||||
}
|
||||
let results = [...allPackets.values()].sort((a, b) => order === 'DESC' ? b.timestamp.localeCompare(a.timestamp) : a.timestamp.localeCompare(b.timestamp));
|
||||
// Apply additional filters
|
||||
if (type !== undefined) results = results.filter(p => String(p.payload_type) === String(type));
|
||||
if (region) results = results.filter(p => (p.observer_id || '').includes(region) || (p.decoded_json || '').includes(region));
|
||||
if (since) results = results.filter(p => p.timestamp >= since);
|
||||
if (until) results = results.filter(p => p.timestamp <= until);
|
||||
const total = results.length;
|
||||
const paged = results.slice(Number(offset), Number(offset) + Number(limit));
|
||||
return res.json({ packets: paged, total, limit: Number(limit), offset: Number(offset) });
|
||||
}
|
||||
|
||||
// groupByHash is now the default behavior (transmissions ARE grouped) — keep param for compat
|
||||
if (groupByHash === 'true') {
|
||||
return res.json(pktStore.queryGrouped({ limit, offset, type, route, region, observer, hash, since, until, node }));
|
||||
}
|
||||
|
||||
res.json(pktStore.query({ limit, offset, type, route, region, observer, hash, since, until, node, order }));
|
||||
const expand = req.query.expand;
|
||||
const result = pktStore.query({ limit, offset, type, route, region, observer, hash, since, until, node, order });
|
||||
|
||||
// Strip observations[] from default response for bandwidth; include with ?expand=observations
|
||||
if (expand !== 'observations') {
|
||||
result.packets = result.packets.map(p => {
|
||||
const { observations, ...rest } = p;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// Lightweight endpoint: just timestamps for timeline sparkline
|
||||
@@ -624,7 +798,12 @@ app.get('/api/packets/:id', (req, res) => {
|
||||
// Build byte breakdown
|
||||
const breakdown = buildBreakdown(packet.raw_hex, decoded);
|
||||
|
||||
res.json({ packet, path: pathHops, breakdown });
|
||||
// Include sibling observations for this transmission
|
||||
const transmission = packet.hash ? pktStore.byTransmission.get(packet.hash) : null;
|
||||
const siblingObservations = transmission ? transmission.observations : [];
|
||||
const observation_count = transmission ? transmission.observation_count : 1;
|
||||
|
||||
res.json({ packet, path: pathHops, breakdown, observation_count, observations: siblingObservations });
|
||||
});
|
||||
|
||||
function buildBreakdown(rawHex, decoded) {
|
||||
@@ -710,7 +889,7 @@ app.post('/api/packets', (req, res) => {
|
||||
const decoded = decoder.decodePacket(hex, channelKeys);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const packetId = pktStore.insert({
|
||||
const apiPktData = {
|
||||
raw_hex: hex.toUpperCase(),
|
||||
timestamp: now,
|
||||
observer_id: observer || null,
|
||||
@@ -722,7 +901,9 @@ app.post('/api/packets', (req, res) => {
|
||||
payload_version: decoded.header.payloadVersion,
|
||||
path_json: JSON.stringify(decoded.path.hops),
|
||||
decoded_json: JSON.stringify(decoded.payload),
|
||||
});
|
||||
};
|
||||
const packetId = pktStore.insert(apiPktData);
|
||||
try { db.insertTransmission(apiPktData); } catch (e) { console.error('[dual-write] transmission insert error:', e.message); }
|
||||
|
||||
if (decoded.path.hops.length > 0) {
|
||||
db.insertPath(packetId, decoded.path.hops);
|
||||
@@ -804,10 +985,12 @@ app.get('/api/nodes/bulk-health', (req, res) => {
|
||||
const results = [];
|
||||
for (const node of nodes) {
|
||||
const packets = pktStore.byNode.get(node.public_key) || [];
|
||||
let totalPackets = packets.length, packetsToday = 0, snrSum = 0, snrCount = 0, lastHeard = null;
|
||||
let packetsToday = 0, snrSum = 0, snrCount = 0, lastHeard = null;
|
||||
const observers = {};
|
||||
let totalObservations = 0;
|
||||
|
||||
for (const pkt of packets) {
|
||||
totalObservations += pkt.observation_count || 1;
|
||||
if (pkt.timestamp > todayISO) packetsToday++;
|
||||
if (pkt.snr != null) { snrSum += pkt.snr; snrCount++; }
|
||||
if (!lastHeard || pkt.timestamp > lastHeard) lastHeard = pkt.timestamp;
|
||||
@@ -834,7 +1017,12 @@ app.get('/api/nodes/bulk-health', (req, res) => {
|
||||
results.push({
|
||||
public_key: node.public_key, name: node.name, role: node.role,
|
||||
lat: node.lat, lon: node.lon,
|
||||
stats: { totalPackets, packetsToday, avgSnr: snrCount ? snrSum / snrCount : null, lastHeard },
|
||||
stats: {
|
||||
totalTransmissions: packets.length,
|
||||
totalObservations,
|
||||
totalPackets: packets.length, // backward compat
|
||||
packetsToday, avgSnr: snrCount ? snrSum / snrCount : null, lastHeard
|
||||
},
|
||||
observers: observerRows
|
||||
});
|
||||
}
|
||||
@@ -854,8 +1042,7 @@ app.get('/api/nodes/network-status', (req, res) => {
|
||||
const ls = n.last_seen ? new Date(n.last_seen).getTime() : 0;
|
||||
const age = now - ls;
|
||||
const isInfra = r === 'repeater' || r === 'room';
|
||||
const degradedMs = isInfra ? 86400000 : 3600000;
|
||||
const silentMs = isInfra ? 259200000 : 86400000;
|
||||
const { degradedMs, silentMs } = getHealthMs(r);
|
||||
if (age < degradedMs) active++;
|
||||
else if (age < silentMs) degraded++;
|
||||
else silent++;
|
||||
@@ -961,6 +1148,7 @@ app.get('/api/analytics/rf', (req, res) => {
|
||||
|
||||
const _rfResult = {
|
||||
totalPackets: packets.length,
|
||||
totalAllPackets: pktStore.packets.length,
|
||||
snr: { min: Math.min(...snrVals), max: Math.max(...snrVals), avg: snrAvg, median: median(snrVals), stddev: stddev(snrVals, snrAvg) },
|
||||
rssi: { min: Math.min(...rssiVals), max: Math.max(...rssiVals), avg: rssiAvg, median: median(rssiVals), stddev: stddev(rssiVals, rssiAvg) },
|
||||
snrValues: snrHistogram, rssiValues: rssiHistogram, packetSizes: sizeHistogram,
|
||||
@@ -1403,7 +1591,7 @@ app.get('/api/resolve-hops', (req, res) => {
|
||||
}
|
||||
|
||||
// Sanity check: drop hops impossibly far from both neighbors (>200km ≈ 1.8°)
|
||||
const MAX_HOP_DIST = 1.8;
|
||||
const MAX_HOP_DIST = MAX_HOP_DIST_SERVER;
|
||||
for (let i = 0; i < hops.length; i++) {
|
||||
const pos = hopPositions[hops[i]];
|
||||
if (!pos) continue;
|
||||
@@ -1558,16 +1746,105 @@ app.get('/api/observers', (req, res) => {
|
||||
const _c = cache.get('observers'); if (_c) return res.json(_c);
|
||||
const observers = db.getObservers();
|
||||
const oneHourAgo = new Date(Date.now() - 3600000).toISOString();
|
||||
// Join observer location from nodes table (observers are nodes — same pubkey)
|
||||
const nodeLocStmt = db.db.prepare("SELECT lat, lon, role FROM nodes WHERE public_key = ? COLLATE NOCASE");
|
||||
const result = observers.map(o => {
|
||||
const obsPackets = pktStore.byObserver.get(o.id) || [];
|
||||
const lastHour = { count: obsPackets.filter(p => p.timestamp > oneHourAgo).length };
|
||||
return { ...o, packetsLastHour: lastHour.count };
|
||||
const node = nodeLocStmt.get(o.id);
|
||||
return { ...o, packetsLastHour: lastHour.count, lat: node?.lat || null, lon: node?.lon || null, nodeRole: node?.role || null };
|
||||
});
|
||||
const _oResult = { observers: result, server_time: new Date().toISOString() };
|
||||
cache.set('observers', _oResult, TTL.observers);
|
||||
res.json(_oResult);
|
||||
});
|
||||
|
||||
// Observer detail
|
||||
app.get('/api/observers/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
const obs = db.db.prepare('SELECT * FROM observers WHERE id = ?').get(id);
|
||||
if (!obs) return res.status(404).json({ error: 'Observer not found' });
|
||||
const oneHourAgo = new Date(Date.now() - 3600000).toISOString();
|
||||
const obsPackets = pktStore.byObserver.get(id) || [];
|
||||
const packetsLastHour = obsPackets.filter(p => p.timestamp > oneHourAgo).length;
|
||||
res.json({ ...obs, packetsLastHour });
|
||||
});
|
||||
|
||||
// Observer analytics
|
||||
app.get('/api/observers/:id/analytics', (req, res) => {
|
||||
const id = req.params.id;
|
||||
const days = parseInt(req.query.days) || 7;
|
||||
const since = new Date(Date.now() - days * 86400000).toISOString();
|
||||
const obsPackets = (pktStore.byObserver.get(id) || []).filter(p => p.timestamp >= since).sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||
|
||||
// Timeline: packets per hour (last N days, bucketed)
|
||||
const bucketMs = days <= 1 ? 3600000 : days <= 7 ? 3600000 * 4 : 86400000;
|
||||
const buckets = {};
|
||||
for (const p of obsPackets) {
|
||||
const t = Math.floor(new Date(p.timestamp).getTime() / bucketMs) * bucketMs;
|
||||
buckets[t] = (buckets[t] || 0) + 1;
|
||||
}
|
||||
const timeline = Object.entries(buckets)
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([t, count]) => {
|
||||
const d = new Date(parseInt(t));
|
||||
const label = days <= 1
|
||||
? d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||||
: days <= 7
|
||||
? d.toLocaleDateString('en-US', { weekday: 'short', hour: '2-digit' })
|
||||
: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
return { label, count };
|
||||
});
|
||||
|
||||
// Packet type breakdown
|
||||
const packetTypes = {};
|
||||
for (const p of obsPackets) {
|
||||
packetTypes[p.payload_type] = (packetTypes[p.payload_type] || 0) + 1;
|
||||
}
|
||||
|
||||
// Unique nodes per time bucket
|
||||
const nodeBuckets = {};
|
||||
for (const p of obsPackets) {
|
||||
const t = Math.floor(new Date(p.timestamp).getTime() / bucketMs) * bucketMs;
|
||||
if (!nodeBuckets[t]) nodeBuckets[t] = new Set();
|
||||
try {
|
||||
const decoded = typeof p.decoded_json === 'string' ? JSON.parse(p.decoded_json) : p.decoded_json;
|
||||
if (decoded && decoded.pubKey) nodeBuckets[t].add(decoded.pubKey);
|
||||
if (decoded && decoded.srcHash) nodeBuckets[t].add(decoded.srcHash);
|
||||
if (decoded && decoded.destHash) nodeBuckets[t].add(decoded.destHash);
|
||||
} catch {}
|
||||
const hops = typeof p.path_json === 'string' ? JSON.parse(p.path_json) : (p.path_json || []);
|
||||
for (const h of hops) nodeBuckets[t].add(h);
|
||||
}
|
||||
const nodesTimeline = Object.entries(nodeBuckets)
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([t, nodes]) => {
|
||||
const d = new Date(parseInt(t));
|
||||
const label = days <= 1
|
||||
? d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||||
: days <= 7
|
||||
? d.toLocaleDateString('en-US', { weekday: 'short', hour: '2-digit' })
|
||||
: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
return { label, count: nodes.size };
|
||||
});
|
||||
|
||||
// SNR distribution
|
||||
const snrBuckets = {};
|
||||
for (const p of obsPackets) {
|
||||
if (p.snr == null) continue;
|
||||
const bucket = Math.floor(p.snr / 2) * 2; // 2dB buckets
|
||||
const range = bucket + ' to ' + (bucket + 2);
|
||||
snrBuckets[bucket] = snrBuckets[bucket] || { range, count: 0 };
|
||||
snrBuckets[bucket].count++;
|
||||
}
|
||||
const snrDistribution = Object.values(snrBuckets).sort((a, b) => parseFloat(a.range) - parseFloat(b.range));
|
||||
|
||||
// Recent packets (last 20) — obsPackets filtered from pktStore, newest-first
|
||||
const recentPackets = obsPackets.slice(0, 20);
|
||||
|
||||
res.json({ timeline, packetTypes, nodesTimeline, snrDistribution, recentPackets });
|
||||
});
|
||||
|
||||
app.get('/api/traces/:hash', (req, res) => {
|
||||
const packets = (pktStore.getSiblings(req.params.hash) || []).sort((a,b) => a.timestamp > b.timestamp ? 1 : -1);
|
||||
const traces = packets.map(p => ({ observer: p.observer_id, time: p.timestamp, snr: p.snr, rssi: p.rssi }));
|
||||
@@ -1584,7 +1861,9 @@ app.get('/api/nodes/:pubkey/health', (req, res) => {
|
||||
|
||||
const todayStart = new Date(); todayStart.setUTCHours(0, 0, 0, 0);
|
||||
const todayISO = todayStart.toISOString();
|
||||
const packets = pktStore.byNode.get(pubkey) || [];
|
||||
|
||||
// Single reusable lookup for all packets referencing this node
|
||||
const packets = pktStore.findPacketsForNode(pubkey).packets;
|
||||
|
||||
// Observers
|
||||
const obsMap = {};
|
||||
@@ -1609,12 +1888,24 @@ app.get('/api/nodes/:pubkey/health', (req, res) => {
|
||||
avgSnr: o.snrN ? o.snrSum / o.snrN : null, avgRssi: o.rssiN ? o.rssiSum / o.rssiN : null
|
||||
})).sort((a, b) => b.packetCount - a.packetCount);
|
||||
|
||||
const recentPackets = packets.slice(-10).reverse();
|
||||
const recentPackets = packets.slice(0, 20);
|
||||
|
||||
// Count transmissions vs observations
|
||||
const counts = pktStore.countForNode(pubkey);
|
||||
const recentWithoutObs = recentPackets.map(p => {
|
||||
const { observations, ...rest } = p;
|
||||
return { ...rest, observation_count: p.observation_count || 1 };
|
||||
});
|
||||
|
||||
const result = {
|
||||
node: node.node || node, observers,
|
||||
stats: { totalPackets: packets.length, packetsToday, avgSnr: snrN ? snrSum / snrN : null, avgHops: hopCount > 0 ? Math.round(totalHops / hopCount) : 0, lastHeard },
|
||||
recentPackets
|
||||
stats: {
|
||||
totalTransmissions: counts.transmissions,
|
||||
totalObservations: counts.observations,
|
||||
totalPackets: counts.transmissions, // backward compat
|
||||
packetsToday, avgSnr: snrN ? snrSum / snrN : null, avgHops: hopCount > 0 ? Math.round(totalHops / hopCount) : 0, lastHeard
|
||||
},
|
||||
recentPackets: recentWithoutObs
|
||||
};
|
||||
cache.set(_ck, result, TTL.nodeHealth);
|
||||
res.json(result);
|
||||
@@ -1633,8 +1924,8 @@ app.get('/api/nodes/:pubkey/analytics', (req, res) => {
|
||||
const fromISO = new Date(now.getTime() - days * 86400000).toISOString();
|
||||
const toISO = now.toISOString();
|
||||
|
||||
// Read from in-memory index, filter by time range
|
||||
const allPkts = pktStore.byNode.get(pubkey) || [];
|
||||
// Read from in-memory index + name search, filter by time range
|
||||
const allPkts = pktStore.findPacketsForNode(pubkey).packets;
|
||||
const packets = allPkts.filter(p => p.timestamp > fromISO);
|
||||
|
||||
// Activity timeline
|
||||
|
||||
Reference in New Issue
Block a user