mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-30 18:15:47 +00:00
Compare commits
153 Commits
rename/cor
...
v2.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60219ae02a | ||
|
|
5e28fb15c9 | ||
|
|
98f958641a | ||
|
|
3a8d92e39b | ||
|
|
0f8d63636b | ||
|
|
132ab60e06 | ||
|
|
c123ee731d | ||
|
|
0fb586730b | ||
|
|
7e304e60d5 | ||
|
|
9171eecc69 | ||
|
|
552ba2f970 | ||
|
|
3ff29519b4 | ||
|
|
39c1782881 | ||
|
|
cbd13c00d6 | ||
|
|
4b1c1c3f22 | ||
|
|
0d3b12d40a | ||
|
|
e000db438c | ||
|
|
09f6f106ba | ||
|
|
a299798b31 | ||
|
|
181fddf196 | ||
|
|
2ae467bc72 | ||
|
|
a11ace77ac | ||
|
|
b1c2e817fa | ||
|
|
3d31afd0ec | ||
|
|
28ac094d83 | ||
|
|
8c4b3f029f | ||
|
|
3b1e16a1d6 | ||
|
|
fa7f1cf76a | ||
|
|
b800d77570 | ||
|
|
7e0cd455ae | ||
|
|
4f66e377d1 | ||
|
|
1877c49adc | ||
|
|
461fb7ee68 | ||
|
|
679f9a552f | ||
|
|
f1e3a57fcf | ||
|
|
1dc5daab67 | ||
|
|
8892fb0f66 | ||
|
|
98a6cbd3b4 | ||
|
|
ad6a796b35 | ||
|
|
1b2f28cb5f | ||
|
|
2894c38435 | ||
|
|
97be64353a | ||
|
|
8ae7d7710b | ||
|
|
b61b71635b | ||
|
|
04a138eba3 | ||
|
|
f51db66775 | ||
|
|
75d76cf68e | ||
|
|
1e61e021ec | ||
|
|
c4d6fb7cd3 | ||
|
|
1158161e1a | ||
|
|
81f284e952 | ||
|
|
6f64ed6b9b | ||
|
|
d51c7a780c | ||
|
|
4a909fbd0b | ||
|
|
105f8546b1 | ||
|
|
eca0c9bd61 | ||
|
|
3c12690ccb | ||
|
|
ddb6dcb113 | ||
|
|
746f5cf3b1 | ||
|
|
1320e33bd6 | ||
|
|
00ce8de7bc | ||
|
|
32b897d8f3 | ||
|
|
ebc72fa364 | ||
|
|
6f350bb785 | ||
|
|
775a45f9eb | ||
|
|
9eb4bcc088 | ||
|
|
e1590c6242 | ||
|
|
7d164f4a67 | ||
|
|
e70dd8b2fa | ||
|
|
a66cc8f126 | ||
|
|
7a3a3a5ea0 | ||
|
|
cf14701592 | ||
|
|
7e841d89c1 | ||
|
|
795be6996f | ||
|
|
187a2ac536 | ||
|
|
2c38d3c7d6 | ||
|
|
9120985ab1 | ||
|
|
cc55e5733d | ||
|
|
6b78b3c5e4 | ||
|
|
75af7c3094 | ||
|
|
e087156d90 | ||
|
|
fe552c8a4b | ||
|
|
3edb02a829 | ||
|
|
7f1c735981 | ||
|
|
5bb17bbe60 | ||
|
|
a892691dd3 | ||
|
|
fd57790d36 | ||
|
|
7d46d96563 | ||
|
|
47fa32f982 | ||
|
|
b3599694c6 | ||
|
|
2f50bc0c89 | ||
|
|
77fe834eb6 | ||
|
|
9f4a9c6506 | ||
|
|
8bf5e64b1d | ||
|
|
f1fd34f47a | ||
|
|
013e33253d | ||
|
|
bbf17b69ef | ||
|
|
3270e389c5 | ||
|
|
95443abd3e | ||
|
|
35313c57d4 | ||
|
|
013cbaf5c4 | ||
|
|
49d4841862 | ||
|
|
eaf0e621af | ||
|
|
99dde1fc31 | ||
|
|
cff995e00c | ||
|
|
ab163227f8 | ||
|
|
9664d6089c | ||
|
|
606d4e134f | ||
|
|
da475e9c13 | ||
|
|
739d4480a1 | ||
|
|
212990a295 | ||
|
|
ce190886ff | ||
|
|
9f53a059c7 | ||
|
|
c6d72e828d | ||
|
|
2834cfccba | ||
|
|
d555ea26be | ||
|
|
133f267c4c | ||
|
|
bd5171cf95 | ||
|
|
dc6df38c9a | ||
|
|
0ef1eb2595 | ||
|
|
4bfe1ec363 | ||
|
|
290508d67c | ||
|
|
36ad6c8f75 | ||
|
|
071acd1561 | ||
|
|
c72f014f99 | ||
|
|
558687051e | ||
|
|
5f291bdaa7 | ||
|
|
31a8f707b6 | ||
|
|
78ea581fc5 | ||
|
|
1b737519bc | ||
|
|
0848a6c634 | ||
|
|
926a68959b | ||
|
|
67a90a3a33 | ||
|
|
be1bcbf733 | ||
|
|
f6fb024a20 | ||
|
|
4395ff348c | ||
|
|
0c97e4e980 | ||
|
|
78cc5edbb4 | ||
|
|
c7e528331c | ||
|
|
e9b2dc7c00 | ||
|
|
5a36b8bf2e | ||
|
|
6bff9ce5e7 | ||
|
|
92c258dabc | ||
|
|
5eacce1b40 | ||
|
|
5f3e5a6ad1 | ||
|
|
975abade32 | ||
|
|
cf9d5e3325 | ||
|
|
003f5b1477 | ||
|
|
1adc3ca41d | ||
|
|
ab22b98f48 | ||
|
|
607eef2d06 | ||
|
|
ac8a6a4dc3 | ||
|
|
209e17fcd4 |
128
CHANGELOG.md
128
CHANGELOG.md
@@ -1,4 +1,88 @@
|
||||
# Changelog
|
||||
|
||||
## [2.4.0] — 2026-03-22
|
||||
|
||||
Big batch: observation drill-down, distance analytics, regional filters on all tabs, channel decryption fixes, performance, and a ton of bug fixes.
|
||||
|
||||
### Added
|
||||
- Observation-level deeplinks (`#/packets/HASH?obs=OBSERVER_ID`)
|
||||
- Observation detail pane (click any child row for its specific data)
|
||||
- Observation sort: Observer / Path ↑↓ / Time ↑↓ with persistent preference
|
||||
- Ungrouped mode flattens all observations into individual rows
|
||||
- Sort help tooltip (ⓘ) explaining each mode
|
||||
- Distance/Range analytics tab with haversine calculations
|
||||
- View on Map buttons for distance leaderboard entries
|
||||
- Realistic packet propagation mode on live map
|
||||
- Packet propagation time in detail pane
|
||||
- Replay sends all observations with realistic animation
|
||||
- Paths-through section on node detail (desktop + mobile)
|
||||
- Regional filters on all tabs (shared RegionFilter component)
|
||||
- Favorites filter on live map (packet-level, not node markers)
|
||||
- Configurable map defaults via `config.json`
|
||||
- Hash prefix labels on map with spiral deconfliction + callout lines
|
||||
- Channel rainbow table (pre-computed keys for common names)
|
||||
- Zero-API live channel updates via WebSocket
|
||||
- Channel message dedup by packet hash
|
||||
- Channel name tags (blue pill) in packet detail column
|
||||
- Shareable channel URLs (`#/channels/HASH`)
|
||||
- API key required for POST endpoints
|
||||
- HTTPS support (lincomatic PR #105)
|
||||
- Graceful shutdown (lincomatic PR #109)
|
||||
- Filter bar: logical grouping, consistent 34px height, help tooltips
|
||||
- Multi-select Observer and Type filters (checkbox dropdowns, OR logic)
|
||||
- Hex Paths toggle: show raw hex hash prefixes vs resolved node names
|
||||
- Time window selector (15min/30min/1h/3h/6h/12h/24h/All) replaces fixed packet count limit
|
||||
- Pause/resume button (⏸/▶) for live WebSocket updates with buffered packet count
|
||||
- localStorage persistence for all filter/view preferences
|
||||
|
||||
### Changed
|
||||
- Channel keys: plain `String(channelHash)`, `hashChannels` for auto-derived SHA256
|
||||
- Node region filtering uses ADVERT-based index (accurate local presence vs mesh-wide routing)
|
||||
- Header row reflects first sorted observation's data
|
||||
- Max hop distance filter: 1000km → 300km (LoRa record ~250km)
|
||||
- Route view labels use deconflicted divIcons
|
||||
- Channels page hides encrypted messages, shows only decrypted
|
||||
- Dark mode: active filter buttons retain accent styling
|
||||
- Region dropdown: `IATA - Friendly Name` format, proper sizing
|
||||
- Observer/Type filters are pure client-side (no API calls on filter change)
|
||||
- Packet loading: time-window based (`since`) instead of fixed count limit
|
||||
- Header row shows matching observer when observer filter is active
|
||||
|
||||
### Removed
|
||||
- Legacy `packets` and `paths` database tables (auto-migrated on startup)
|
||||
- Redundant server-side type/observer filtering (client filters in-memory)
|
||||
|
||||
### Fixed
|
||||
- Header row showed longest path instead of first observer's path
|
||||
- Observer/path mismatch when earlier observation arrives later
|
||||
- Auto-seeding fake data on empty DB (now requires `--seed` flag)
|
||||
- Channel "10h ago" bug (used stale `first_seen` instead of current time)
|
||||
- Stale UI: wrong ID type for packet lookup after insert
|
||||
- ADVERT timestamp validation rejecting valid nodes
|
||||
- Channels page API spam on every WS update
|
||||
- Duplicate observations in expanded view
|
||||
- Analytics RF 500 error (stack overflow with 193K observations)
|
||||
- Region filter SQL using non-existent column
|
||||
- Channel hash: decimal→hex, keyed by decrypted name
|
||||
- Corrupted repeater entries (ADVERT validation at ingestion)
|
||||
- Hash_size: uses newest ADVERT, precomputed at startup
|
||||
- Tab backgrounding: skip animations, resume cleanly
|
||||
- Feed panel position (obscured by VCR bar)
|
||||
- Hop disambiguation anchored from sender origin
|
||||
- Packet hash case normalization for deeplinks
|
||||
- Sort help tooltip rendering (CSS pseudo-elements don't support newlines)
|
||||
|
||||
### Performance
|
||||
- `/api/analytics/distance`: 3s → 630ms
|
||||
- `/api/analytics/topology`: 289ms → 193ms
|
||||
- `/api/observers`: 3s → 130ms
|
||||
- `/api/nodes`: 50ms → 2ms (precomputed hash_size)
|
||||
- Event loop max: 3.2s → 903ms (startup only)
|
||||
- Pre-warm yields event loop via `setImmediate`
|
||||
- Client-side hop resolution
|
||||
- SQLite manual PASSIVE checkpointing
|
||||
- Single API call for packet expand (was 3)
|
||||
|
||||
## [2.3.0] - 2026-03-20
|
||||
|
||||
### Added
|
||||
@@ -27,47 +111,3 @@
|
||||
### Performance
|
||||
- **8.19× dedup ratio on production** (117K observations → 14K transmissions)
|
||||
- RAM usage reduced proportionally — store loads transmissions, not inflated observations
|
||||
|
||||
## v2.1.1 — Multi-Broker MQTT & Observer Detail (2026-03-20)
|
||||
|
||||
### 🆕 New Features
|
||||
|
||||
- **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.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **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.
|
||||
|
||||
### 🏗️ Infrastructure
|
||||
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
|
||||
93
DEDUP-DESIGN.md
Normal file
93
DEDUP-DESIGN.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Packet Deduplication Design
|
||||
|
||||
## The Problem
|
||||
|
||||
A single physical RF transmission gets recorded as N rows in the DB, where N = number of observers that heard it. Each row has the same `hash` but different `path_json` and `observer_id`.
|
||||
|
||||
### Example
|
||||
```
|
||||
Pkt 1 repeat 1: Path: A→B→C→D→E (observer E)
|
||||
Pkt 1 repeat 2: Path: A→B→F→G (observer G)
|
||||
Pkt 1 repeat 3: Path: A→C→H→J→K (observer K)
|
||||
```
|
||||
|
||||
- Repeater A sent 1 packet, not 3
|
||||
- Repeater B sent 1 packet, not 2 (C and F both heard the same broadcast)
|
||||
- The hash is identical across all 3 rows
|
||||
|
||||
### Why the hash works
|
||||
|
||||
`computeContentHash()` = `SHA256(header_byte + payload)`, skipping path hops. Two observations of the same original packet through different paths produce the same hash. This is the dedup key.
|
||||
|
||||
## What's inflated (and what's not)
|
||||
|
||||
| Context | Current (inflated?) | Correct behavior |
|
||||
|---------|-------------------|------------------|
|
||||
| Node "total packets" | COUNT(*) — inflated | COUNT(DISTINCT hash) for transmissions |
|
||||
| Packets/hour on observer page | Raw count | Correct — each observer DID receive it |
|
||||
| Node analytics throughput | Inflated | DISTINCT hash |
|
||||
| Live map animations | N animations per physical packet | 1 animation? Or 1 per path? TBD |
|
||||
| "Heard By" table | Observations per observer | Correct as-is |
|
||||
| RF analytics (SNR/RSSI) | Mixes observations | Each observation has its own SNR — all valid |
|
||||
| Topology/path analysis | All paths shown | All paths are valuable — don't discard |
|
||||
| Packet list (grouped mode) | Groups by hash already | Probably fine |
|
||||
| Packet list (ungrouped) | Shows every observation | Maybe show distinct, expand for repeats? |
|
||||
|
||||
## Key Principle
|
||||
|
||||
**Observations are valuable data — never discard them.** The paths tell you about mesh topology, coverage, and redundancy. But **counts displayed to users should reflect reality** (1 transmission = 1 count).
|
||||
|
||||
## Design Decisions Needed
|
||||
|
||||
1. **What does "packets" mean in node detail?** Unique transmissions? Total observations? Both?
|
||||
2. **Live map**: 1 animation with multiple path lines? Or 1 per observation?
|
||||
3. **Analytics charts**: Should throughput charts show transmissions or observations?
|
||||
4. **Packet list default view**: Group by hash by default?
|
||||
5. **New metric: "observation ratio"?** — avg observations per transmission tells you about mesh redundancy/coverage
|
||||
|
||||
## Work Items
|
||||
|
||||
- [ ] **DB/API: Add distinct counts** — `findPacketsForNode()` and health endpoint should return both `totalTransmissions` (DISTINCT hash) and `totalObservations` (COUNT(*))
|
||||
- [ ] **Node detail UI** — show "X transmissions seen Y times" or similar
|
||||
- [ ] **Bulk health / network status** — use distinct hash counts
|
||||
- [ ] **Node analytics charts** — throughput should use distinct hashes
|
||||
- [ ] **Packets page default** — consider grouping by hash by default
|
||||
- [ ] **Live map** — decide on animation strategy for repeated observations
|
||||
- [ ] **Observer page** — observation count is correct, but could add "unique packets" column
|
||||
- [ ] **In-memory store** — add hash→[packets] index if not already there (check `pktStore.byHash`)
|
||||
- [ ] **API: packet siblings** — `/api/packets/:id/siblings` or `?groupByHash=true` (may already exist)
|
||||
- [ ] **RF analytics** — keep all observations for SNR/RSSI (each is a real measurement) but label counts correctly
|
||||
- [ ] **"Coverage ratio" metric** — avg(observations per unique hash) per node/observer — measures mesh redundancy
|
||||
|
||||
## Live Map Animation Design
|
||||
|
||||
### Current behavior
|
||||
Every observation triggers a separate animation. Same packet heard by 3 observers = 3 independent route animations. Looks like 3 packets when it was 1.
|
||||
|
||||
### Options considered
|
||||
|
||||
**Option A: Single animation, all paths simultaneously (PREFERRED)**
|
||||
When a hash first arrives, buffer briefly (500ms-2s) for sibling observations, then animate all paths at once. One pulse from origin, multiple route lines fanning out simultaneously. Most accurate — this IS what physically happened: one RF burst propagating through the mesh along multiple paths at once.
|
||||
|
||||
Timing challenge: observations don't arrive simultaneously (seconds apart). Need to buffer the first observation, wait for siblings, then render all together. Adds slight latency to "live" feel.
|
||||
|
||||
**Option B: Single animation, "best" path only** — REJECTED
|
||||
Pick shortest/highest-SNR path, animate only that. Clean but loses coverage/redundancy info.
|
||||
|
||||
**Option C: Single origin pulse, staggered path reveals** — REJECTED
|
||||
Origin pulses once, paths draw in sequence with delay. Dramatic but busy, and doesn't reflect reality (the propagation is simultaneous).
|
||||
|
||||
**Option D: Animate first, suppress siblings** — REJECTED (pragmatic but inaccurate)
|
||||
First observation gets animation, subsequent same-hash observations silently logged. Simple but you never see alternate paths on the live map.
|
||||
|
||||
### Implementation notes (for when we build this)
|
||||
- Need a client-side hash buffer: `Map<hash, {timer, packets[]}>`
|
||||
- On first WS packet with new hash: start timer (configurable, ~1-2s)
|
||||
- On subsequent packets with same hash: add to buffer, reset/extend timer
|
||||
- On timer expiry: animate all buffered paths for that hash simultaneously
|
||||
- Feed sidebar could show consolidated entry: "1 packet, 3 paths" with expand
|
||||
- Buffer window should be configurable (config.json)
|
||||
|
||||
## Status
|
||||
|
||||
**Discussion phase** — no code changes yet. Iavor wants to finalize design before implementation. Live map changes tabled for later.
|
||||
236
DEDUP-MIGRATION-PLAN.md
Normal file
236
DEDUP-MIGRATION-PLAN.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# Packet Deduplication — Normalized Schema Migration Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Split the monolithic `packets` table into two tables:
|
||||
- **`packets`** — one row per unique physical transmission (keyed by content hash)
|
||||
- **`observations`** — one row per observer sighting (SNR, RSSI, path, observer, timestamp)
|
||||
|
||||
This fixes inflated packet counts across the entire app and enables proper "1 transmission seen N times" semantics.
|
||||
|
||||
## Current State
|
||||
|
||||
**`packets` table**: 1 row per observation. ~61MB, ~30K+ rows. Same hash appears N times (once per observer). Fields mix transmission data (raw_hex, payload_type, decoded_json, hash) with observation data (observer_id, snr, rssi, path_json).
|
||||
|
||||
**`packet-store.js`**: In-memory mirror of packets table. Indexes: `byId`, `byHash` (hash → [packets]), `byObserver`, `byNode`. All reads served from RAM. SQLite is write-only for packets.
|
||||
|
||||
**Touch surface**: ~66 SQL queries across db.js/server.js/packet-store.js. ~12 frontend files consume packet data.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 1: Schema Migration (Backend Only)
|
||||
|
||||
**Goal**: New tables exist, data migrated, old table preserved as backup. No behavioral changes yet.
|
||||
|
||||
### Tasks
|
||||
1. **Create new schema** in `db.js` init:
|
||||
```sql
|
||||
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 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);
|
||||
```
|
||||
|
||||
2. **Write migration script** (`scripts/migrate-dedup.js`):
|
||||
- Read all rows from `packets` ordered by timestamp
|
||||
- Group by hash
|
||||
- For each unique hash: INSERT into `transmissions` (use first observation's raw_hex, decoded_json, etc.)
|
||||
- For each row: INSERT into `observations` with foreign key to transmission
|
||||
- Verify counts: `SELECT COUNT(*) FROM observations` = old packets count
|
||||
- Verify: `SELECT COUNT(*) FROM transmissions` < observations count
|
||||
- **Do NOT drop old `packets` table** — rename to `packets_backup`
|
||||
|
||||
3. **Print migration stats**: total packets, unique transmissions, dedup ratio, time taken
|
||||
|
||||
### Validation
|
||||
- `COUNT(*) FROM observations` = `COUNT(*) FROM packets_backup`
|
||||
- `COUNT(*) FROM transmissions` = `COUNT(DISTINCT hash) FROM packets_backup`
|
||||
- Spot-check: pick 5 known multi-observer packets, verify transmission + observations match
|
||||
|
||||
### Risk: LOW — additive only, old data preserved
|
||||
|
||||
---
|
||||
|
||||
## Milestone 2: Dual-Write Ingest
|
||||
|
||||
**Goal**: New packets written to both old and new tables. Read path unchanged. Zero downtime.
|
||||
|
||||
### Tasks
|
||||
1. **Update `db.js` `insertPacket()`**:
|
||||
- On new packet: check if `transmissions` row exists for hash
|
||||
- If not: INSERT into `transmissions`, get id
|
||||
- If yes: UPDATE `first_seen` if this timestamp is earlier
|
||||
- INSERT into `observations` with transmission_id
|
||||
- **Still also write to old `packets` table** (dual-write for safety)
|
||||
|
||||
2. **Update `packet-store.js` `insert()`**: Mirror the dual-write in memory model
|
||||
- Maintain both old flat array AND new `byTransmission` Map
|
||||
|
||||
### Validation
|
||||
- Send test packets, verify they appear in both old and new tables
|
||||
- Verify multi-observer packet creates 1 transmission + N observations
|
||||
|
||||
### Risk: LOW — old read path still works as fallback
|
||||
|
||||
---
|
||||
|
||||
## Milestone 3: In-Memory Store Restructure
|
||||
|
||||
**Goal**: `packet-store.js` switches from flat packet array to transmission-centric model.
|
||||
|
||||
### Tasks
|
||||
1. **New in-memory data model**:
|
||||
```
|
||||
transmissions: Map<hash, {id, raw_hex, hash, first_seen, payload_type, decoded_json, observations: []}>
|
||||
```
|
||||
Each observation: `{id, observer_id, observer_name, snr, rssi, path_json, timestamp}`
|
||||
|
||||
2. **Update indexes**:
|
||||
- `byHash`: hash → transmission object (1:1 instead of 1:N)
|
||||
- `byObserver`: observer_id → [observation references]
|
||||
- `byNode`: pubkey → [transmission references] (deduped!)
|
||||
- `byId`: observation.id → observation (for backward compat with packet detail links)
|
||||
|
||||
3. **Update `load()`**: Read from `transmissions` JOIN `observations` instead of `packets`
|
||||
|
||||
4. **Update query methods**:
|
||||
- `findPackets()` — returns transmissions by default, with `.observations` attached
|
||||
- `findPacketsForNode()` — returns transmissions where node appears in ANY observation's path/decoded_json
|
||||
- `getSiblings()` — becomes `getObservations(hash)` — trivial, just return `transmission.observations`
|
||||
- `countForNode()` — returns `{transmissions: N, observations: M}`
|
||||
|
||||
### Validation
|
||||
- All existing API endpoints return valid data
|
||||
- Packet counts decrease (correctly!) for multi-observer nodes
|
||||
- `/api/perf` shows no regression
|
||||
|
||||
### Risk: MEDIUM — core read path changes. Test thoroughly.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 4: API Response Changes
|
||||
|
||||
**Goal**: APIs return deduped data with observation counts.
|
||||
|
||||
### Tasks
|
||||
1. **`GET /api/packets`**:
|
||||
- Default: return transmissions (1 row per unique packet)
|
||||
- Each transmission includes `observation_count` and optionally `observations[]`
|
||||
- `?expand=observations` to include full observation list
|
||||
- `?groupByHash` becomes the default behavior (deprecate param)
|
||||
- Preserve `observer` filter: return transmissions where at least one observation matches
|
||||
|
||||
2. **`GET /api/nodes/:pubkey/health`**:
|
||||
- `stats.totalPackets` → `stats.totalTransmissions` (distinct hashes)
|
||||
- Add `stats.totalObservations` (old count, for reference)
|
||||
- `recentPackets` → returns transmissions with observation_count
|
||||
|
||||
3. **`GET /api/nodes/bulk-health`**: Same changes as health
|
||||
|
||||
4. **`GET /api/nodes/network-status`**: Use transmission counts
|
||||
|
||||
5. **`GET /api/nodes/:pubkey/analytics`**: All throughput charts use transmission counts
|
||||
|
||||
6. **WebSocket broadcast**: Include `observation_count` when sibling observations exist for same hash
|
||||
|
||||
### Backward Compatibility
|
||||
- Add `?legacy=1` param that returns old-style flat observations (for any external consumers)
|
||||
- Include both `totalTransmissions` and `totalObservations` in health responses during transition
|
||||
|
||||
### Risk: MEDIUM — frontend expects certain shapes. May need coordinated deploy with Milestone 5.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 5: Frontend Updates
|
||||
|
||||
**Goal**: UI shows correct counts and leverages observation data.
|
||||
|
||||
### Tasks
|
||||
1. **Packets page**:
|
||||
- Default view shows transmissions (already has groupByHash mode — make it default)
|
||||
- Expand row to see individual observations with their paths/SNR/RSSI
|
||||
- Badge: "×3 observers" on grouped rows
|
||||
|
||||
2. **Node detail panel** (nodes.js + live.js):
|
||||
- Show "X transmissions" not "X packets"
|
||||
- Or "X packets (seen Y times)" to show both
|
||||
|
||||
3. **Home page**: Network stats use transmission counts
|
||||
|
||||
4. **Node analytics**: Throughput charts use transmissions
|
||||
|
||||
5. **Observer detail**: Keep observation counts (correct metric for observers)
|
||||
|
||||
6. **Analytics page**: Topology/RF analysis uses all observations (SNR per observation is valid data)
|
||||
|
||||
### Risk: LOW-MEDIUM — mostly display changes
|
||||
|
||||
---
|
||||
|
||||
## Milestone 6: Cleanup
|
||||
|
||||
**Goal**: Remove dual-write, drop old table, clean up.
|
||||
|
||||
### Tasks
|
||||
1. Remove dual-write from `insertPacket()`
|
||||
2. Drop `packets_backup` table (after confirming everything works for 1+ week)
|
||||
3. Remove `?legacy=1` support if unused
|
||||
4. Update DEDUP-DESIGN.md → mark as complete
|
||||
5. VACUUM the database
|
||||
6. Tag release (v2.3.0?)
|
||||
|
||||
### Risk: LOW — cleanup only, all functional changes already proven
|
||||
|
||||
---
|
||||
|
||||
## Estimated Scope
|
||||
|
||||
| Milestone | Files Modified | Complexity | Can Deploy Independently? |
|
||||
|-----------|---------------|------------|--------------------------|
|
||||
| 1. Schema Migration | db.js, new script | Low | Yes — additive only |
|
||||
| 2. Dual-Write | db.js, packet-store.js | Low | Yes — old reads unchanged |
|
||||
| 3. Memory Store | packet-store.js | Medium | No — must deploy with M4 |
|
||||
| 4. API Changes | server.js, db.js | Medium | No — must deploy with M5 |
|
||||
| 5. Frontend | 8+ public/*.js files | Medium | No — must deploy with M4 |
|
||||
| 6. Cleanup | db.js, server.js | Low | Yes — after bake period |
|
||||
|
||||
**Milestones 1-2**: Safe to deploy independently, no user-visible changes.
|
||||
**Milestones 3-5**: Must ship together (API shape changes + frontend expects new shape).
|
||||
**Milestone 6**: Ship after 1 week bake.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Table naming**: `transmissions` + `observations`? Or keep `packets` + add `observations`? The word "transmission" is more accurate but "packet" is what the whole UI calls them.
|
||||
2. **Packet detail URLs**: Currently `#/packet/123` uses the observation ID. Keep observation IDs as the URL key? Or switch to hash?
|
||||
3. **Path dedup in paths table**: The `paths` table also has per-observation entries. Normalize that too, or leave as-is?
|
||||
4. **Migration on prod**: Run migration script before deploying new code, or make new code handle both old and new schema?
|
||||
@@ -9,7 +9,7 @@ COPY package.json package-lock.json ./
|
||||
RUN npm ci --production
|
||||
|
||||
# Copy application
|
||||
COPY *.js config.example.json ./
|
||||
COPY *.js config.example.json channel-rainbow.json ./
|
||||
COPY public/ ./public/
|
||||
|
||||
# Supervisor + Mosquitto + Caddy config
|
||||
|
||||
@@ -148,6 +148,10 @@ Edit `config.json`:
|
||||
```json
|
||||
{
|
||||
"port": 3000,
|
||||
"https": {
|
||||
"cert": "/path/to/cert.pem",
|
||||
"key": "/path/to/key.pem"
|
||||
},
|
||||
"mqtt": {
|
||||
"broker": "mqtt://localhost:1883",
|
||||
"topic": "meshcore/+/+/packets"
|
||||
@@ -178,6 +182,7 @@ Edit `config.json`:
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `port` | HTTP server port (default: 3000) |
|
||||
| `https.cert` / `https.key` | Optional PEM cert/key paths to enable native HTTPS (falls back to HTTP if omitted or unreadable) |
|
||||
| `mqtt.broker` | Local MQTT broker URL. Set to `""` to disable |
|
||||
| `mqtt.topic` | MQTT topic pattern for packet ingestion |
|
||||
| `mqttSources` | Array of external MQTT broker connections (optional) |
|
||||
|
||||
298
channel-rainbow.json
Normal file
298
channel-rainbow.json
Normal file
@@ -0,0 +1,298 @@
|
||||
{
|
||||
"#LongFast": "2cc3d22840e086105ad73443da2cacb8",
|
||||
"#MediumSlow": "99aa7084b6312841eb9b79b3a146bea4",
|
||||
"#ShortFast": "18267412b697cb98344c4a44b044d04d",
|
||||
"#ShortSlow": "8dffe23f9ed28b7d617fc587bdb19ec0",
|
||||
"#LongSlow": "7f8722cce459fc6d452db4f5be59ba5e",
|
||||
"#MediumFast": "7a5d6b6c3977df0e9a0929cd6fe98f5f",
|
||||
"#LongModerate": "ca954bbfd33831fa3a2bb7018d3ab654",
|
||||
"#ShortTurbo": "efe09f21c232292838d5c657a0ecb814",
|
||||
"#test": "9cd8fcf22a47333b591d96a2b848b73f",
|
||||
"#public": "8b4b705b080c0d943b1c80f6b3ef6b6d",
|
||||
"#general": "4c49f3f24629f5ee4ad5b3965db47985",
|
||||
"#chat": "d0bdd6d71538138ed979eec00d98ad97",
|
||||
"#local": "d2d35fa76be9875ed254db80397483a5",
|
||||
"#emergency": "e1ad578d25108e344808f30dfdaaf926",
|
||||
"#help": "dcc67fae2067046832af7b2b0b743165",
|
||||
"#info": "ce51a275a0a0507c43d1651d78292320",
|
||||
"#news": "ecadb1a7d803db8958bea1302ca6e8be",
|
||||
"#weather": "88f502554fee92a1625cfb311546e7cb",
|
||||
"#admin": "889334b7e486938c776dbdde120da9de",
|
||||
"#mod": "8e238c8f71e508c849fa1743783359c9",
|
||||
"#ops": "3b644de377c32c78793605a25aa915bf",
|
||||
"#dev": "d41bcba61e9dca7177c7b8533d23a0bc",
|
||||
"#debug": "fd7a60ed4796efcd1965c2de466105cb",
|
||||
"#bot": "eb50a1bcb3e4e5d7bf69a57c9dada211",
|
||||
"#bots": "0d24f5830b449668b8c221759b6c50d2",
|
||||
"#sf": "a32c1fcfda0def959c305e4cd803def1",
|
||||
"#bayarea": "7f9a5fd3070ad14e337ba100ca53a89b",
|
||||
"#sfo": "1ecce5970716c9415b0411bf190944b1",
|
||||
"#oakland": "c5a2f1d9f4433d041881447bf443084b",
|
||||
"#sanjose": "ce964c28170b1c043d06073e6fcd83a7",
|
||||
"#socal": "f4018307615ac79d2e5ef17bb44654d4",
|
||||
"#la": "21349a74e68588be435f33abed117d84",
|
||||
"#losangeles": "3dd9373dcea0294bd05ab067cc58e9d4",
|
||||
"#sandiego": "08623bbd90a96ecdc1f5c34e7292b35f",
|
||||
"#sacramento": "a6d6927f0b48762cf1346e2ae95cec14",
|
||||
"#nyc": "6e6554655f84ca26fbc09d81d15d6b96",
|
||||
"#newyork": "82a78024dd7edf6c9be298c919632e25",
|
||||
"#brooklyn": "bcc6e13acd87570dcee4d5d87ac711e6",
|
||||
"#manhattan": "56c90afc93b5bc55d1e3bdb1003dc2ef",
|
||||
"#queens": "55ff7df317b63d269d878e51d125ced3",
|
||||
"#seattle": "ef627a9bbbb549347fdb76bf0cd3bc14",
|
||||
"#portland": "45c6bc719c15b9fb809f48f594359877",
|
||||
"#pdx": "e75d6c892ff4d085e66701548c97acec",
|
||||
"#denver": "b24355a0d22ed2bf393ec530d75810b4",
|
||||
"#boulder": "eaa379d95ac9bbf857f499019ed0e8a5",
|
||||
"#colorado": "ef61c9e5a3286053746f7603044bcb08",
|
||||
"#austin": "b2e6f9af95d959734d71cdf90ca62533",
|
||||
"#dallas": "5b2efa4e2ad0a2b83c5486ca4dd244de",
|
||||
"#houston": "c001fbacad2d97676395ca37e2576345",
|
||||
"#texas": "c4a214e133de5e9ad276f99fdbd7216f",
|
||||
"#dfw": "5b7dc809ba579affccab5462c537244e",
|
||||
"#chicago": "c1c289b131e5222370cbc2048445844b",
|
||||
"#detroit": "bd01f26bf7d8c90952753157a41c61ac",
|
||||
"#minneapolis": "3283cca82b7b0ac50e8014c344cb8a86",
|
||||
"#stlouis": "f366422991a19e745f65096e59b43d51",
|
||||
"#boston": "9587d847a7208da684c89cc1f525bc03",
|
||||
"#philly": "9ff9182dd800e0be620dead724cbdf88",
|
||||
"#philadelphia": "963ad5382e8d910ce0872958c5b36e6a",
|
||||
"#dc": "0f3aa71fed514f5c16ebaf265ef05b2e",
|
||||
"#washingtondc": "0067d451cc79f26bc9924b3fc53f28d7",
|
||||
"#baltimore": "c5e649cacdc8fe661d5910d00c7c95ac",
|
||||
"#atlanta": "3c8f15665f99a349a97427e7c312ee0e",
|
||||
"#miami": "d81c566a5d337d588ebd250df6fc1b63",
|
||||
"#tampa": "8d10508c39d5d8e6c5e3e8fab41d1c09",
|
||||
"#orlando": "3553bdbd9b3a54da760624a27dcda156",
|
||||
"#florida": "c44bd74eac2c81dcb6dfb217727b05cd",
|
||||
"#phoenix": "027850d9410fa98809819c96644ec04c",
|
||||
"#tucson": "cf989ccea881cf5ddcf40d87bbfc441e",
|
||||
"#arizona": "8017183d8f9c01b660cd3663b8972e9d",
|
||||
"#vegas": "d45435b6467e7ded33c28f6796bf5183",
|
||||
"#lasvegas": "b82b823d6dcc845ead47cee8cfa758b3",
|
||||
"#nevada": "9dc06c13ed0875b4a1acd545653fef33",
|
||||
"#hawaii": "3fd57495820328594e1641d14583faa6",
|
||||
"#honolulu": "81a4f1ae399448b8c10f8e761ba4e216",
|
||||
"#maui": "cd08692b0cfbecfc06248bf8b8f10463",
|
||||
"#alaska": "2a5841192b151422baec71537b0b5238",
|
||||
"#anchorage": "2d87885e753b3231d33fd57dc53b5d69",
|
||||
"#london": "9881d2b7ab9105a41a8d0f6ba449447e",
|
||||
"#uk": "22b2eed34b5cc429ce1dc5e88635ff84",
|
||||
"#berlin": "8bffd7b0bf481d92afc625e409b88a16",
|
||||
"#germany": "0a834b4687fd4e09f72f6eeb3191ee25",
|
||||
"#paris": "d0fc2b1ec400eb8669010ce0311a00fb",
|
||||
"#france": "edcb362b38e74b99025a7e551d925d20",
|
||||
"#amsterdam": "d768f5a0aa65f8c54e4ea521bd49eb4f",
|
||||
"#netherlands": "cfc0a6c4004324e8adc99dfec1501943",
|
||||
"#tokyo": "c574cca64e441dc3a414ef8047e8054d",
|
||||
"#japan": "6c3db58db1c49d7e974971f675a66c89",
|
||||
"#sydney": "57fe5284a5b905835193868d9bdbe1e9",
|
||||
"#australia": "eadb84fa1da64c44b77c40fd11b9d78e",
|
||||
"#melbourne": "4d73731d9450ccb9673eb923c0f40af2",
|
||||
"#brisbane": "e4bd09784621decac600d0d4d857e3b2",
|
||||
"#toronto": "e0d3774dd1da4dc5c55d8bd731555334",
|
||||
"#vancouver": "16d6034be448ab86d11858cfa4c57c9c",
|
||||
"#canada": "8373bb1055f34164c6dd2663927cb6d9",
|
||||
"#montreal": "0c4c03b5fbea5b80f89e2a2a16ed3f40",
|
||||
"#calgary": "bbbb1ad23fe1cdd7739667418204a57d",
|
||||
"#mexico": "1e3806b6eccf8114ff7fc27ed8f84b0b",
|
||||
"#cdmx": "042268f5d791d342d8c50c065ef0c50b",
|
||||
"#brasil": "47d6242d2c1dd0f1e0eee4f0df64b2c1",
|
||||
"#brazil": "aac6e31471f27feac8da78793bed9690",
|
||||
"#ham": "5270db3979da687fa133fec6684cd952",
|
||||
"#radio": "266f225baced1b2a868dcc8e9c69a304",
|
||||
"#mesh": "5b664cde0b08b220612113db980650f3",
|
||||
"#lora": "0749ea267c6be7b54ca1dbbae7dba0aa",
|
||||
"#meshtastic": "73a2e13ff0dea9ed19b24b2ab753650f",
|
||||
"#meshcore": "2fa78a5aef618e7c2a78f0ab5c8869b3",
|
||||
"#offgrid": "aaa26662bebf122262692d0bca61dcef",
|
||||
"#prepper": "1eec1a7df7080a392cb490473c4a9920",
|
||||
"#preppers": "4877173813de9668b9ef33adbc1b8f37",
|
||||
"#survival": "e1f465ea51df09fc901389758f1c5f01",
|
||||
"#shtf": "9321638017bd7f42ca4468726cd06893",
|
||||
"#emcomm": "a9a49340642dfc4c562d7849b7c8a258",
|
||||
"#ares": "44419e394cde859e45710f288db939a8",
|
||||
"#cert": "828d37872695b8b47e537164fb1570cf",
|
||||
"#skywarn": "46e8175d5a3b373985eb471a3ad479f8",
|
||||
"#hike": "a8f9964431372ed34db9088d03362f6f",
|
||||
"#hiking": "2370a013053e384e5f18918bc2b26baf",
|
||||
"#camping": "c011b7c2abf33748cd9bd5a78c2b4955",
|
||||
"#outdoors": "b4c10e8ecfe10ef66cae6299ae29d488",
|
||||
"#trail": "9b7f5ade7e2bff2eaaeebfd0333401a5",
|
||||
"#bike": "22a682f2c0f3011aab4510c533278413",
|
||||
"#cycling": "795980fbfe059133a2fc47c2c210f127",
|
||||
"#wardrive": "4076c315c1ef385fa93f066027320fe5",
|
||||
"#wardriving": "e3c26491e9cd321e3a6be50d57d54acf",
|
||||
"#security": "b7123ea6c2dcbf7332a198ddd6612da9",
|
||||
"#infosec": "364aa6797508df1cf248c8983ec968bd",
|
||||
"#cyber": "bc435860170598cb9b1c7cc6938c8be5",
|
||||
"#hacker": "98010d08107a5bc0b7f41ce3c93cba99",
|
||||
"#hackers": "bb6c2edeb9a25b4f77398a687acfdb85",
|
||||
"#maker": "04cf78295deab8c76fa3236f8a87612e",
|
||||
"#makers": "4d8febe1979910912d7f8d11ca8ce689",
|
||||
"#diy": "2947bea0668269732f29808d2b9c8fed",
|
||||
"#electronics": "34d8645bda0ba7e4a7c78a1f9f3ed1ac",
|
||||
"#arduino": "ce8c596922eff9274f3b1e19ad296754",
|
||||
"#raspberry": "469bc476d8263b64b6f90ca868b04b91",
|
||||
"#esp32": "9f8aac4c48973b07bbdc47acbaea3e8e",
|
||||
"#linux": "02c74eda5d8ec8b9bb945049fb2f55c6",
|
||||
"#unix": "a4c8cd0f130f2f9801d1afa2d0a52a2f",
|
||||
"#tech": "5177b749eefbfab3c90a21b4e2518c5f",
|
||||
"#code": "2a8a567349cce15a48fd5d81709424f4",
|
||||
"#coding": "becdff6841b6f36610e97ac89c3a40ea",
|
||||
"#programming": "bce55c792ec60d79530a2eec9a8ede14",
|
||||
"#yo": "51f93a1e79f96333fe5d0c8eb3bed7c3",
|
||||
"#sup": "153ec8634ac55727e006e37c39b29f16",
|
||||
"#hello": "dc9fe3652402447479779b06609a22a5",
|
||||
"#hi": "e411034bd14b17b5e39a76b5a5b4f348",
|
||||
"#hey": "3972b5f0fe9a438f260ffc1d125eefdb",
|
||||
"#random": "bda30db2910b90b1bc5a608a1b5f0ee4",
|
||||
"#offtopic": "4bfd513d655d6e435bd8f4d6b863e500",
|
||||
"#memes": "5b36bb8722a8c1741127266299439cdd",
|
||||
"#fun": "ecfceab52a3d730051d2ce6adea30f78",
|
||||
"#music": "b025ab29b0a5f56fb68a474741d7a2cc",
|
||||
"#gaming": "3802c79121f195ea50ad9ab2aa2c402a",
|
||||
"#games": "ddffe2cfbf037caed279d02c41b74f5f",
|
||||
"#sports": "e8ee81f3aabf105d9ba2d2d4bd94fe4a",
|
||||
"#food": "7b4f27d6b5bbf5f8eb3bfc6f43770fdd",
|
||||
"#beer": "8fbe47b032102949554ac78fcd583560",
|
||||
"#coffee": "82cd4bd9e7dda8cae0854281246cc64f",
|
||||
"#queer": "5754476f162d93bbee3de0efba136860",
|
||||
"#lgbtq": "7d71d54a2bb4bbd7352322a59126d7fb",
|
||||
"#pride": "c732b12b15a5bca3cff2e39b7480baca",
|
||||
"#bookclub": "b803ab3fbb867737ab5b7d32914d7e67",
|
||||
"#books": "b2fdc9c9313dad4515527cade7d67021",
|
||||
"#reading": "46d531186b6148726a97a62324f8a356",
|
||||
"#film": "35ed875bfa83f29298e81f83c3c56c1e",
|
||||
"#movies": "15cea29e9e62887c767cabec8c601ebe",
|
||||
"#dogs": "e956e2b054b795c129a5daab4aad0be1",
|
||||
"#cats": "4aeaf541d243ea9f84abc406b7eba360",
|
||||
"#pets": "6f36ecba946c2eba3a7a9c694c53f23a",
|
||||
"#1": "0b0fa0b2280a09639e2059e56c8fa932",
|
||||
"#2": "b6b52c0e41dd6e18a31636deb586175b",
|
||||
"#3": "ea46599a238699be9409316928670559",
|
||||
"#4": "7e3cfd9c828a75671a34898112caa743",
|
||||
"#5": "7aff87c72ca0f13d288b3418c89f67e9",
|
||||
"#42": "2bccaf40951009e4203e2065b2a4bde0",
|
||||
"#69": "0285b036b9837b5babac54668e623ce0",
|
||||
"#420": "d4346dd20f9a85256cff48f33f46de0a",
|
||||
"#1337": "817d01d43c5960c1ceaf9a2467182675",
|
||||
"#a": "302d59c4f9e75750166105ea1d8b3673",
|
||||
"#b": "dd883973c3c017ed51c9e10fba7bca0b",
|
||||
"#c": "3732c64f873466d50e0badb3f8d79faf",
|
||||
"#aa": "e147f36926b7b509af9b41b65304dc30",
|
||||
"#ab": "7ee8192c80507041253e255dcc7e6f87",
|
||||
"#abc": "00e16e1d31c0ba2b3b3d17583bb2ac3b",
|
||||
"#norcal": "f2438510715f5d9d55eb4370664330f5",
|
||||
"#nocal": "819836f0049f5dca9596cd681d0cbab8",
|
||||
"#bay": "80e5fadb907564764eb09d2667a02638",
|
||||
"#eastbay": "4c2e48f600e4952346441278ac363432",
|
||||
"#southbay": "3c9e372b38917334d1091419f32bed8a",
|
||||
"#northbay": "8dfb7427cc1e5abab25bc16e8ae4373c",
|
||||
"#peninsula": "7f2ce5480359431d2f3ca259bf8bef68",
|
||||
"#marin": "a084727e9d2d2afcc73f49055c6f7764",
|
||||
"#pnw": "98059014c708581fbf0a398cfe8a486d",
|
||||
"#pacnw": "49cc714305cf0a61817a01114414d490",
|
||||
"#cascadia": "1313c4078af5c36040bec10115c04806",
|
||||
"#midwest": "cf7910dacee35da8b90da21a0e37fea7",
|
||||
"#northeast": "3b1ca0ae6003193eb9f91984eefef5dc",
|
||||
"#southeast": "7b4392ed3c3fdde98cdb167c2f0f2c8b",
|
||||
"#southwest": "e17c45726653035f7a90a6b57b5a0d57",
|
||||
"#socalmesh": "df9e74198a7c334964f18f200a065e33",
|
||||
"#sfmesh": "89454fcff893b5a2ecc16d886e9cf3b3",
|
||||
"#nycmesh": "021be2e194650cc5d3ea77618eea817f",
|
||||
"#atlmesh": "50b8266c71b3d3ee0253d462b34f6b2b",
|
||||
"#wx": "472dd8595b8fd0ab542b3e86a379a620",
|
||||
"#fire": "3d74a070077293ab66baf3aa724349ff",
|
||||
"#earthquake": "0c7082c04a1a90502a5f32fc6a9f6524",
|
||||
"#flood": "f2a0fd0abe4c9fc6865b5f8eebf319d6",
|
||||
"#tsunami": "153ddc83452935d8486ab3d34dd6d313",
|
||||
"#storm": "113761b9e31a5c30e0ee4fc78dc7310b",
|
||||
"#alert": "678c7d2e08c019e113ace03eaaa128ad",
|
||||
"#alerts": "b8212240d8b433b54db46906738e2094",
|
||||
"#sos": "9ed2c78bcd68ac7ce2a2fc3bb4045114",
|
||||
"#rescue": "8fefdc46995fd86cf1265a96e25e9be1",
|
||||
"#missing": "bb627c3e98fb103e54b203a11d2c1a8c",
|
||||
"#evac": "c20a9bf0eabefaf3dd3553bb5df19b61",
|
||||
"#shelter": "b11ebee14de380147b2f0b613c82ee84",
|
||||
"#default": "66e7fd8b7b4caa5dc98e752d43044d30",
|
||||
"#main": "512ba51f98c27b93cd2ff6fbc2c0fad1",
|
||||
"#primary": "3e417c7ce555fa7dcb705a94cbb358e4",
|
||||
"#secondary": "69abb13534f87ebfedc0d92797da1fbe",
|
||||
"#backup": "1fa50c46f15c60363567cae7982b8394",
|
||||
"#private": "bd072d2fbd62a89db08b2a9e6976cc36",
|
||||
"#secret": "be657c0527e122bf93bc735999cd7e0d",
|
||||
"#hidden": "f78143af9f1168c243729b1bf6bb3235",
|
||||
"#invite": "d46e531ee7d3b591fcf2dfc9e23e63e1",
|
||||
"#repeater": "289991a3077903263f2d31493887c651",
|
||||
"#repeaters": "89db441e2814dccf0dbd2e8cc5f501a3",
|
||||
"#node": "85cdc068443a7bf5b9435423c40dbefd",
|
||||
"#nodes": "d2b5d06216710d4dbef3de9d08168a53",
|
||||
"#gateway": "73feacb0c27f83b3d2db143823efb891",
|
||||
"#server": "11c4e843fc066c8bfe01719cfca1fb1e",
|
||||
"#gisborne": "d8b45a1eb52d0bd45655d5f2a72d571f",
|
||||
"#wellington": "c2da57d74f78996ad71bbe3e22446f16",
|
||||
"#auckland": "7c6f1c71a5a3d6823a1d7bfd2a349ac8",
|
||||
"#nz": "eb87ee8817ba71315ac7be9c733b523a",
|
||||
"#newzealand": "1870a961e4aa06f62b02b835efcacb71",
|
||||
"#spain": "49217e19fa0d5c28bd02fd6b688dd11d",
|
||||
"#madrid": "8886480d4b99f6882328d9068d0c6235",
|
||||
"#barcelona": "919a4a5d9522320ca9c95dceb92a5544",
|
||||
"#italy": "f8d4dd36f6b9476eb4cec18a1536b17d",
|
||||
"#rome": "4c3729aca56d948088bd25750e8d7d33",
|
||||
"#milan": "71cd9f729336b7a1110f77bb93c43e9e",
|
||||
"#sweden": "d6bcdf00baf5d2d981846d2b47ba5b42",
|
||||
"#stockholm": "889483e6b54ca5bf8cbb2af23e1ab7f0",
|
||||
"#norway": "0ed3e16f327a787d5ff4c4496bb4c4a9",
|
||||
"#oslo": "1a06f287bc45a8cf14a304f898cc1fea",
|
||||
"#finland": "cc012bb6718f447824c8ba7cd81b7fbd",
|
||||
"#helsinki": "6bd215dc2f6f8833a309eba8d4ba57d7",
|
||||
"#denmark": "7bb81ff9d3eb29f091d1d64e044b2a79",
|
||||
"#copenhagen": "76c1331a0d13d081988a379c60ad59bb",
|
||||
"#poland": "e63d27631c0f74aca88a6b91efcc7067",
|
||||
"#warsaw": "86afa156c7f0567c97ea03df07482888",
|
||||
"#czech": "0a15067478a2b8e74177c8fc68e4001e",
|
||||
"#prague": "564cb31b3895393f22f0f50354389334",
|
||||
"#austria": "faaa5ef01081222e319a8205357321f4",
|
||||
"#vienna": "8e52cd2b9ff13fb0030fd41714edc95e",
|
||||
"#switzerland": "8ad1ce57ad257627090ed28413c1f0b7",
|
||||
"#zurich": "95a7261009cdcb13d22e8f8d532f3ba5",
|
||||
"#portugal": "11f13d9d06c892574a277337967a7267",
|
||||
"#lisbon": "6c49a07fd953c5856df538d5dce0b19e",
|
||||
"#ireland": "1b2a12acc5db1517d9d407946756b1da",
|
||||
"#dublin": "8792638977132bc05a1f72d6bb913694",
|
||||
"#scotland": "f4fce403cfd56f7089920d065718f29c",
|
||||
"#edinburgh": "72d70a8e87b0e7f8072239d68ffccc9e",
|
||||
"#wales": "809573d8134fa262d284400a788f63d9",
|
||||
"#india": "bedb569c4d55038e801985e87bc311cb",
|
||||
"#mumbai": "408b4cecfb253c8150cadd5da8925b1f",
|
||||
"#delhi": "4974162f580211f17187d1a16cab2514",
|
||||
"#bangalore": "f87f1fcf72618fbb5d36642847859df0",
|
||||
"#korea": "38ea37fe7de4691145f8e200a3fc6976",
|
||||
"#seoul": "d6e3685ad1ce9d943c34594321eb3d75",
|
||||
"#taiwan": "489ff602625eb18ae5f457fb70e149cd",
|
||||
"#taipei": "257f2ea07b93df20b8ef8a69459cc541",
|
||||
"#singapore": "524771d953b40e8880e00d7250f02c42",
|
||||
"#malaysia": "a51ef9684d30551eb7fe4faa00c4dd64",
|
||||
"#thailand": "6ddc1b15fd53b35a8c1a7e8bb720d5ac",
|
||||
"#bangkok": "68b4467f7e1a99705143a6a5ebcfb8e8",
|
||||
"#vietnam": "9a6514b712cbc8a1be0663591c6a6e13",
|
||||
"#indonesia": "2be2d4470d8cc641ce69dfe6497a2842",
|
||||
"#jakarta": "5b931a38c29a24ec8395c23421248138",
|
||||
"#philippines": "74d160fb0ad7867295730d41351dd21a",
|
||||
"#manila": "48a840132b292a2dcde0ef0d10c3149b",
|
||||
"#southafrica": "35bb5bcec03c0c7ba256bdc948108a1c",
|
||||
"#capetown": "a16101e1fa482e43dcb4b35ab836b3f3",
|
||||
"#nairobi": "44cf6c94cdcb61d576c8855935997260",
|
||||
"#kenya": "5bde28964c3008a6741f593d3b70c78e",
|
||||
"#nigeria": "9a20897b3b223ee02ba9eebc43ac2300",
|
||||
"#lagos": "c1d000aa764a45ba0d992d0289137991",
|
||||
"#argentina": "22304ee269f9623972776a4d1d306afd",
|
||||
"#buenosaires": "43ece797139ce4051cf62568a6a28c2b",
|
||||
"#chile": "15f352f255947e485b845652791f3354",
|
||||
"#santiago": "9d7f9df716281124aa16d27a45b2ff5f",
|
||||
"#colombia": "bea223a8c1d13ed9638ee000ea3a6aca",
|
||||
"#bogota": "6d0864985b64350ce4cbfebf4979e970",
|
||||
"#peru": "7e6fc347bf29a4c128ac3156865bd521",
|
||||
"#lima": "5f167ce354eca08ab742463df10ef255"
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"port": 3000,
|
||||
"apiKey": "your-secret-api-key-here",
|
||||
"https": {
|
||||
"cert": "/path/to/cert.pem",
|
||||
"key": "/path/to/key.pem"
|
||||
},
|
||||
"mqtt": {
|
||||
"broker": "mqtt://localhost:1883",
|
||||
"topic": "meshcore/+/+/packets"
|
||||
@@ -34,17 +39,27 @@
|
||||
}
|
||||
],
|
||||
"channelKeys": {
|
||||
"public": "8b3387e9c5cdea6ac9e5edbaa115cd72",
|
||||
"#test": "9cd8fcf22a47333b591d96a2b848b73f",
|
||||
"#sf": "a32c1fcfda0def959c305e4cd803def1",
|
||||
"#wardrive": "4076c315c1ef385fa93f066027320fe5",
|
||||
"#yo": "51f93a1e79f96333fe5d0c8eb3bed7c3",
|
||||
"#bot": "eb50a1bcb3e4e5d7bf69a57c9dada211",
|
||||
"#queer": "5754476f162d93bbee3de0efba136860",
|
||||
"#bookclub": "b803ab3fbb867737ab5b7d32914d7e67",
|
||||
"#shtf": "9321638017bd7f42ca4468726cd06893"
|
||||
"public": "8b3387e9c5cdea6ac9e5edbaa115cd72"
|
||||
},
|
||||
"hashChannels": [
|
||||
"#LongFast",
|
||||
"#test",
|
||||
"#sf",
|
||||
"#wardrive",
|
||||
"#yo",
|
||||
"#bot",
|
||||
"#queer",
|
||||
"#bookclub",
|
||||
"#shtf"
|
||||
],
|
||||
"defaultRegion": "SJC",
|
||||
"mapDefaults": {
|
||||
"center": [
|
||||
37.45,
|
||||
-122.0
|
||||
],
|
||||
"zoom": 9
|
||||
},
|
||||
"regions": {
|
||||
"SJC": "San Jose, US",
|
||||
"SFO": "San Francisco, US",
|
||||
@@ -72,6 +87,10 @@
|
||||
"invalidationDebounce": 30,
|
||||
"_comment": "All values in seconds. Server uses these directly. Client fetches via /api/config/cache."
|
||||
},
|
||||
"liveMap": {
|
||||
"propagationBufferMs": 5000,
|
||||
"_comment": "How long (ms) to buffer incoming observations of the same packet before animating. Mesh packets propagate through multiple paths and arrive at different observers over several seconds. This window collects all observations of a single transmission so the live map can animate them simultaneously as one realistic propagation event. Set higher for wide meshes with many observers, lower for snappier animations. 5000ms captures ~95% of observations for a typical mesh."
|
||||
},
|
||||
"packetStore": {
|
||||
"maxMemoryMB": 1024,
|
||||
"estimatedPacketBytes": 450,
|
||||
|
||||
167
db.js
167
db.js
@@ -10,28 +10,21 @@ if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.pragma('wal_autocheckpoint = 0'); // Disable auto-checkpoint — manual checkpoint on timer to avoid random event loop spikes
|
||||
|
||||
// --- Migration: drop legacy tables (replaced by transmissions + observations in v2.3.0) ---
|
||||
// Drop paths first (has FK to packets)
|
||||
const legacyTables = ['paths', 'packets'];
|
||||
for (const t of legacyTables) {
|
||||
const exists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`).get(t);
|
||||
if (exists) {
|
||||
console.log(`[migration] Dropping legacy table: ${t}`);
|
||||
db.exec(`DROP TABLE IF EXISTS ${t}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Schema ---
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS packets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
observer_id TEXT,
|
||||
observer_name TEXT,
|
||||
direction TEXT,
|
||||
snr REAL,
|
||||
rssi REAL,
|
||||
score INTEGER,
|
||||
hash TEXT,
|
||||
route_type INTEGER,
|
||||
payload_type INTEGER,
|
||||
payload_version INTEGER,
|
||||
path_json TEXT,
|
||||
decoded_json TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
public_key TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
@@ -59,16 +52,6 @@ db.exec(`
|
||||
noise_floor INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paths (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
packet_id INTEGER REFERENCES packets(id),
|
||||
hop_index INTEGER,
|
||||
node_hash TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_packets_timestamp ON packets(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_packets_hash ON packets(hash);
|
||||
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);
|
||||
|
||||
@@ -106,6 +89,21 @@ db.exec(`
|
||||
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);
|
||||
DROP INDEX IF EXISTS idx_observations_dedup;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_observations_dedup ON observations(hash, observer_id, COALESCE(path_json, ''));
|
||||
|
||||
-- Clean up legacy duplicates (same hash+observer+path, keep lowest id)
|
||||
DELETE FROM observations WHERE id NOT IN (
|
||||
SELECT MIN(id) FROM observations GROUP BY hash, observer_id, COALESCE(path_json, '')
|
||||
);
|
||||
|
||||
CREATE VIEW IF NOT EXISTS packets_v AS
|
||||
SELECT o.id, t.raw_hex, o.timestamp, o.observer_id, o.observer_name,
|
||||
o.direction, o.snr, o.rssi, o.score, t.hash, t.route_type,
|
||||
t.payload_type, t.payload_version, o.path_json, t.decoded_json,
|
||||
t.created_at
|
||||
FROM observations o
|
||||
JOIN transmissions t ON t.id = o.transmission_id;
|
||||
`);
|
||||
|
||||
// --- Migrations for existing DBs ---
|
||||
@@ -118,13 +116,21 @@ for (const col of ['model', 'firmware', 'client_version', 'radio', 'battery_mv',
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cleanup corrupted nodes on startup ---
|
||||
// Remove nodes with obviously invalid data (short pubkeys, control chars in names, etc.)
|
||||
{
|
||||
const cleaned = db.prepare(`
|
||||
DELETE FROM nodes WHERE
|
||||
length(public_key) < 16
|
||||
OR public_key GLOB '*[^0-9a-fA-F]*'
|
||||
OR (lat IS NOT NULL AND (lat < -90 OR lat > 90))
|
||||
OR (lon IS NOT NULL AND (lon < -180 OR lon > 180))
|
||||
`).run();
|
||||
if (cleaned.changes > 0) console.log(`[cleanup] Removed ${cleaned.changes} corrupted node(s) from DB`);
|
||||
}
|
||||
|
||||
// --- Prepared statements ---
|
||||
const stmts = {
|
||||
insertPacket: db.prepare(`
|
||||
INSERT INTO packets (raw_hex, timestamp, observer_id, observer_name, direction, snr, rssi, score, hash, route_type, payload_type, payload_version, path_json, decoded_json)
|
||||
VALUES (@raw_hex, @timestamp, @observer_id, @observer_name, @direction, @snr, @rssi, @score, @hash, @route_type, @payload_type, @payload_version, @path_json, @decoded_json)
|
||||
`),
|
||||
insertPath: db.prepare(`INSERT INTO paths (packet_id, hop_index, node_hash) VALUES (?, ?, ?)`),
|
||||
upsertNode: db.prepare(`
|
||||
INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (@public_key, @name, @role, @lat, @lon, @last_seen, @first_seen, 1)
|
||||
@@ -167,18 +173,17 @@ const stmts = {
|
||||
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`),
|
||||
getPacket: db.prepare(`SELECT * FROM packets_v WHERE id = ?`),
|
||||
getNode: db.prepare(`SELECT * FROM nodes WHERE public_key = ?`),
|
||||
getRecentPacketsForNode: db.prepare(`
|
||||
SELECT * FROM packets WHERE decoded_json LIKE ? OR decoded_json LIKE ? OR decoded_json LIKE ? OR decoded_json LIKE ?
|
||||
SELECT * FROM packets_v WHERE decoded_json LIKE ? OR decoded_json LIKE ? OR decoded_json LIKE ? OR decoded_json LIKE ?
|
||||
ORDER BY timestamp DESC LIMIT 20
|
||||
`),
|
||||
getObservers: db.prepare(`SELECT * FROM observers ORDER BY last_seen DESC`),
|
||||
countPackets: db.prepare(`SELECT COUNT(*) as count FROM packets`),
|
||||
countPackets: db.prepare(`SELECT COUNT(*) as count FROM observations`),
|
||||
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 > ?`),
|
||||
countRecentPackets: db.prepare(`SELECT COUNT(*) as count FROM observations 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)
|
||||
@@ -186,33 +191,13 @@ const stmts = {
|
||||
`),
|
||||
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)
|
||||
INSERT OR IGNORE 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 ---
|
||||
|
||||
function insertPacket(data) {
|
||||
const d = {
|
||||
raw_hex: data.raw_hex,
|
||||
timestamp: data.timestamp || new Date().toISOString(),
|
||||
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,
|
||||
hash: data.hash || null,
|
||||
route_type: data.route_type ?? null,
|
||||
payload_type: data.payload_type ?? null,
|
||||
payload_version: data.payload_version ?? null,
|
||||
path_json: data.path_json || null,
|
||||
decoded_json: data.decoded_json || null,
|
||||
};
|
||||
return stmts.insertPacket.run(d).lastInsertRowid;
|
||||
}
|
||||
|
||||
function insertTransmission(data) {
|
||||
const hash = data.hash;
|
||||
if (!hash) return null; // Can't deduplicate without a hash
|
||||
@@ -256,15 +241,6 @@ function insertTransmission(data) {
|
||||
return { transmissionId, observationId: obsResult.lastInsertRowid };
|
||||
}
|
||||
|
||||
function insertPath(packetId, hops) {
|
||||
const tx = db.transaction((hops) => {
|
||||
for (let i = 0; i < hops.length; i++) {
|
||||
stmts.insertPath.run(packetId, i, hops[i]);
|
||||
}
|
||||
});
|
||||
tx(hops);
|
||||
}
|
||||
|
||||
function upsertNode(data) {
|
||||
const now = new Date().toISOString();
|
||||
stmts.upsertNode.run({
|
||||
@@ -322,15 +298,20 @@ function getPackets({ limit = 50, offset = 0, type, route, hash, since } = {}) {
|
||||
if (hash) { where.push('hash = @hash'); params.hash = hash; }
|
||||
if (since) { where.push('timestamp > @since'); params.since = since; }
|
||||
const clause = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||
const rows = db.prepare(`SELECT * FROM packets ${clause} ORDER BY timestamp DESC LIMIT @limit OFFSET @offset`).all({ ...params, limit, offset });
|
||||
const total = db.prepare(`SELECT COUNT(*) as count FROM packets ${clause}`).get(params).count;
|
||||
const rows = db.prepare(`SELECT * FROM packets_v ${clause} ORDER BY timestamp DESC LIMIT @limit OFFSET @offset`).all({ ...params, limit, offset });
|
||||
const total = db.prepare(`SELECT COUNT(*) as count FROM packets_v ${clause}`).get(params).count;
|
||||
return { rows, total };
|
||||
}
|
||||
|
||||
function getTransmission(id) {
|
||||
try {
|
||||
return db.prepare('SELECT * FROM transmissions WHERE id = ?').get(id) || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function getPacket(id) {
|
||||
const packet = stmts.getPacket.get(id);
|
||||
if (!packet) return null;
|
||||
packet.paths = stmts.getPathsForPacket.all(id);
|
||||
return packet;
|
||||
}
|
||||
|
||||
@@ -370,9 +351,9 @@ function getStats() {
|
||||
totalTransmissions = db.prepare('SELECT COUNT(*) as count FROM transmissions').get().count;
|
||||
} catch {}
|
||||
return {
|
||||
totalPackets: stmts.countPackets.get().count,
|
||||
totalPackets: totalTransmissions || stmts.countPackets.get().count,
|
||||
totalTransmissions,
|
||||
totalObservations: stmts.countPackets.get().count, // legacy packets = observations
|
||||
totalObservations: stmts.countPackets.get().count,
|
||||
totalNodes: stmts.countNodes.get().count,
|
||||
totalObservers: stmts.countObservers.get().count,
|
||||
packetsLastHour: stmts.countRecentPackets.get(oneHourAgo).count,
|
||||
@@ -386,7 +367,7 @@ function seed() {
|
||||
|
||||
upsertObserver({ id: 'obs-seed-001', name: 'Seed Observer', iata: 'UNK', last_seen: now, first_seen: now });
|
||||
|
||||
const pktId = insertPacket({
|
||||
insertTransmission({
|
||||
raw_hex: rawHex,
|
||||
timestamp: now,
|
||||
observer_id: 'obs-seed-001',
|
||||
@@ -403,8 +384,6 @@ function seed() {
|
||||
decoded_json: JSON.stringify({ type: 'ADVERT', name: 'Test Repeater', role: 'repeater', lat: 0, lon: 0 }),
|
||||
});
|
||||
|
||||
insertPath(pktId, ['A1B2', 'C3D4']);
|
||||
|
||||
upsertNode({
|
||||
public_key: 'seed-test-pubkey',
|
||||
name: 'Test Repeater',
|
||||
@@ -454,7 +433,7 @@ function getNodeHealth(pubkey) {
|
||||
const observers = db.prepare(`
|
||||
SELECT observer_id, observer_name,
|
||||
AVG(snr) as avgSnr, AVG(rssi) as avgRssi, COUNT(*) as packetCount
|
||||
FROM packets
|
||||
FROM packets_v
|
||||
WHERE ${whereClause} AND observer_id IS NOT NULL
|
||||
GROUP BY observer_id
|
||||
ORDER BY packetCount DESC
|
||||
@@ -462,20 +441,20 @@ function getNodeHealth(pubkey) {
|
||||
|
||||
// Stats
|
||||
const packetsToday = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM packets WHERE ${whereClause} AND timestamp > @since
|
||||
SELECT COUNT(*) as count FROM packets_v WHERE ${whereClause} AND timestamp > @since
|
||||
`).get({ ...params, since: todayISO }).count;
|
||||
|
||||
const avgStats = db.prepare(`
|
||||
SELECT AVG(snr) as avgSnr FROM packets WHERE ${whereClause}
|
||||
SELECT AVG(snr) as avgSnr FROM packets_v WHERE ${whereClause}
|
||||
`).get(params);
|
||||
|
||||
const lastHeard = db.prepare(`
|
||||
SELECT MAX(timestamp) as lastHeard FROM packets WHERE ${whereClause}
|
||||
SELECT MAX(timestamp) as lastHeard FROM packets_v WHERE ${whereClause}
|
||||
`).get(params).lastHeard;
|
||||
|
||||
// Avg hops from path_json
|
||||
const pathRows = db.prepare(`
|
||||
SELECT path_json FROM packets WHERE ${whereClause} AND path_json IS NOT NULL
|
||||
SELECT path_json FROM packets_v WHERE ${whereClause} AND path_json IS NOT NULL
|
||||
`).all(params);
|
||||
|
||||
let totalHops = 0, hopCount = 0;
|
||||
@@ -488,12 +467,12 @@ function getNodeHealth(pubkey) {
|
||||
const avgHops = hopCount > 0 ? Math.round(totalHops / hopCount) : 0;
|
||||
|
||||
const totalPackets = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM packets WHERE ${whereClause}
|
||||
SELECT COUNT(*) as count FROM packets_v WHERE ${whereClause}
|
||||
`).get(params).count;
|
||||
|
||||
// Recent 10 packets
|
||||
const recentPackets = db.prepare(`
|
||||
SELECT * FROM packets WHERE ${whereClause} ORDER BY timestamp DESC LIMIT 10
|
||||
SELECT * FROM packets_v WHERE ${whereClause} ORDER BY timestamp DESC LIMIT 10
|
||||
`).all(params);
|
||||
|
||||
return {
|
||||
@@ -524,31 +503,31 @@ function getNodeAnalytics(pubkey, days) {
|
||||
// Activity timeline
|
||||
const activityTimeline = db.prepare(`
|
||||
SELECT strftime('%Y-%m-%dT%H:00:00Z', timestamp) as bucket, COUNT(*) as count
|
||||
FROM packets WHERE ${timeWhere} GROUP BY bucket ORDER BY bucket
|
||||
FROM packets_v WHERE ${timeWhere} GROUP BY bucket ORDER BY bucket
|
||||
`).all(params);
|
||||
|
||||
// SNR trend
|
||||
const snrTrend = db.prepare(`
|
||||
SELECT timestamp, snr, rssi, observer_id, observer_name
|
||||
FROM packets WHERE ${timeWhere} AND snr IS NOT NULL ORDER BY timestamp
|
||||
FROM packets_v WHERE ${timeWhere} AND snr IS NOT NULL ORDER BY timestamp
|
||||
`).all(params);
|
||||
|
||||
// Packet type breakdown
|
||||
const packetTypeBreakdown = db.prepare(`
|
||||
SELECT payload_type, COUNT(*) as count FROM packets WHERE ${timeWhere} GROUP BY payload_type
|
||||
SELECT payload_type, COUNT(*) as count FROM packets_v WHERE ${timeWhere} GROUP BY payload_type
|
||||
`).all(params);
|
||||
|
||||
// Observer coverage
|
||||
const observerCoverage = db.prepare(`
|
||||
SELECT observer_id, observer_name, COUNT(*) as packetCount,
|
||||
AVG(snr) as avgSnr, AVG(rssi) as avgRssi, MIN(timestamp) as firstSeen, MAX(timestamp) as lastSeen
|
||||
FROM packets WHERE ${timeWhere} AND observer_id IS NOT NULL
|
||||
FROM packets_v WHERE ${timeWhere} AND observer_id IS NOT NULL
|
||||
GROUP BY observer_id ORDER BY packetCount DESC
|
||||
`).all(params);
|
||||
|
||||
// Hop distribution
|
||||
const pathRows = db.prepare(`
|
||||
SELECT path_json FROM packets WHERE ${timeWhere} AND path_json IS NOT NULL
|
||||
SELECT path_json FROM packets_v WHERE ${timeWhere} AND path_json IS NOT NULL
|
||||
`).all(params);
|
||||
|
||||
const hopCounts = {};
|
||||
@@ -570,7 +549,7 @@ function getNodeAnalytics(pubkey, days) {
|
||||
|
||||
// Peer interactions from decoded_json
|
||||
const decodedRows = db.prepare(`
|
||||
SELECT decoded_json, timestamp FROM packets WHERE ${timeWhere} AND decoded_json IS NOT NULL
|
||||
SELECT decoded_json, timestamp FROM packets_v WHERE ${timeWhere} AND decoded_json IS NOT NULL
|
||||
`).all(params);
|
||||
|
||||
const peerMap = {};
|
||||
@@ -596,11 +575,11 @@ function getNodeAnalytics(pubkey, days) {
|
||||
const uptimeHeatmap = db.prepare(`
|
||||
SELECT CAST(strftime('%w', timestamp) AS INTEGER) as dayOfWeek,
|
||||
CAST(strftime('%H', timestamp) AS INTEGER) as hour, COUNT(*) as count
|
||||
FROM packets WHERE ${timeWhere} GROUP BY dayOfWeek, hour
|
||||
FROM packets_v WHERE ${timeWhere} GROUP BY dayOfWeek, hour
|
||||
`).all(params);
|
||||
|
||||
// Computed stats
|
||||
const totalPackets = db.prepare(`SELECT COUNT(*) as count FROM packets WHERE ${timeWhere}`).get(params).count;
|
||||
const totalPackets = db.prepare(`SELECT COUNT(*) as count FROM packets_v WHERE ${timeWhere}`).get(params).count;
|
||||
const uniqueObservers = observerCoverage.length;
|
||||
const uniquePeers = peerInteractions.length;
|
||||
const avgPacketsPerDay = days > 0 ? Math.round(totalPackets / days * 10) / 10 : totalPackets;
|
||||
@@ -612,7 +591,7 @@ function getNodeAnalytics(pubkey, days) {
|
||||
|
||||
// Longest silence
|
||||
const timestamps = db.prepare(`
|
||||
SELECT timestamp FROM packets WHERE ${timeWhere} ORDER BY timestamp
|
||||
SELECT timestamp FROM packets_v WHERE ${timeWhere} ORDER BY timestamp
|
||||
`).all(params).map(r => new Date(r.timestamp).getTime());
|
||||
|
||||
let longestSilenceMs = 0, longestSilenceStart = null;
|
||||
@@ -652,4 +631,4 @@ function getNodeAnalytics(pubkey, days) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { db, insertPacket, insertTransmission, insertPath, upsertNode, upsertObserver, updateObserverStatus, getPackets, getPacket, getNodes, getNode, getObservers, getStats, seed, searchNodes, getNodeHealth, getNodeAnalytics };
|
||||
module.exports = { db, insertTransmission, upsertNode, upsertObserver, updateObserverStatus, getPackets, getPacket, getTransmission, getNodes, getNode, getObservers, getStats, seed, searchNodes, getNodeHealth, getNodeAnalytics };
|
||||
|
||||
53
decoder.js
53
decoder.js
@@ -265,7 +265,58 @@ function decodePacket(hexString, channelKeys) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { decodePacket, ROUTE_TYPES, PAYLOAD_TYPES };
|
||||
// --- ADVERT validation ---
|
||||
|
||||
const VALID_ROLES = new Set(['repeater', 'companion', 'room', 'sensor']);
|
||||
|
||||
/**
|
||||
* Validate decoded ADVERT data before upserting into the DB.
|
||||
* Returns { valid: true } or { valid: false, reason: string }.
|
||||
*/
|
||||
function validateAdvert(advert) {
|
||||
if (!advert || advert.error) return { valid: false, reason: advert?.error || 'null advert' };
|
||||
|
||||
// pubkey must be at least 16 hex chars (8 bytes) and not all zeros
|
||||
const pk = advert.pubKey || '';
|
||||
if (pk.length < 16) return { valid: false, reason: `pubkey too short (${pk.length} hex chars)` };
|
||||
if (/^0+$/.test(pk)) return { valid: false, reason: 'pubkey is all zeros' };
|
||||
|
||||
// lat/lon must be in valid ranges if present
|
||||
if (advert.lat != null) {
|
||||
if (!Number.isFinite(advert.lat) || advert.lat < -90 || advert.lat > 90) {
|
||||
return { valid: false, reason: `invalid lat: ${advert.lat}` };
|
||||
}
|
||||
}
|
||||
if (advert.lon != null) {
|
||||
if (!Number.isFinite(advert.lon) || advert.lon < -180 || advert.lon > 180) {
|
||||
return { valid: false, reason: `invalid lon: ${advert.lon}` };
|
||||
}
|
||||
}
|
||||
|
||||
// name must not contain control chars (except space) or be garbage
|
||||
if (advert.name != null) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(advert.name)) {
|
||||
return { valid: false, reason: 'name contains control characters' };
|
||||
}
|
||||
// Reject names that are mostly non-printable or suspiciously long
|
||||
if (advert.name.length > 64) {
|
||||
return { valid: false, reason: `name too long (${advert.name.length} chars)` };
|
||||
}
|
||||
}
|
||||
|
||||
// role derivation check — flags byte should produce a known role
|
||||
if (advert.flags) {
|
||||
const role = advert.flags.repeater ? 'repeater' : advert.flags.room ? 'room' : advert.flags.sensor ? 'sensor' : 'companion';
|
||||
if (!VALID_ROLES.has(role)) return { valid: false, reason: `unknown role: ${role}` };
|
||||
}
|
||||
|
||||
// timestamp: decoded but not currently used for node storage — skip validation
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
module.exports = { decodePacket, validateAdvert, ROUTE_TYPES, PAYLOAD_TYPES, VALID_ROLES };
|
||||
|
||||
// --- Tests ---
|
||||
if (require.main === module) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "meshcore-analyzer",
|
||||
"version": "2.1.0",
|
||||
"version": "2.4.0",
|
||||
"description": "Community-run alternative to the closed-source `analyzer.letsmesh.net`. MQTT packet collection + open-source web analyzer for the Bay Area MeshCore mesh.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
173
packet-store.js
173
packet-store.js
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
class PacketStore {
|
||||
constructor(dbModule, config = {}) {
|
||||
this.dbModule = dbModule; // The full db module (has .db, .insertPacket, .getPacket)
|
||||
this.dbModule = dbModule; // The full db module (has .db, .insertTransmission, .getPacket)
|
||||
this.db = dbModule.db; // Raw better-sqlite3 instance for queries
|
||||
this.maxBytes = (config.maxMemoryMB || 1024) * 1024 * 1024;
|
||||
this.estPacketBytes = config.estimatedPacketBytes || 450;
|
||||
@@ -27,10 +27,10 @@ class PacketStore {
|
||||
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._advertByObserver = new Map(); // pubkey → Set<observer_id> (ADVERT-only, for region filtering)
|
||||
|
||||
this.loaded = false;
|
||||
this.stats = { totalLoaded: 0, totalObservations: 0, evicted: 0, inserts: 0, queries: 0 };
|
||||
@@ -77,9 +77,9 @@ class PacketStore {
|
||||
`).all();
|
||||
|
||||
for (const row of rows) {
|
||||
if (this.packets.length >= this.maxPackets && !this.byTransmission.has(row.hash)) break;
|
||||
if (this.packets.length >= this.maxPackets && !this.byHash.has(row.hash)) break;
|
||||
|
||||
let tx = this.byTransmission.get(row.hash);
|
||||
let tx = this.byHash.get(row.hash);
|
||||
if (!tx) {
|
||||
tx = {
|
||||
id: row.transmission_id,
|
||||
@@ -100,7 +100,7 @@ class PacketStore {
|
||||
path_json: null,
|
||||
direction: null,
|
||||
};
|
||||
this.byTransmission.set(row.hash, tx);
|
||||
this.byHash.set(row.hash, tx);
|
||||
this.byHash.set(row.hash, tx);
|
||||
this.packets.push(tx);
|
||||
this.byTxId.set(tx.id, tx);
|
||||
@@ -126,6 +126,10 @@ class PacketStore {
|
||||
route_type: row.route_type,
|
||||
};
|
||||
|
||||
// Dedup: skip if same observer + same path already loaded
|
||||
const isDupeLoad = tx.observations.some(o => o.observer_id === obs.observer_id && (o.path_json || '') === (obs.path_json || ''));
|
||||
if (isDupeLoad) continue;
|
||||
|
||||
tx.observations.push(obs);
|
||||
tx.observation_count++;
|
||||
|
||||
@@ -151,12 +155,39 @@ class PacketStore {
|
||||
this.stats.totalObservations++;
|
||||
}
|
||||
}
|
||||
|
||||
// Post-load: set each transmission's observer/path to the EARLIEST observation
|
||||
for (const tx of this.packets) {
|
||||
if (tx.observations.length > 0) {
|
||||
let earliest = tx.observations[0];
|
||||
for (let i = 1; i < tx.observations.length; i++) {
|
||||
if (tx.observations[i].timestamp < earliest.timestamp) earliest = tx.observations[i];
|
||||
}
|
||||
tx.observer_id = earliest.observer_id;
|
||||
tx.observer_name = earliest.observer_name;
|
||||
tx.snr = earliest.snr;
|
||||
tx.rssi = earliest.rssi;
|
||||
tx.path_json = earliest.path_json;
|
||||
tx.direction = earliest.direction;
|
||||
}
|
||||
}
|
||||
|
||||
// Post-load: build ADVERT-by-observer index (needs all observations loaded first)
|
||||
for (const tx of this.packets) {
|
||||
if (tx.payload_type === 4 && tx.decoded_json) {
|
||||
try {
|
||||
const d = JSON.parse(tx.decoded_json);
|
||||
if (d.pubKey) this._indexAdvertObservers(d.pubKey, tx);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
console.log(`[PacketStore] ADVERT observer index: ${this._advertByObserver.size} nodes tracked`);
|
||||
}
|
||||
|
||||
/** Fallback: load from legacy packets table */
|
||||
_loadLegacy() {
|
||||
const rows = this.db.prepare(
|
||||
'SELECT * FROM packets ORDER BY timestamp DESC'
|
||||
'SELECT * FROM packets_v ORDER BY timestamp DESC'
|
||||
).all();
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -167,7 +198,7 @@ class PacketStore {
|
||||
|
||||
/** Index a legacy packet row (old flat structure) — builds transmission + observation */
|
||||
_indexLegacy(pkt) {
|
||||
let tx = this.byTransmission.get(pkt.hash);
|
||||
let tx = this.byHash.get(pkt.hash);
|
||||
if (!tx) {
|
||||
tx = {
|
||||
id: pkt.id,
|
||||
@@ -187,7 +218,7 @@ class PacketStore {
|
||||
path_json: pkt.path_json,
|
||||
direction: pkt.direction,
|
||||
};
|
||||
this.byTransmission.set(pkt.hash, tx);
|
||||
this.byHash.set(pkt.hash, tx);
|
||||
this.byHash.set(pkt.hash, tx);
|
||||
this.packets.push(tx);
|
||||
this.byTxId.set(tx.id, tx);
|
||||
@@ -197,6 +228,9 @@ class PacketStore {
|
||||
if (pkt.timestamp < tx.first_seen) {
|
||||
tx.first_seen = pkt.timestamp;
|
||||
tx.timestamp = pkt.timestamp;
|
||||
tx.observer_id = pkt.observer_id;
|
||||
tx.observer_name = pkt.observer_name;
|
||||
tx.path_json = pkt.path_json;
|
||||
}
|
||||
|
||||
const obs = {
|
||||
@@ -215,6 +249,10 @@ class PacketStore {
|
||||
decoded_json: pkt.decoded_json,
|
||||
route_type: pkt.route_type,
|
||||
};
|
||||
// Dedup: skip if same observer + same path already recorded for this transmission
|
||||
const isDupe = tx.observations.some(o => o.observer_id === obs.observer_id && (o.path_json || '') === (obs.path_json || ''));
|
||||
if (isDupe) return tx;
|
||||
|
||||
tx.observations.push(obs);
|
||||
tx.observation_count++;
|
||||
|
||||
@@ -239,7 +277,7 @@ class PacketStore {
|
||||
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
|
||||
if (this._nodeHashIndex.get(k).has(tx.hash)) continue;
|
||||
this._nodeHashIndex.get(k).add(tx.hash);
|
||||
if (!this.byNode.has(k)) this.byNode.set(k, []);
|
||||
this.byNode.get(k).push(tx);
|
||||
@@ -247,12 +285,32 @@ class PacketStore {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Track which observers saw an ADVERT from a given pubkey */
|
||||
_indexAdvertObservers(pubkey, tx) {
|
||||
if (!this._advertByObserver.has(pubkey)) this._advertByObserver.set(pubkey, new Set());
|
||||
const s = this._advertByObserver.get(pubkey);
|
||||
for (const obs of tx.observations) {
|
||||
if (obs.observer_id) s.add(obs.observer_id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get node pubkeys whose ADVERTs were seen by any of the given observer IDs */
|
||||
getNodesByAdvertObservers(observerIds) {
|
||||
const result = new Set();
|
||||
for (const [pubkey, observers] of this._advertByObserver) {
|
||||
for (const obsId of observerIds) {
|
||||
if (observers.has(obsId)) { result.add(pubkey); break; }
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Remove oldest transmissions when over memory limit */
|
||||
_evict() {
|
||||
while (this.packets.length > this.maxPackets) {
|
||||
const old = this.packets.pop();
|
||||
this.byHash.delete(old.hash);
|
||||
this.byTransmission.delete(old.hash);
|
||||
this.byHash.delete(old.hash);
|
||||
this.byTxId.delete(old.id);
|
||||
// Remove observations from byId and byObserver
|
||||
for (const obs of old.observations) {
|
||||
@@ -269,14 +327,34 @@ class PacketStore {
|
||||
|
||||
/** Insert a new packet (to both memory and SQLite) */
|
||||
insert(packetData) {
|
||||
const id = this.dbModule.insertPacket(packetData);
|
||||
const row = this.dbModule.getPacket(id);
|
||||
if (row && !this.sqliteOnly) {
|
||||
// Write to normalized tables and get the transmission ID
|
||||
const txResult = this.dbModule.insertTransmission ? this.dbModule.insertTransmission(packetData) : null;
|
||||
const transmissionId = txResult ? txResult.transmissionId : null;
|
||||
const observationId = txResult ? txResult.observationId : null;
|
||||
|
||||
// Build row directly from packetData — avoids view ID mismatch issues
|
||||
const row = {
|
||||
id: observationId,
|
||||
raw_hex: packetData.raw_hex,
|
||||
hash: packetData.hash,
|
||||
timestamp: packetData.timestamp,
|
||||
route_type: packetData.route_type,
|
||||
payload_type: packetData.payload_type,
|
||||
payload_version: packetData.payload_version,
|
||||
decoded_json: packetData.decoded_json,
|
||||
observer_id: packetData.observer_id,
|
||||
observer_name: packetData.observer_name,
|
||||
snr: packetData.snr,
|
||||
rssi: packetData.rssi,
|
||||
path_json: packetData.path_json,
|
||||
direction: packetData.direction,
|
||||
};
|
||||
if (!this.sqliteOnly) {
|
||||
// Update or create transmission in memory
|
||||
let tx = this.byTransmission.get(row.hash);
|
||||
let tx = this.byHash.get(row.hash);
|
||||
if (!tx) {
|
||||
tx = {
|
||||
id: row.id,
|
||||
id: transmissionId || row.id,
|
||||
raw_hex: row.raw_hex,
|
||||
hash: row.hash,
|
||||
first_seen: row.timestamp,
|
||||
@@ -293,16 +371,19 @@ class PacketStore {
|
||||
path_json: row.path_json,
|
||||
direction: row.direction,
|
||||
};
|
||||
this.byTransmission.set(row.hash, tx);
|
||||
this.byHash.set(row.hash, tx);
|
||||
this.byHash.set(row.hash, tx);
|
||||
this.packets.unshift(tx); // newest first
|
||||
this.byTxId.set(tx.id, tx);
|
||||
this._indexByNode(tx);
|
||||
} else {
|
||||
// Update first_seen if earlier
|
||||
// Update first_seen if earlier — also update observer + path to match
|
||||
if (row.timestamp < tx.first_seen) {
|
||||
tx.first_seen = row.timestamp;
|
||||
tx.timestamp = row.timestamp;
|
||||
tx.observer_id = row.observer_id;
|
||||
tx.observer_name = row.observer_name;
|
||||
tx.path_json = row.path_json;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,8 +404,12 @@ class PacketStore {
|
||||
decoded_json: row.decoded_json,
|
||||
route_type: row.route_type,
|
||||
};
|
||||
tx.observations.push(obs);
|
||||
tx.observation_count++;
|
||||
// Dedup: skip if same observer + same path already recorded for this transmission
|
||||
const isDupe = tx.observations.some(o => o.observer_id === obs.observer_id && (o.path_json || '') === (obs.path_json || ''));
|
||||
if (!isDupe) {
|
||||
tx.observations.push(obs);
|
||||
tx.observation_count++;
|
||||
}
|
||||
|
||||
// Update transmission's display fields if this is first observation
|
||||
if (tx.observations.length === 1) {
|
||||
@@ -342,6 +427,18 @@ class PacketStore {
|
||||
}
|
||||
|
||||
this.stats.totalObservations++;
|
||||
|
||||
// Update ADVERT observer index for live ingestion
|
||||
if (tx.payload_type === 4 && obs.observer_id && tx.decoded_json) {
|
||||
try {
|
||||
const d = JSON.parse(tx.decoded_json);
|
||||
if (d.pubKey) {
|
||||
if (!this._advertByObserver.has(d.pubKey)) this._advertByObserver.set(d.pubKey, new Set());
|
||||
this._advertByObserver.get(d.pubKey).add(obs.observer_id);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
this._evict();
|
||||
this.stats.inserts++;
|
||||
}
|
||||
@@ -414,8 +511,9 @@ class PacketStore {
|
||||
}
|
||||
if (observer) results = this._transmissionsForObserver(observer, results);
|
||||
if (hash) {
|
||||
const tx = this.byHash.get(hash);
|
||||
results = tx ? results.filter(p => p.hash === hash) : [];
|
||||
const h = hash.toLowerCase();
|
||||
const tx = this.byHash.get(h);
|
||||
results = tx ? results.filter(p => p.hash === h) : [];
|
||||
}
|
||||
if (since) results = results.filter(p => p.timestamp > since);
|
||||
if (until) results = results.filter(p => p.timestamp < until);
|
||||
@@ -465,7 +563,7 @@ class PacketStore {
|
||||
for (const o of obs) {
|
||||
if (!seen.has(o.hash)) {
|
||||
seen.add(o.hash);
|
||||
const tx = this.byTransmission.get(o.hash);
|
||||
const tx = this.byHash.get(o.hash);
|
||||
if (tx) result.push(tx);
|
||||
}
|
||||
}
|
||||
@@ -506,7 +604,7 @@ class PacketStore {
|
||||
/** Get timestamps for sparkline */
|
||||
getTimestamps(since) {
|
||||
if (this.sqliteOnly) {
|
||||
return this.db.prepare('SELECT timestamp FROM packets WHERE timestamp > ? ORDER BY timestamp ASC').all(since).map(r => r.timestamp);
|
||||
return this.db.prepare('SELECT timestamp FROM packets_v WHERE timestamp > ? ORDER BY timestamp ASC').all(since).map(r => r.timestamp);
|
||||
}
|
||||
const results = [];
|
||||
for (const p of this.packets) {
|
||||
@@ -518,7 +616,7 @@ class PacketStore {
|
||||
|
||||
/** 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;
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets_v WHERE id = ?').get(id) || null;
|
||||
return this.byId.get(id) || null;
|
||||
}
|
||||
|
||||
@@ -530,20 +628,21 @@ class PacketStore {
|
||||
|
||||
/** Get all siblings of a packet (same hash) — returns observations array */
|
||||
getSiblings(hash) {
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets WHERE hash = ? ORDER BY timestamp DESC').all(hash);
|
||||
const tx = this.byTransmission.get(hash);
|
||||
const h = hash.toLowerCase();
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets_v WHERE hash = ? ORDER BY timestamp DESC').all(h);
|
||||
const tx = this.byHash.get(h);
|
||||
return tx ? tx.observations : [];
|
||||
}
|
||||
|
||||
/** Get all transmissions (backward compat — returns packets array) */
|
||||
all() {
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets ORDER BY timestamp DESC').all();
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets_v ORDER BY timestamp DESC').all();
|
||||
return this.packets;
|
||||
}
|
||||
|
||||
/** Get all transmissions matching a filter function */
|
||||
filter(fn) {
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets ORDER BY timestamp DESC').all().filter(fn);
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets_v ORDER BY timestamp DESC').all().filter(fn);
|
||||
return this.packets.filter(fn);
|
||||
}
|
||||
|
||||
@@ -560,7 +659,7 @@ class PacketStore {
|
||||
byHash: this.byHash.size,
|
||||
byObserver: this.byObserver.size,
|
||||
byNode: this.byNode.size,
|
||||
byTransmission: this.byTransmission.size,
|
||||
advertByObserver: this._advertByObserver.size,
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -571,14 +670,14 @@ class PacketStore {
|
||||
if (type !== undefined) { where.push('payload_type = ?'); params.push(Number(type)); }
|
||||
if (route !== undefined) { where.push('route_type = ?'); params.push(Number(route)); }
|
||||
if (observer) { where.push('observer_id = ?'); params.push(observer); }
|
||||
if (hash) { where.push('hash = ?'); params.push(hash); }
|
||||
if (hash) { where.push('hash = ?'); params.push(hash.toLowerCase()); }
|
||||
if (since) { where.push('timestamp > ?'); params.push(since); }
|
||||
if (until) { where.push('timestamp < ?'); params.push(until); }
|
||||
if (region) { where.push('observer_id IN (SELECT id FROM observers WHERE iata = ?)'); params.push(region); }
|
||||
if (node) { try { const nr = this.db.prepare('SELECT public_key FROM nodes WHERE public_key = ? OR name = ? LIMIT 1').get(node, node); const pk = nr ? nr.public_key : node; where.push('(decoded_json LIKE ? OR id IN (SELECT packet_id FROM paths WHERE node_hash = ?))'); params.push('%' + pk + '%', pk.substring(0, 8)); } catch(e) { where.push('decoded_json LIKE ?'); params.push('%' + node + '%'); } }
|
||||
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 ?'); params.push('%' + pk + '%'); } 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);
|
||||
const total = this.db.prepare(`SELECT COUNT(*) as c FROM packets_v ${w}`).get(...params).c;
|
||||
const packets = this.db.prepare(`SELECT * FROM packets_v ${w} ORDER BY timestamp ${order === 'ASC' ? 'ASC' : 'DESC'} LIMIT ? OFFSET ?`).all(...params, limit, offset);
|
||||
return { packets, total };
|
||||
}
|
||||
|
||||
@@ -588,21 +687,21 @@ class PacketStore {
|
||||
if (type !== undefined) { where.push('payload_type = ?'); params.push(Number(type)); }
|
||||
if (route !== undefined) { where.push('route_type = ?'); params.push(Number(route)); }
|
||||
if (observer) { where.push('observer_id = ?'); params.push(observer); }
|
||||
if (hash) { where.push('hash = ?'); params.push(hash); }
|
||||
if (hash) { where.push('hash = ?'); params.push(hash.toLowerCase()); }
|
||||
if (since) { where.push('timestamp > ?'); params.push(since); }
|
||||
if (until) { where.push('timestamp < ?'); params.push(until); }
|
||||
if (region) { where.push('observer_id IN (SELECT id FROM observers WHERE iata = ?)'); params.push(region); }
|
||||
if (node) { try { const nr = this.db.prepare('SELECT public_key FROM nodes WHERE public_key = ? OR name = ? LIMIT 1').get(node, node); const pk = nr ? nr.public_key : node; where.push('(decoded_json LIKE ? OR id IN (SELECT packet_id FROM paths WHERE node_hash = ?))'); params.push('%' + pk + '%', pk.substring(0, 8)); } catch(e) { where.push('decoded_json LIKE ?'); params.push('%' + node + '%'); } }
|
||||
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 ?'); params.push('%' + pk + '%'); } catch(e) { where.push('decoded_json LIKE ?'); params.push('%' + node + '%'); } }
|
||||
const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||
|
||||
const sql = `SELECT hash, COUNT(*) as count, COUNT(DISTINCT observer_id) as observer_count,
|
||||
MAX(timestamp) as latest, MIN(observer_id) as observer_id, MIN(observer_name) as observer_name,
|
||||
MIN(path_json) as path_json, MIN(payload_type) as payload_type, MIN(raw_hex) as raw_hex,
|
||||
MIN(decoded_json) as decoded_json
|
||||
FROM packets ${w} GROUP BY hash ORDER BY latest DESC LIMIT ? OFFSET ?`;
|
||||
FROM packets_v ${w} GROUP BY hash ORDER BY latest DESC LIMIT ? OFFSET ?`;
|
||||
const packets = this.db.prepare(sql).all(...params, limit, offset);
|
||||
|
||||
const countSql = `SELECT COUNT(DISTINCT hash) as c FROM packets ${w}`;
|
||||
const countSql = `SELECT COUNT(DISTINCT hash) as c FROM packets_v ${w}`;
|
||||
const total = this.db.prepare(countSql).get(...params).c;
|
||||
return { packets, total };
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
(function () {
|
||||
let _analyticsData = {};
|
||||
const sf = (v, d) => (v != null ? v.toFixed(d) : '–'); // safe toFixed
|
||||
function esc(s) { return s ? String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') : ''; }
|
||||
|
||||
// --- SVG helpers ---
|
||||
@@ -65,6 +66,7 @@
|
||||
<div class="analytics-header">
|
||||
<h2>📊 Mesh Analytics</h2>
|
||||
<p class="text-muted">Deep dive into your mesh network data</p>
|
||||
<div id="analyticsRegionFilter" class="region-filter-container"></div>
|
||||
<div class="analytics-tabs" id="analyticsTabs">
|
||||
<button class="tab-btn active" data-tab="overview">Overview</button>
|
||||
<button class="tab-btn" data-tab="rf">RF / Signal</button>
|
||||
@@ -74,6 +76,7 @@
|
||||
<button class="tab-btn" data-tab="collisions">Hash Collisions</button>
|
||||
<button class="tab-btn" data-tab="subpaths">Route Patterns</button>
|
||||
<button class="tab-btn" data-tab="nodes">Nodes</button>
|
||||
<button class="tab-btn" data-tab="distance">Distance</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="analyticsContent" class="analytics-content">
|
||||
@@ -89,9 +92,13 @@
|
||||
if (!btn) return;
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
renderTab(btn.dataset.tab);
|
||||
_currentTab = btn.dataset.tab;
|
||||
renderTab(_currentTab);
|
||||
});
|
||||
|
||||
RegionFilter.init(document.getElementById('analyticsRegionFilter'));
|
||||
RegionFilter.onChange(function () { loadAnalytics(); });
|
||||
|
||||
// Delegated click/keyboard handler for clickable table rows
|
||||
const analyticsContent = document.getElementById('analyticsContent');
|
||||
if (analyticsContent) {
|
||||
@@ -106,16 +113,24 @@
|
||||
analyticsContent.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
loadAnalytics();
|
||||
}
|
||||
|
||||
let _currentTab = 'overview';
|
||||
|
||||
async function loadAnalytics() {
|
||||
try {
|
||||
_analyticsData = {};
|
||||
const rqs = RegionFilter.regionQueryString();
|
||||
const sep = rqs ? '?' + rqs.slice(1) : '';
|
||||
const [hashData, rfData, topoData, chanData] = await Promise.all([
|
||||
api('/analytics/hash-sizes', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/rf', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/topology', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/channels', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/hash-sizes' + sep, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/rf' + sep, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/topology' + sep, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/channels' + sep, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
]);
|
||||
_analyticsData = { hashData, rfData, topoData, chanData };
|
||||
renderTab('overview');
|
||||
renderTab(_currentTab);
|
||||
} catch (e) {
|
||||
document.getElementById('analyticsContent').innerHTML =
|
||||
`<div class="text-muted" role="alert" aria-live="polite" style="padding:40px">Failed to load: ${e.message}</div>`;
|
||||
@@ -134,6 +149,7 @@
|
||||
case 'collisions': await renderCollisionTab(el, d.hashData); break;
|
||||
case 'subpaths': await renderSubpaths(el); break;
|
||||
case 'nodes': await renderNodesTab(el); break;
|
||||
case 'distance': await renderDistanceTab(el); break;
|
||||
}
|
||||
// Auto-apply column resizing to all analytics tables
|
||||
requestAnimationFrame(() => {
|
||||
@@ -163,17 +179,17 @@
|
||||
<div class="stat-label">Unique Nodes</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${rf.snr.avg.toFixed(1)} dB</div>
|
||||
<div class="stat-value">${sf(rf.snr.avg, 1)} dB</div>
|
||||
<div class="stat-label">Avg SNR</div>
|
||||
<div class="stat-detail">${rf.snr.min.toFixed(1)} to ${rf.snr.max.toFixed(1)}</div>
|
||||
<div class="stat-detail">${sf(rf.snr.min, 1)} to ${sf(rf.snr.max, 1)}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${rf.rssi.avg.toFixed(0)} dBm</div>
|
||||
<div class="stat-value">${sf(rf.rssi.avg, 0)} dBm</div>
|
||||
<div class="stat-label">Avg RSSI</div>
|
||||
<div class="stat-detail">${rf.rssi.min} to ${rf.rssi.max}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${topo.avgHops.toFixed(1)}</div>
|
||||
<div class="stat-value">${sf(topo.avgHops, 1)}</div>
|
||||
<div class="stat-label">Avg Hops</div>
|
||||
<div class="stat-detail">max ${topo.maxHops}</div>
|
||||
</div>
|
||||
@@ -241,11 +257,11 @@
|
||||
<p class="text-muted">Signal-to-Noise Ratio (higher = cleaner signal)</p>
|
||||
${snrHist.svg}
|
||||
<div class="rf-stats">
|
||||
<span>Min: <strong>${rf.snr.min.toFixed(1)} dB</strong></span>
|
||||
<span>Mean: <strong>${rf.snr.avg.toFixed(1)} dB</strong></span>
|
||||
<span>Median: <strong>${rf.snr.median.toFixed(1)} dB</strong></span>
|
||||
<span>Max: <strong>${rf.snr.max.toFixed(1)} dB</strong></span>
|
||||
<span>σ: <strong>${rf.snr.stddev.toFixed(1)} dB</strong></span>
|
||||
<span>Min: <strong>${sf(rf.snr.min, 1)} dB</strong></span>
|
||||
<span>Mean: <strong>${sf(rf.snr.avg, 1)} dB</strong></span>
|
||||
<span>Median: <strong>${sf(rf.snr.median, 1)} dB</strong></span>
|
||||
<span>Max: <strong>${sf(rf.snr.max, 1)} dB</strong></span>
|
||||
<span>σ: <strong>${sf(rf.snr.stddev, 1)} dB</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="analytics-card flex-1">
|
||||
@@ -254,10 +270,10 @@
|
||||
${rssiHist.svg}
|
||||
<div class="rf-stats">
|
||||
<span>Min: <strong>${rf.rssi.min} dBm</strong></span>
|
||||
<span>Mean: <strong>${rf.rssi.avg.toFixed(0)} dBm</strong></span>
|
||||
<span>Mean: <strong>${sf(rf.rssi.avg, 0)} dBm</strong></span>
|
||||
<span>Median: <strong>${rf.rssi.median} dBm</strong></span>
|
||||
<span>Max: <strong>${rf.rssi.max} dBm</strong></span>
|
||||
<span>σ: <strong>${rf.rssi.stddev.toFixed(1)} dBm</strong></span>
|
||||
<span>σ: <strong>${sf(rf.rssi.stddev, 1)} dBm</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -357,9 +373,9 @@
|
||||
html += `<tr>
|
||||
<td><strong>${t.name}</strong></td>
|
||||
<td>${t.count}</td>
|
||||
<td><strong>${t.avg.toFixed(1)} dB</strong></td>
|
||||
<td>${t.min.toFixed(1)}</td>
|
||||
<td>${t.max.toFixed(1)}</td>
|
||||
<td><strong>${sf(t.avg, 1)} dB</strong></td>
|
||||
<td>${sf(t.min, 1)}</td>
|
||||
<td>${sf(t.max, 1)}</td>
|
||||
<td><div class="hash-bar-track" style="height:14px"><div class="hash-bar-fill" style="width:${barPct}%;background:${color};height:100%"></div></div></td>
|
||||
</tr>`;
|
||||
});
|
||||
@@ -408,7 +424,7 @@
|
||||
<p class="text-muted">Number of repeater hops per packet</p>
|
||||
${barChart(topo.hopDistribution.map(h=>h.count), topo.hopDistribution.map(h=>h.hops), ['#3b82f6'])}
|
||||
<div class="rf-stats">
|
||||
<span>Avg: <strong>${topo.avgHops.toFixed(1)} hops</strong></span>
|
||||
<span>Avg: <strong>${sf(topo.avgHops, 1)} hops</strong></span>
|
||||
<span>Median: <strong>${topo.medianHops}</strong></span>
|
||||
<span>Max: <strong>${topo.maxHops}</strong></span>
|
||||
<span>1-hop direct: <strong>${topo.hopDistribution[0]?.count || 0}</strong></span>
|
||||
@@ -604,7 +620,7 @@
|
||||
<tbody>
|
||||
${ch.channels.map(c => `<tr class="clickable-row" data-action="navigate" data-value="#/channels?ch=${c.hash}" tabindex="0" role="row">
|
||||
<td><strong>${esc(c.name || 'Unknown')}</strong></td>
|
||||
<td class="mono">${c.hash}</td>
|
||||
<td class="mono">${typeof c.hash === 'number' ? '0x' + c.hash.toString(16).toUpperCase().padStart(2, '0') : c.hash}</td>
|
||||
<td>${c.messages}</td>
|
||||
<td>${c.senders}</td>
|
||||
<td>${timeAgo(c.lastActivity)}</td>
|
||||
@@ -759,7 +775,7 @@
|
||||
</div>
|
||||
`;
|
||||
let allNodes = [];
|
||||
try { const nd = await api('/nodes?limit=2000', { ttl: CLIENT_TTL.nodeList }); allNodes = nd.nodes || []; } catch {}
|
||||
try { const nd = await api('/nodes?limit=2000' + RegionFilter.regionQueryString(), { ttl: CLIENT_TTL.nodeList }); allNodes = nd.nodes || []; } catch {}
|
||||
renderHashMatrix(data.topHops, allNodes);
|
||||
renderCollisions(data.topHops, allNodes);
|
||||
}
|
||||
@@ -949,11 +965,12 @@
|
||||
async function renderSubpaths(el) {
|
||||
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">Analyzing route patterns…</div>';
|
||||
try {
|
||||
const rq = RegionFilter.regionQueryString();
|
||||
const [d2, d3, d4, d5] = await Promise.all([
|
||||
api('/analytics/subpaths?minLen=2&maxLen=2&limit=50', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/subpaths?minLen=3&maxLen=3&limit=30', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/subpaths?minLen=4&maxLen=4&limit=20', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/subpaths?minLen=5&maxLen=8&limit=15', { ttl: CLIENT_TTL.analyticsRF })
|
||||
api('/analytics/subpaths?minLen=2&maxLen=2&limit=50' + rq, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/subpaths?minLen=3&maxLen=3&limit=30' + rq, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/subpaths?minLen=4&maxLen=4&limit=20' + rq, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/subpaths?minLen=5&maxLen=8&limit=15' + rq, { ttl: CLIENT_TTL.analyticsRF })
|
||||
]);
|
||||
|
||||
function renderTable(data, title) {
|
||||
@@ -1152,10 +1169,11 @@
|
||||
async function renderNodesTab(el) {
|
||||
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Loading node analytics…</div>';
|
||||
try {
|
||||
const rq = RegionFilter.regionQueryString();
|
||||
const [nodesResp, bulkHealth, netStatus] = await Promise.all([
|
||||
api('/nodes?limit=200&sortBy=lastSeen', { ttl: CLIENT_TTL.nodeList }),
|
||||
api('/nodes/bulk-health?limit=50', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/nodes/network-status', { ttl: CLIENT_TTL.analyticsRF })
|
||||
api('/nodes?limit=200&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }),
|
||||
api('/nodes/bulk-health?limit=50' + rq, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/nodes/network-status' + (rq ? '?' + rq.slice(1) : ''), { ttl: CLIENT_TTL.analyticsRF })
|
||||
]);
|
||||
const nodes = nodesResp.nodes || nodesResp;
|
||||
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
|
||||
@@ -1296,6 +1314,92 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function renderDistanceTab(el) {
|
||||
try {
|
||||
const rqs = RegionFilter.regionQueryString();
|
||||
const sep = rqs ? '?' + rqs.slice(1) : '';
|
||||
const data = await api('/analytics/distance' + sep, { ttl: CLIENT_TTL.analyticsRF });
|
||||
const s = data.summary;
|
||||
let html = `<div class="analytics-grid">
|
||||
<div class="stat-card"><div class="stat-value">${s.totalHops.toLocaleString()}</div><div class="stat-label">Total Hops Analyzed</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${s.totalPaths.toLocaleString()}</div><div class="stat-label">Paths Analyzed</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${s.avgDist} km</div><div class="stat-label">Avg Hop Distance</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${s.maxDist} km</div><div class="stat-label">Max Hop Distance</div></div>
|
||||
</div>`;
|
||||
|
||||
// Category stats
|
||||
const cats = data.catStats;
|
||||
html += `<div class="analytics-section"><h3>Distance by Link Type</h3><table class="data-table"><thead><tr><th>Type</th><th>Count</th><th>Avg (km)</th><th>Median (km)</th><th>Min (km)</th><th>Max (km)</th></tr></thead><tbody>`;
|
||||
for (const [cat, st] of Object.entries(cats)) {
|
||||
if (!st.count) continue;
|
||||
html += `<tr><td><strong>${esc(cat)}</strong></td><td>${st.count.toLocaleString()}</td><td>${st.avg}</td><td>${st.median}</td><td>${st.min}</td><td>${st.max}</td></tr>`;
|
||||
}
|
||||
html += `</tbody></table></div>`;
|
||||
|
||||
// Histogram
|
||||
if (data.distHistogram && data.distHistogram.bins) {
|
||||
const buckets = data.distHistogram.bins.map(b => b.count);
|
||||
const labels = data.distHistogram.bins.map(b => b.x.toFixed(1));
|
||||
html += `<div class="analytics-section"><h3>Hop Distance Distribution</h3>${barChart(buckets, labels, '#22c55e')}</div>`;
|
||||
}
|
||||
|
||||
// Distance over time
|
||||
if (data.distOverTime && data.distOverTime.length > 1) {
|
||||
html += `<div class="analytics-section"><h3>Average Distance Over Time</h3>${sparkSvg(data.distOverTime.map(d => d.avg), 'var(--accent)', 800, 120)}</div>`;
|
||||
}
|
||||
|
||||
// Top hops leaderboard
|
||||
html += `<div class="analytics-section"><h3>🏆 Top 20 Longest Hops</h3><table class="data-table"><thead><tr><th>#</th><th>From</th><th>To</th><th>Distance (km)</th><th>Type</th><th>SNR</th><th>Packet</th><th></th></tr></thead><tbody>`;
|
||||
const top20 = data.topHops.slice(0, 20);
|
||||
top20.forEach((h, i) => {
|
||||
const fromLink = h.fromPk ? `<a href="#/nodes/${encodeURIComponent(h.fromPk)}" class="analytics-link">${esc(h.fromName)}</a>` : esc(h.fromName || '?');
|
||||
const toLink = h.toPk ? `<a href="#/nodes/${encodeURIComponent(h.toPk)}" class="analytics-link">${esc(h.toName)}</a>` : esc(h.toName || '?');
|
||||
const snr = h.snr != null ? h.snr + ' dB' : '<span class="text-muted">—</span>';
|
||||
const pktLink = h.hash ? `<a href="#/packet/${encodeURIComponent(h.hash)}" class="analytics-link mono" style="font-size:0.85em">${esc(h.hash.slice(0, 12))}…</a>` : '—';
|
||||
const mapBtn = h.fromPk && h.toPk ? `<button class="btn-icon dist-map-hop" data-from="${esc(h.fromPk)}" data-to="${esc(h.toPk)}" title="View on map">🗺️</button>` : '';
|
||||
html += `<tr><td>${i+1}</td><td>${fromLink}</td><td>${toLink}</td><td><strong>${h.dist}</strong></td><td>${esc(h.type)}</td><td>${snr}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
|
||||
});
|
||||
html += `</tbody></table></div>`;
|
||||
|
||||
// Top paths
|
||||
if (data.topPaths.length) {
|
||||
html += `<div class="analytics-section"><h3>🛤️ Top 10 Longest Multi-Hop Paths</h3><table class="data-table"><thead><tr><th>#</th><th>Total Distance (km)</th><th>Hops</th><th>Route</th><th>Packet</th><th></th></tr></thead><tbody>`;
|
||||
data.topPaths.slice(0, 10).forEach((p, i) => {
|
||||
const route = p.hops.map(h => esc(h.fromName)).concat(esc(p.hops[p.hops.length-1].toName)).join(' → ');
|
||||
const pktLink = p.hash ? `<a href="#/packet/${encodeURIComponent(p.hash)}" class="analytics-link mono" style="font-size:0.85em">${esc(p.hash.slice(0, 12))}…</a>` : '—';
|
||||
// Collect all unique pubkeys in path order
|
||||
const pathPks = [];
|
||||
p.hops.forEach(h => { if (h.fromPk && !pathPks.includes(h.fromPk)) pathPks.push(h.fromPk); });
|
||||
if (p.hops.length && p.hops[p.hops.length-1].toPk) { const last = p.hops[p.hops.length-1].toPk; if (!pathPks.includes(last)) pathPks.push(last); }
|
||||
const mapBtn = pathPks.length >= 2 ? `<button class="btn-icon dist-map-path" data-hops='${JSON.stringify(pathPks)}' title="View on map">🗺️</button>` : '';
|
||||
html += `<tr><td>${i+1}</td><td><strong>${p.totalDist}</strong></td><td>${p.hopCount}</td><td style="font-size:0.9em">${route}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
|
||||
});
|
||||
html += `</tbody></table></div>`;
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
|
||||
// Wire up map buttons
|
||||
el.querySelectorAll('.dist-map-hop').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
sessionStorage.setItem('map-route-hops', JSON.stringify({ hops: [btn.dataset.from, btn.dataset.to] }));
|
||||
window.location.hash = '#/map?route=1';
|
||||
});
|
||||
});
|
||||
el.querySelectorAll('.dist-map-path').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
try {
|
||||
const hops = JSON.parse(btn.dataset.hops);
|
||||
sessionStorage.setItem('map-route-hops', JSON.stringify({ hops }));
|
||||
window.location.hash = '#/map?route=1';
|
||||
} catch {}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div style="padding:40px;text-align:center;color:#ff6b6b">Failed to load distance analytics: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() { _analyticsData = {}; }
|
||||
|
||||
registerPage('analytics', { init, destroy });
|
||||
|
||||
@@ -215,7 +215,6 @@ function connectWS() {
|
||||
api._invalidateTimer = null;
|
||||
invalidateApiCache('/stats');
|
||||
invalidateApiCache('/nodes');
|
||||
invalidateApiCache('/channels');
|
||||
}, 5000);
|
||||
}
|
||||
wsListeners.forEach(fn => fn(msg));
|
||||
@@ -455,7 +454,7 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
const pktList = packets.packets || packets;
|
||||
if (Array.isArray(pktList)) {
|
||||
for (const p of pktList.slice(0, 5)) {
|
||||
html += `<div class="search-result-item" onclick="location.hash='#/packets?id=${p.id}';document.getElementById('searchOverlay').classList.add('hidden')">
|
||||
html += `<div class="search-result-item" onclick="location.hash='#/packets/${p.packet_hash || p.hash || p.id}';document.getElementById('searchOverlay').classList.add('hidden')">
|
||||
<span class="search-result-type">Packet</span>${truncate(p.packet_hash || '', 16)} — ${payloadTypeName(p.payload_type)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +205,14 @@
|
||||
return str.length > len ? str.slice(0, len) + '…' : str;
|
||||
}
|
||||
|
||||
function formatSecondsAgo(sec) {
|
||||
if (sec < 0) sec = 0;
|
||||
if (sec < 60) return sec + 's ago';
|
||||
if (sec < 3600) return Math.floor(sec / 60) + 'm ago';
|
||||
if (sec < 86400) return Math.floor(sec / 3600) + 'h ago';
|
||||
return Math.floor(sec / 86400) + 'd ago';
|
||||
}
|
||||
|
||||
function highlightMentions(text) {
|
||||
if (!text) return '';
|
||||
return escapeHtml(text).replace(/@\[([^\]]+)\]/g, function(_, name) {
|
||||
@@ -213,12 +221,15 @@
|
||||
});
|
||||
}
|
||||
|
||||
let regionChangeHandler = null;
|
||||
|
||||
function init(app, routeParam) {
|
||||
app.innerHTML = `<div class="ch-layout">
|
||||
<div class="ch-sidebar" aria-label="Channel list">
|
||||
<div class="ch-sidebar-header">
|
||||
<div class="ch-sidebar-title"><span class="ch-icon">💬</span> Channels</div>
|
||||
</div>
|
||||
<div id="chRegionFilter" class="region-filter-container" style="padding:0 8px"></div>
|
||||
<div class="ch-channel-list" id="chList" role="listbox" aria-label="Channels">
|
||||
<div class="ch-loading">Loading channels…</div>
|
||||
</div>
|
||||
@@ -237,6 +248,9 @@
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
RegionFilter.init(document.getElementById('chRegionFilter'));
|
||||
regionChangeHandler = RegionFilter.onChange(function () { loadChannels(); });
|
||||
|
||||
loadChannels().then(() => {
|
||||
if (routeParam) selectChannel(routeParam);
|
||||
});
|
||||
@@ -367,21 +381,140 @@
|
||||
});
|
||||
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
var dominated = msgs.some(function (m) {
|
||||
var dominated = msgs.filter(function (m) {
|
||||
return m.type === 'message' || (m.type === 'packet' && m.data?.decoded?.header?.payloadTypeName === 'GRP_TXT');
|
||||
});
|
||||
if (dominated) {
|
||||
loadChannels(true);
|
||||
if (selectedHash) {
|
||||
refreshMessages();
|
||||
if (!dominated.length) return;
|
||||
|
||||
var channelListDirty = false;
|
||||
var messagesDirty = false;
|
||||
var seenHashes = new Set();
|
||||
|
||||
for (var i = 0; i < dominated.length; i++) {
|
||||
var m = dominated[i];
|
||||
var payload = m.data?.decoded?.payload;
|
||||
if (!payload) continue;
|
||||
|
||||
var channelName = payload.channel || 'unknown';
|
||||
var rawText = payload.text || '';
|
||||
var sender = payload.sender || null;
|
||||
var displayText = rawText;
|
||||
|
||||
// Parse "sender: message" format
|
||||
if (rawText && !sender) {
|
||||
var colonIdx = rawText.indexOf(': ');
|
||||
if (colonIdx > 0 && colonIdx < 50) {
|
||||
sender = rawText.slice(0, colonIdx);
|
||||
displayText = rawText.slice(colonIdx + 2);
|
||||
}
|
||||
} else if (rawText && sender) {
|
||||
var colonIdx2 = rawText.indexOf(': ');
|
||||
if (colonIdx2 > 0 && colonIdx2 < 50) {
|
||||
displayText = rawText.slice(colonIdx2 + 2);
|
||||
}
|
||||
}
|
||||
if (!sender) sender = 'Unknown';
|
||||
|
||||
var ts = new Date().toISOString();
|
||||
var pktHash = m.data?.hash || m.data?.packet?.hash || null;
|
||||
var pktId = m.data?.id || null;
|
||||
var snr = m.data?.snr ?? m.data?.packet?.snr ?? payload.SNR ?? null;
|
||||
var observer = m.data?.packet?.observer_name || m.data?.observer || null;
|
||||
|
||||
// Update channel list entry — only once per unique packet hash
|
||||
var isFirstObservation = pktHash && !seenHashes.has(pktHash + ':' + channelName);
|
||||
if (pktHash) seenHashes.add(pktHash + ':' + channelName);
|
||||
|
||||
var ch = channels.find(function (c) { return c.hash === channelName; });
|
||||
if (ch) {
|
||||
if (isFirstObservation) ch.messageCount = (ch.messageCount || 0) + 1;
|
||||
ch.lastActivityMs = Date.now();
|
||||
ch.lastSender = sender;
|
||||
ch.lastMessage = truncate(displayText, 100);
|
||||
channelListDirty = true;
|
||||
} else if (isFirstObservation) {
|
||||
// New channel we haven't seen
|
||||
channels.push({
|
||||
hash: channelName,
|
||||
name: channelName,
|
||||
messageCount: 1,
|
||||
lastActivityMs: Date.now(),
|
||||
lastSender: sender,
|
||||
lastMessage: truncate(displayText, 100),
|
||||
});
|
||||
channelListDirty = true;
|
||||
}
|
||||
|
||||
// If this message is for the selected channel, append to messages
|
||||
if (selectedHash && channelName === selectedHash) {
|
||||
// Deduplicate by packet hash — same message seen by multiple observers
|
||||
var existing = pktHash ? messages.find(function (msg) { return msg.packetHash === pktHash; }) : null;
|
||||
if (existing) {
|
||||
existing.repeats = (existing.repeats || 1) + 1;
|
||||
if (observer && existing.observers && existing.observers.indexOf(observer) === -1) {
|
||||
existing.observers.push(observer);
|
||||
}
|
||||
} else {
|
||||
messages.push({
|
||||
sender: sender,
|
||||
text: displayText,
|
||||
timestamp: ts,
|
||||
sender_timestamp: payload.sender_timestamp || null,
|
||||
packetId: pktId,
|
||||
packetHash: pktHash,
|
||||
repeats: 1,
|
||||
observers: observer ? [observer] : [],
|
||||
hops: payload.path_len || 0,
|
||||
snr: snr,
|
||||
});
|
||||
}
|
||||
messagesDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (channelListDirty) {
|
||||
channels.sort(function (a, b) { return (b.lastActivityMs || 0) - (a.lastActivityMs || 0); });
|
||||
renderChannelList();
|
||||
}
|
||||
if (messagesDirty) {
|
||||
renderMessages();
|
||||
// Update header count
|
||||
var ch2 = channels.find(function (c) { return c.hash === selectedHash; });
|
||||
var header = document.getElementById('chHeader');
|
||||
if (header && ch2) {
|
||||
header.querySelector('.ch-header-text').textContent = (ch2.name || 'Channel ' + selectedHash) + ' — ' + messages.length + ' messages';
|
||||
}
|
||||
var msgEl = document.getElementById('chMessages');
|
||||
if (msgEl && autoScroll) scrollToBottom();
|
||||
else {
|
||||
document.getElementById('chScrollBtn')?.classList.remove('hidden');
|
||||
var liveEl = document.getElementById('chAriaLive');
|
||||
if (liveEl) liveEl.textContent = 'New message received';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Tick relative timestamps every 1s — iterates channels array, updates DOM text only
|
||||
timeAgoTimer = setInterval(function () {
|
||||
var now = Date.now();
|
||||
for (var i = 0; i < channels.length; i++) {
|
||||
var ch = channels[i];
|
||||
if (!ch.lastActivityMs) continue;
|
||||
var el = document.querySelector('.ch-item-time[data-channel-hash="' + ch.hash + '"]');
|
||||
if (el) el.textContent = formatSecondsAgo(Math.floor((now - ch.lastActivityMs) / 1000));
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
var timeAgoTimer = null;
|
||||
|
||||
function destroy() {
|
||||
if (wsHandler) offWS(wsHandler);
|
||||
wsHandler = null;
|
||||
if (timeAgoTimer) clearInterval(timeAgoTimer);
|
||||
timeAgoTimer = null;
|
||||
if (regionChangeHandler) RegionFilter.offChange(regionChangeHandler);
|
||||
regionChangeHandler = null;
|
||||
channels = [];
|
||||
messages = [];
|
||||
selectedHash = null;
|
||||
@@ -393,8 +526,13 @@
|
||||
|
||||
async function loadChannels(silent) {
|
||||
try {
|
||||
const data = await api('/channels', { ttl: CLIENT_TTL.channels });
|
||||
channels = (data.channels || []).sort((a, b) => (b.lastActivity || '').localeCompare(a.lastActivity || ''));
|
||||
const rp = RegionFilter.getRegionParam();
|
||||
const qs = rp ? '?region=' + encodeURIComponent(rp) : '';
|
||||
const data = await api('/channels' + qs, { ttl: CLIENT_TTL.channels });
|
||||
channels = (data.channels || []).map(ch => {
|
||||
ch.lastActivityMs = ch.lastActivity ? new Date(ch.lastActivity).getTime() : 0;
|
||||
return ch;
|
||||
}).sort((a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0));
|
||||
renderChannelList();
|
||||
} catch (e) {
|
||||
if (!silent) {
|
||||
@@ -409,30 +547,27 @@
|
||||
if (!el) return;
|
||||
if (channels.length === 0) { el.innerHTML = '<div class="ch-empty">No channels found</div>'; return; }
|
||||
|
||||
// Sort: decrypted first (by message count desc), then encrypted (by message count desc)
|
||||
// Sort by message count desc
|
||||
const sorted = [...channels].sort((a, b) => {
|
||||
if (a.encrypted !== b.encrypted) return a.encrypted ? 1 : -1;
|
||||
return (b.messageCount || 0) - (a.messageCount || 0);
|
||||
});
|
||||
|
||||
el.innerHTML = sorted.map(ch => {
|
||||
const name = ch.name || `Channel ${ch.hash}`;
|
||||
const color = getChannelColor(ch.hash);
|
||||
const time = ch.lastActivity ? timeAgo(ch.lastActivity) : '';
|
||||
const time = ch.lastActivityMs ? formatSecondsAgo(Math.floor((Date.now() - ch.lastActivityMs) / 1000)) : '';
|
||||
const preview = ch.lastSender && ch.lastMessage
|
||||
? `${ch.lastSender}: ${truncate(ch.lastMessage, 28)}`
|
||||
: ch.encrypted ? `🔒 ${ch.messageCount} encrypted` : `${ch.messageCount} messages`;
|
||||
: `${ch.messageCount} messages`;
|
||||
const sel = selectedHash === ch.hash ? ' selected' : '';
|
||||
const lockIcon = ch.encrypted ? ' 🔒' : '';
|
||||
const encClass = ch.encrypted ? ' ch-item-encrypted' : '';
|
||||
const abbr = name.startsWith('#') ? name.slice(0, 3) : name.slice(0, 2).toUpperCase();
|
||||
|
||||
return `<button class="ch-item${sel}${encClass}" data-hash="${ch.hash}" type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}">
|
||||
return `<button class="ch-item${sel}" data-hash="${ch.hash}" type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}">
|
||||
<div class="ch-badge" style="background:${color}" aria-hidden="true">${escapeHtml(abbr)}</div>
|
||||
<div class="ch-item-body">
|
||||
<div class="ch-item-top">
|
||||
<span class="ch-item-name">${escapeHtml(name)}${lockIcon}</span>
|
||||
<span class="ch-item-time">${time}</span>
|
||||
<span class="ch-item-name">${escapeHtml(name)}</span>
|
||||
<span class="ch-item-time" data-channel-hash="${ch.hash}">${time}</span>
|
||||
</div>
|
||||
<div class="ch-item-preview">${escapeHtml(preview)}</div>
|
||||
</div>
|
||||
@@ -442,7 +577,7 @@
|
||||
|
||||
async function selectChannel(hash) {
|
||||
selectedHash = hash;
|
||||
history.replaceState(null, '', `#/channels/${hash}`);
|
||||
history.replaceState(null, '', `#/channels/${encodeURIComponent(hash)}`);
|
||||
renderChannelList();
|
||||
const ch = channels.find(c => c.hash === hash);
|
||||
const name = ch?.name || `Channel ${hash}`;
|
||||
@@ -456,7 +591,7 @@
|
||||
msgEl.innerHTML = '<div class="ch-loading">Loading messages…</div>';
|
||||
|
||||
try {
|
||||
const data = await api(`/channels/${hash}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
|
||||
const data = await api(`/channels/${encodeURIComponent(hash)}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
|
||||
messages = data.messages || [];
|
||||
renderMessages();
|
||||
scrollToBottom();
|
||||
@@ -471,7 +606,7 @@
|
||||
if (!msgEl) return;
|
||||
const wasAtBottom = msgEl.scrollHeight - msgEl.scrollTop - msgEl.clientHeight < 60;
|
||||
try {
|
||||
const data = await api(`/channels/${selectedHash}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
|
||||
const data = await api(`/channels/${encodeURIComponent(selectedHash)}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
|
||||
const newMsgs = data.messages || [];
|
||||
// #92: Use message ID/hash for change detection instead of count + timestamp
|
||||
var _getLastId = function (arr) { var m = arr.length ? arr[arr.length - 1] : null; return m ? (m.id || m.packetId || m.timestamp || '') : ''; };
|
||||
@@ -499,11 +634,7 @@
|
||||
const senderLetter = sender.replace(/[^\w]/g, '').charAt(0).toUpperCase() || '?';
|
||||
|
||||
let displayText;
|
||||
if (msg.encrypted) {
|
||||
displayText = '<span class="mono ch-encrypted-text">🔒 encrypted</span>';
|
||||
} else {
|
||||
displayText = highlightMentions(msg.text || '');
|
||||
}
|
||||
displayText = highlightMentions(msg.text || '');
|
||||
|
||||
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
||||
const date = msg.timestamp ? new Date(msg.timestamp).toLocaleDateString() : '';
|
||||
|
||||
142
public/hop-resolver.js
Normal file
142
public/hop-resolver.js
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Client-side hop resolver — eliminates /api/resolve-hops HTTP requests.
|
||||
* Mirrors the server's disambiguateHops() logic from server.js.
|
||||
*/
|
||||
window.HopResolver = (function() {
|
||||
'use strict';
|
||||
|
||||
const MAX_HOP_DIST = 1.8; // ~200km in degrees
|
||||
let prefixIdx = {}; // lowercase hex prefix → [node, ...]
|
||||
let nodesList = [];
|
||||
|
||||
function dist(lat1, lon1, lat2, lon2) {
|
||||
return Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize (or rebuild) the prefix index from the full nodes list.
|
||||
* @param {Array} nodes - Array of {public_key, name, lat, lon, ...}
|
||||
*/
|
||||
function init(nodes) {
|
||||
nodesList = nodes || [];
|
||||
prefixIdx = {};
|
||||
for (const n of nodesList) {
|
||||
if (!n.public_key) continue;
|
||||
const pk = n.public_key.toLowerCase();
|
||||
for (let len = 1; len <= 3; len++) {
|
||||
const p = pk.slice(0, len * 2);
|
||||
if (!prefixIdx[p]) prefixIdx[p] = [];
|
||||
prefixIdx[p].push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an array of hex hop prefixes to node info.
|
||||
* Returns a map: { hop: {name, pubkey, lat, lon, ambiguous, unreliable} }
|
||||
*
|
||||
* @param {string[]} hops - Hex prefixes
|
||||
* @param {number|null} originLat - Sender latitude (forward anchor)
|
||||
* @param {number|null} originLon - Sender longitude (forward anchor)
|
||||
* @param {number|null} observerLat - Observer latitude (backward anchor)
|
||||
* @param {number|null} observerLon - Observer longitude (backward anchor)
|
||||
* @returns {Object} resolved map keyed by hop prefix
|
||||
*/
|
||||
function resolve(hops, originLat, originLon, observerLat, observerLon) {
|
||||
if (!hops || !hops.length) return {};
|
||||
|
||||
const resolved = {};
|
||||
const hopPositions = {};
|
||||
|
||||
// First pass: find candidates
|
||||
for (const hop of hops) {
|
||||
const h = hop.toLowerCase();
|
||||
const candidates = prefixIdx[h] || [];
|
||||
if (candidates.length === 0) {
|
||||
resolved[hop] = { name: null, candidates: [] };
|
||||
} else if (candidates.length === 1) {
|
||||
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key, candidates: [{ name: candidates[0].name, pubkey: candidates[0].public_key }] };
|
||||
} else {
|
||||
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key, ambiguous: true, candidates: candidates.map(c => ({ name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon })) };
|
||||
}
|
||||
}
|
||||
|
||||
// Build initial positions for unambiguous hops
|
||||
for (const hop of hops) {
|
||||
const r = resolved[hop];
|
||||
if (r && !r.ambiguous && r.pubkey) {
|
||||
const node = nodesList.find(n => n.public_key === r.pubkey);
|
||||
if (node && node.lat && node.lon && !(node.lat === 0 && node.lon === 0)) {
|
||||
hopPositions[hop] = { lat: node.lat, lon: node.lon };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Forward pass
|
||||
let lastPos = (originLat != null && originLon != null) ? { lat: originLat, lon: originLon } : null;
|
||||
for (let i = 0; i < hops.length; i++) {
|
||||
const hop = hops[i];
|
||||
if (hopPositions[hop]) { lastPos = hopPositions[hop]; continue; }
|
||||
const r = resolved[hop];
|
||||
if (!r || !r.ambiguous) continue;
|
||||
const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0));
|
||||
if (!withLoc.length) continue;
|
||||
let anchor = lastPos;
|
||||
if (!anchor && i === hops.length - 1 && observerLat != null) {
|
||||
anchor = { lat: observerLat, lon: observerLon };
|
||||
}
|
||||
if (anchor) {
|
||||
withLoc.sort((a, b) => dist(a.lat, a.lon, anchor.lat, anchor.lon) - dist(b.lat, b.lon, anchor.lat, anchor.lon));
|
||||
}
|
||||
r.name = withLoc[0].name;
|
||||
r.pubkey = withLoc[0].pubkey;
|
||||
hopPositions[hop] = { lat: withLoc[0].lat, lon: withLoc[0].lon };
|
||||
lastPos = hopPositions[hop];
|
||||
}
|
||||
|
||||
// Backward pass
|
||||
let nextPos = (observerLat != null && observerLon != null) ? { lat: observerLat, lon: observerLon } : null;
|
||||
for (let i = hops.length - 1; i >= 0; i--) {
|
||||
const hop = hops[i];
|
||||
if (hopPositions[hop]) { nextPos = hopPositions[hop]; continue; }
|
||||
const r = resolved[hop];
|
||||
if (!r || !r.ambiguous) continue;
|
||||
const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0));
|
||||
if (!withLoc.length || !nextPos) continue;
|
||||
withLoc.sort((a, b) => dist(a.lat, a.lon, nextPos.lat, nextPos.lon) - dist(b.lat, b.lon, nextPos.lat, nextPos.lon));
|
||||
r.name = withLoc[0].name;
|
||||
r.pubkey = withLoc[0].pubkey;
|
||||
hopPositions[hop] = { lat: withLoc[0].lat, lon: withLoc[0].lon };
|
||||
nextPos = hopPositions[hop];
|
||||
}
|
||||
|
||||
// Sanity check: drop hops impossibly far from neighbors
|
||||
for (let i = 0; i < hops.length; i++) {
|
||||
const pos = hopPositions[hops[i]];
|
||||
if (!pos) continue;
|
||||
const prev = i > 0 ? hopPositions[hops[i - 1]] : null;
|
||||
const next = i < hops.length - 1 ? hopPositions[hops[i + 1]] : null;
|
||||
if (!prev && !next) continue;
|
||||
const dPrev = prev ? dist(pos.lat, pos.lon, prev.lat, prev.lon) : 0;
|
||||
const dNext = next ? dist(pos.lat, pos.lon, next.lat, next.lon) : 0;
|
||||
const tooFarPrev = prev && dPrev > MAX_HOP_DIST;
|
||||
const tooFarNext = next && dNext > MAX_HOP_DIST;
|
||||
if ((tooFarPrev && tooFarNext) || (tooFarPrev && !next) || (tooFarNext && !prev)) {
|
||||
const r = resolved[hops[i]];
|
||||
if (r) r.unreliable = true;
|
||||
delete hopPositions[hops[i]];
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the resolver has been initialized with nodes.
|
||||
*/
|
||||
function ready() {
|
||||
return nodesList.length > 0;
|
||||
}
|
||||
|
||||
return { init: init, resolve: resolve, ready: ready };
|
||||
})();
|
||||
@@ -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=1774042199">
|
||||
<link rel="stylesheet" href="style.css?v=1774138896">
|
||||
<link rel="stylesheet" href="home.css">
|
||||
<link rel="stylesheet" href="live.css?v=1774034490">
|
||||
<link rel="stylesheet" href="live.css?v=1774058575">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="anonymous">
|
||||
@@ -79,19 +79,21 @@
|
||||
<main id="app" role="main"></main>
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=1774028201"></script>
|
||||
<script src="app.js?v=1774034748"></script>
|
||||
<script src="roles.js?v=1774290000"></script>
|
||||
<script src="region-filter.js?v=1774136865"></script>
|
||||
<script src="hop-resolver.js?v=1774126708"></script>
|
||||
<script src="app.js?v=1774126708"></script>
|
||||
<script src="home.js?v=1774042199"></script>
|
||||
<script src="packets.js?v=1774051434"></script>
|
||||
<script src="map.js?v=1774028201" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774050030" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774050030" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774048777" 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=1774050030" 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="packets.js?v=1774141832"></script>
|
||||
<script src="map.js?v=1774126708" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774331200" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774126708" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774135052" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774126708" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774126708" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774290000" 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="node-analytics.js?v=1774126708" 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>
|
||||
|
||||
@@ -642,7 +642,7 @@
|
||||
.vcr-prompt-btn:hover { background: rgba(59,130,246,0.3); }
|
||||
|
||||
/* Adjust feed position to not overlap VCR bar */
|
||||
.live-feed { bottom: 58px; }
|
||||
.live-feed { bottom: 68px; }
|
||||
.feed-show-btn { bottom: 68px !important; }
|
||||
|
||||
/* Mobile VCR */
|
||||
|
||||
309
public/live.js
309
public/live.js
@@ -11,6 +11,9 @@
|
||||
let audioCtx = null;
|
||||
let soundEnabled = false;
|
||||
let showGhostHops = localStorage.getItem('live-ghost-hops') !== 'false';
|
||||
let realisticPropagation = localStorage.getItem('live-realistic-propagation') === 'true';
|
||||
let showOnlyFavorites = localStorage.getItem('live-favorites-only') === 'true';
|
||||
const propagationBuffer = new Map(); // hash -> {timer, packets[]}
|
||||
let _onResize = null;
|
||||
let _navCleanup = null;
|
||||
let _timelineRefreshInterval = null;
|
||||
@@ -423,6 +426,21 @@
|
||||
}
|
||||
|
||||
// Buffer a packet from WS
|
||||
let _tabHidden = false;
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
_tabHidden = true;
|
||||
} else {
|
||||
// Tab restored — skip animating anything that queued while away
|
||||
_tabHidden = false;
|
||||
// Clear any pending propagation buffers so they don't all fire at once
|
||||
for (const [hash, entry] of propagationBuffer) {
|
||||
clearTimeout(entry.timer);
|
||||
}
|
||||
propagationBuffer.clear();
|
||||
}
|
||||
});
|
||||
|
||||
function bufferPacket(pkt) {
|
||||
pkt._ts = Date.now();
|
||||
const entry = { ts: pkt._ts, pkt };
|
||||
@@ -437,7 +455,26 @@
|
||||
}
|
||||
|
||||
if (VCR.mode === 'LIVE') {
|
||||
animatePacket(pkt);
|
||||
// Skip animations when tab is backgrounded — just buffer for VCR timeline
|
||||
if (_tabHidden) {
|
||||
updateTimeline();
|
||||
return;
|
||||
}
|
||||
if (realisticPropagation && pkt.hash) {
|
||||
const hash = pkt.hash;
|
||||
if (propagationBuffer.has(hash)) {
|
||||
propagationBuffer.get(hash).packets.push(pkt);
|
||||
} else {
|
||||
const entry = { packets: [pkt], timer: setTimeout(() => {
|
||||
const buffered = propagationBuffer.get(hash);
|
||||
propagationBuffer.delete(hash);
|
||||
if (buffered) animateRealisticPropagation(buffered.packets);
|
||||
}, PROPAGATION_BUFFER_MS) };
|
||||
propagationBuffer.set(hash, entry);
|
||||
}
|
||||
} else {
|
||||
animatePacket(pkt);
|
||||
}
|
||||
updateTimeline();
|
||||
} else if (VCR.mode === 'PAUSED') {
|
||||
VCR.missedCount++;
|
||||
@@ -592,6 +629,10 @@
|
||||
<span id="heatDesc" class="sr-only">Overlay a density heat map on the mesh nodes</span>
|
||||
<label><input type="checkbox" id="liveGhostToggle" checked aria-describedby="ghostDesc"> Ghosts</label>
|
||||
<span id="ghostDesc" class="sr-only">Show interpolated ghost markers for unknown hops</span>
|
||||
<label><input type="checkbox" id="liveRealisticToggle" aria-describedby="realisticDesc"> Realistic</label>
|
||||
<span id="realisticDesc" class="sr-only">Buffer packets by hash and animate all paths simultaneously</span>
|
||||
<label><input type="checkbox" id="liveFavoritesToggle" aria-describedby="favDesc"> ⭐ Favorites</label>
|
||||
<span id="favDesc" class="sr-only">Show only favorited and claimed nodes</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="live-overlay live-feed" id="liveFeed">
|
||||
@@ -645,10 +686,19 @@
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Fetch configurable map defaults (#115)
|
||||
let mapCenter = [37.45, -122.0];
|
||||
let mapZoom = 9;
|
||||
try {
|
||||
const mapCfg = await (await fetch('/api/config/map')).json();
|
||||
if (Array.isArray(mapCfg.center) && mapCfg.center.length === 2) mapCenter = mapCfg.center;
|
||||
if (typeof mapCfg.zoom === 'number') mapZoom = mapCfg.zoom;
|
||||
} catch {}
|
||||
|
||||
map = L.map('liveMap', {
|
||||
zoomControl: false, attributionControl: false,
|
||||
zoomAnimation: true, markerZoomAnimation: true
|
||||
}).setView([37.45, -122.0], 9);
|
||||
}).setView(mapCenter, mapZoom);
|
||||
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
@@ -674,14 +724,27 @@
|
||||
initResizeHandler();
|
||||
startRateCounter();
|
||||
|
||||
// Check for single packet replay from packets page
|
||||
// Check for packet replay from packets page (single or array of observations)
|
||||
const replayData = sessionStorage.getItem('replay-packet');
|
||||
if (replayData) {
|
||||
sessionStorage.removeItem('replay-packet');
|
||||
try {
|
||||
const pkt = JSON.parse(replayData);
|
||||
const parsed = JSON.parse(replayData);
|
||||
const packets = Array.isArray(parsed) ? parsed : [parsed];
|
||||
vcrPause(); // suppress live packets
|
||||
setTimeout(() => animatePacket(pkt), 1500);
|
||||
if (packets.length > 1 && packets[0].hash) {
|
||||
// Multiple observations — use realistic propagation (animate all paths at once)
|
||||
setTimeout(() => {
|
||||
if (typeof animateRealisticPropagation === 'function') {
|
||||
animateRealisticPropagation(packets);
|
||||
} else {
|
||||
// Fallback: stagger animations
|
||||
packets.forEach((p, i) => setTimeout(() => animatePacket(p), i * 400));
|
||||
}
|
||||
}, 1500);
|
||||
} else {
|
||||
setTimeout(() => animatePacket(packets[0]), 1500);
|
||||
}
|
||||
} catch {}
|
||||
} else {
|
||||
replayRecent();
|
||||
@@ -708,6 +771,21 @@
|
||||
localStorage.setItem('live-ghost-hops', showGhostHops);
|
||||
});
|
||||
|
||||
const realisticToggle = document.getElementById('liveRealisticToggle');
|
||||
realisticToggle.checked = realisticPropagation;
|
||||
realisticToggle.addEventListener('change', (e) => {
|
||||
realisticPropagation = e.target.checked;
|
||||
localStorage.setItem('live-realistic-propagation', realisticPropagation);
|
||||
});
|
||||
|
||||
const favoritesToggle = document.getElementById('liveFavoritesToggle');
|
||||
favoritesToggle.checked = showOnlyFavorites;
|
||||
favoritesToggle.addEventListener('change', (e) => {
|
||||
showOnlyFavorites = e.target.checked;
|
||||
localStorage.setItem('live-favorites-only', showOnlyFavorites);
|
||||
applyFavoritesFilter();
|
||||
});
|
||||
|
||||
// Feed show/hide
|
||||
const feedEl = document.getElementById('liveFeed');
|
||||
// Keyboard support for feed items (event delegation)
|
||||
@@ -1040,12 +1118,48 @@
|
||||
'</div>';
|
||||
}
|
||||
|
||||
html += `<div id="liveNodePaths" style="margin-top:8px;"><div style="font-size:11px;color:var(--text-muted);padding:4px 0;"><span class="spinner" style="font-size:10px"></span> Loading paths…</div></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;
|
||||
|
||||
// Fetch paths asynchronously
|
||||
api('/nodes/' + encodeURIComponent(n.public_key) + '/paths', { ttl: 300 }).then(pathData => {
|
||||
const pathEl = document.getElementById('liveNodePaths');
|
||||
if (!pathEl) return;
|
||||
if (!pathData || !pathData.paths || !pathData.paths.length) {
|
||||
pathEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const COLLAPSE = 5;
|
||||
function renderPathList(paths) {
|
||||
return paths.map(p => {
|
||||
const chain = p.hops.map(h => {
|
||||
const isThis = h.pubkey === n.public_key || (h.prefix && n.public_key.toLowerCase().startsWith(h.prefix.toLowerCase()));
|
||||
const name = escapeHtml(h.name || h.prefix);
|
||||
if (isThis) return `<strong style="color:var(--accent)">${name}</strong>`;
|
||||
return h.pubkey ? `<a href="#/nodes/${h.pubkey}" style="color:var(--text-primary);text-decoration:none">${name}</a>` : name;
|
||||
}).join(' → ');
|
||||
return `<div style="padding:3px 0;font-size:11px;line-height:1.4">${chain} <span style="color:var(--text-muted)">(${p.count}×)</span></div>`;
|
||||
}).join('');
|
||||
}
|
||||
pathEl.innerHTML = `<h4 style="font-size:12px;margin:8px 0 4px;color:var(--text-muted);">Paths Through (${pathData.totalPaths})</h4>` +
|
||||
`<div id="livePathsList" style="max-height:200px;overflow-y:auto;">` +
|
||||
renderPathList(pathData.paths.slice(0, COLLAPSE)) +
|
||||
(pathData.paths.length > COLLAPSE ? `<button id="showMorePaths" style="font-size:11px;color:var(--accent);background:none;border:none;cursor:pointer;padding:4px 0;">Show all ${pathData.paths.length} paths</button>` : '') +
|
||||
'</div>';
|
||||
const moreBtn = document.getElementById('showMorePaths');
|
||||
if (moreBtn) moreBtn.addEventListener('click', () => {
|
||||
document.getElementById('livePathsList').innerHTML = renderPathList(pathData.paths);
|
||||
});
|
||||
}).catch(() => {
|
||||
const pathEl = document.getElementById('liveNodePaths');
|
||||
if (pathEl) pathEl.innerHTML = '';
|
||||
});
|
||||
} catch (e) {
|
||||
content.innerHTML = `<div style="padding:20px;color:var(--text-muted);">Error: ${e.message}</div>`;
|
||||
}
|
||||
@@ -1078,6 +1192,74 @@
|
||||
if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; }
|
||||
}
|
||||
|
||||
function getFavoritePubkeys() {
|
||||
let favs = [];
|
||||
try { favs = favs.concat(JSON.parse(localStorage.getItem('meshcore-favorites') || '[]')); } catch {}
|
||||
try { favs = favs.concat(JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]').map(n => n.pubkey)); } catch {}
|
||||
return favs.filter(Boolean);
|
||||
}
|
||||
|
||||
function packetInvolvesFavorite(pkt) {
|
||||
const favs = getFavoritePubkeys();
|
||||
if (favs.length === 0) return false;
|
||||
const decoded = pkt.decoded || {};
|
||||
const payload = decoded.payload || {};
|
||||
const hops = decoded.path?.hops || [];
|
||||
|
||||
// Full pubkeys: sender
|
||||
if (payload.pubKey && favs.some(f => f === payload.pubKey)) return true;
|
||||
|
||||
// Observer: may be name or pubkey
|
||||
const obs = pkt.observer_name || pkt.observer || '';
|
||||
if (obs) {
|
||||
if (favs.some(f => f === obs)) return true;
|
||||
for (const nd of Object.values(nodeData)) {
|
||||
if ((nd.name === obs || nd.public_key === obs) && favs.some(f => f === nd.public_key)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Hops are truncated hex prefixes — match by prefix in either direction
|
||||
for (const hop of hops) {
|
||||
const h = (hop.id || hop.public_key || hop).toString().toLowerCase();
|
||||
if (favs.some(f => f.toLowerCase().startsWith(h) || h.startsWith(f.toLowerCase()))) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isNodeFavorited(pubkey) {
|
||||
return getFavoritePubkeys().some(f => f === pubkey);
|
||||
}
|
||||
|
||||
function rebuildFeedList() {
|
||||
const feed = document.getElementById('liveFeed');
|
||||
if (!feed) return;
|
||||
// Remove all feed items but keep the hide button and resize handle
|
||||
feed.querySelectorAll('.live-feed-item').forEach(el => el.remove());
|
||||
// Re-add from VCR buffer (most recent first, up to 25)
|
||||
const entries = VCR.buffer.slice(-100).reverse();
|
||||
let count = 0;
|
||||
for (const entry of entries) {
|
||||
if (count >= 25) break;
|
||||
const pkt = entry.pkt;
|
||||
if (showOnlyFavorites && !packetInvolvesFavorite(pkt)) continue;
|
||||
const decoded = pkt.decoded || {};
|
||||
const header = decoded.header || {};
|
||||
const payload = decoded.payload || {};
|
||||
const typeName = header.payloadTypeName || 'UNKNOWN';
|
||||
const icon = PAYLOAD_ICONS[typeName] || '📦';
|
||||
const hops = decoded.path?.hops || [];
|
||||
const color = TYPE_COLORS[typeName] || '#6b7280';
|
||||
addFeedItemDOM(icon, typeName, payload, hops, color, pkt, feed);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFavoritesFilter() {
|
||||
// Node markers always stay visible — only rebuild the feed list
|
||||
rebuildFeedList();
|
||||
}
|
||||
|
||||
function addNodeMarker(n) {
|
||||
if (nodeMarkers[n.public_key]) return nodeMarkers[n.public_key];
|
||||
const color = ROLE_COLORS[n.role] || ROLE_COLORS.unknown;
|
||||
@@ -1166,6 +1348,9 @@
|
||||
playSound(typeName);
|
||||
addFeedItem(icon, typeName, payload, hops, color, pkt);
|
||||
|
||||
// Favorites filter: skip animation if packet doesn't involve a favorited node
|
||||
if (showOnlyFavorites && !packetInvolvesFavorite(pkt)) return;
|
||||
|
||||
// If ADVERT, ensure node appears on map
|
||||
if (typeName === 'ADVERT' && payload.pubKey) {
|
||||
const key = payload.pubKey;
|
||||
@@ -1183,6 +1368,94 @@
|
||||
animatePath(hopPositions, typeName, color);
|
||||
}
|
||||
|
||||
function animateRealisticPropagation(packets) {
|
||||
if (!packets.length) return;
|
||||
const first = packets[0];
|
||||
const decoded = first.decoded || {};
|
||||
const header = decoded.header || {};
|
||||
const typeName = header.payloadTypeName || 'UNKNOWN';
|
||||
const color = TYPE_COLORS[typeName] || '#6b7280';
|
||||
const icon = PAYLOAD_ICONS[typeName] || '📦';
|
||||
const payload = decoded.payload || {};
|
||||
|
||||
packetCount += packets.length;
|
||||
pktTimestamps.push(Date.now());
|
||||
const _el = document.getElementById('livePktCount'); if (_el) _el.textContent = packetCount;
|
||||
|
||||
// Favorites filter: skip if none of the packets involve a favorite
|
||||
if (showOnlyFavorites && !packets.some(p => packetInvolvesFavorite(p))) return;
|
||||
|
||||
playSound(typeName);
|
||||
|
||||
// Ensure ADVERT nodes appear
|
||||
for (const pkt of packets) {
|
||||
const d = pkt.decoded || {};
|
||||
const h = d.header || {};
|
||||
const p = d.payload || {};
|
||||
if (h.payloadTypeName === 'ADVERT' && p.pubKey) {
|
||||
const key = p.pubKey;
|
||||
if (!nodeMarkers[key] && p.lat != null && p.lon != null && !(p.lat === 0 && p.lon === 0)) {
|
||||
const n = { public_key: key, name: p.name || key.slice(0,8), role: p.role || 'unknown', lat: p.lat, lon: p.lon };
|
||||
nodeData[key] = n;
|
||||
addNodeMarker(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
const _el2 = document.getElementById('liveNodeCount'); if (_el2) _el2.textContent = Object.keys(nodeMarkers).length;
|
||||
|
||||
// Resolve all unique paths
|
||||
const allPaths = [];
|
||||
const seenPathKeys = new Set();
|
||||
const observers = new Set();
|
||||
for (const pkt of packets) {
|
||||
const d = pkt.decoded || {};
|
||||
const p = d.payload || {};
|
||||
const hops = d.path?.hops || [];
|
||||
if (pkt.observer) observers.add(pkt.observer);
|
||||
const pathKey = hops.join(',');
|
||||
if (seenPathKeys.has(pathKey)) continue;
|
||||
seenPathKeys.add(pathKey);
|
||||
const hopPositions = resolveHopPositions(hops, p);
|
||||
if (hopPositions.length >= 2) allPaths.push(hopPositions);
|
||||
}
|
||||
|
||||
// Consolidated feed item
|
||||
const hops0 = decoded.path?.hops || [];
|
||||
const text = payload.text || payload.name || '';
|
||||
const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
|
||||
const feed = document.getElementById('liveFeed');
|
||||
if (feed) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'live-feed-item live-feed-enter';
|
||||
item.setAttribute('tabindex', '0');
|
||||
item.setAttribute('role', 'button');
|
||||
item.style.cursor = 'pointer';
|
||||
item.innerHTML = `
|
||||
<span class="feed-icon" style="color:${color}">${icon}</span>
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
<span class="feed-hops">${allPaths.length}⇢ ${observers.size}👁</span>
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time">${new Date(first._ts || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}</span>
|
||||
`;
|
||||
item.addEventListener('click', () => showFeedCard(item, first, color));
|
||||
feed.prepend(item);
|
||||
requestAnimationFrame(() => { requestAnimationFrame(() => item.classList.remove('live-feed-enter')); });
|
||||
while (feed.children.length > 25) feed.removeChild(feed.lastChild);
|
||||
}
|
||||
|
||||
if (allPaths.length === 0) {
|
||||
// Single hop or unresolvable — just pulse origin if possible
|
||||
const hp0 = resolveHopPositions(decoded.path?.hops || [], payload);
|
||||
if (hp0.length >= 1) pulseNode(hp0[0].key, hp0[0].pos, typeName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Animate all paths simultaneously
|
||||
for (const hopPositions of allPaths) {
|
||||
animatePath(hopPositions, typeName, color);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHopPositions(hops, payload) {
|
||||
const known = Object.values(nodeData);
|
||||
|
||||
@@ -1467,10 +1740,34 @@
|
||||
if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; }
|
||||
}
|
||||
|
||||
function addFeedItemDOM(icon, typeName, payload, hops, color, pkt, feed) {
|
||||
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';
|
||||
item.setAttribute('tabindex', '0');
|
||||
item.setAttribute('role', 'button');
|
||||
item.style.cursor = 'pointer';
|
||||
item.innerHTML = `
|
||||
<span class="feed-icon" style="color:${color}">${icon}</span>
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
${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>
|
||||
`;
|
||||
item.addEventListener('click', () => showFeedCard(item, pkt, color));
|
||||
feed.appendChild(item);
|
||||
}
|
||||
|
||||
function addFeedItem(icon, typeName, payload, hops, color, pkt) {
|
||||
const feed = document.getElementById('liveFeed');
|
||||
if (!feed) return;
|
||||
|
||||
// Favorites filter: skip feed item if packet doesn't involve a favorite
|
||||
if (showOnlyFavorites && !packetInvolvesFavorite(pkt)) return;
|
||||
|
||||
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>` : '';
|
||||
@@ -1525,7 +1822,7 @@
|
||||
${rssi != null ? `<span>📡 ${rssi} dBm</span>` : ''}
|
||||
${observer ? `<span>👁 ${escapeHtml(observer)}</span>` : ''}
|
||||
</div>
|
||||
${pkt.hash ? `<a class="fdc-link" href="#/packets/${pkt.hash}">View in packets →</a>` : ''}
|
||||
${pkt.hash ? `<a class="fdc-link" href="#/packets/${pkt.hash.toLowerCase()}">View in packets →</a>` : ''}
|
||||
<button class="fdc-replay">↻ Replay</button>
|
||||
`;
|
||||
card.querySelector('.fdc-close').addEventListener('click', (e) => { e.stopPropagation(); card.remove(); });
|
||||
|
||||
237
public/map.js
237
public/map.js
@@ -8,7 +8,7 @@
|
||||
let clusterGroup = null;
|
||||
let nodes = [];
|
||||
let observers = [];
|
||||
let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clusters: false };
|
||||
let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clusters: false, hashLabels: localStorage.getItem('meshcore-map-hash-labels') !== 'false' };
|
||||
let wsHandler = null;
|
||||
let heatLayer = null;
|
||||
let userHasMoved = false;
|
||||
@@ -60,7 +60,24 @@
|
||||
});
|
||||
}
|
||||
|
||||
function init(container) {
|
||||
function makeRepeaterLabelIcon(node) {
|
||||
var s = ROLE_STYLE['repeater'] || ROLE_STYLE.companion;
|
||||
var hs = node.hash_size || 1;
|
||||
// Show the short mesh hash ID (first N bytes of pubkey, uppercased)
|
||||
var shortHash = node.public_key ? node.public_key.slice(0, hs * 2).toUpperCase() : '??';
|
||||
var bgColor = node.hash_size ? s.color : '#888';
|
||||
var html = '<div style="background:' + bgColor + ';color:#fff;font-weight:bold;font-size:11px;padding:2px 5px;border-radius:3px;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,0.4);text-align:center;line-height:1.2;white-space:nowrap;">' +
|
||||
shortHash + '</div>';
|
||||
return L.divIcon({
|
||||
html: html,
|
||||
className: 'meshcore-marker meshcore-label-marker',
|
||||
iconSize: null,
|
||||
iconAnchor: [14, 12],
|
||||
popupAnchor: [0, -12],
|
||||
});
|
||||
}
|
||||
|
||||
async function init(container) {
|
||||
container.innerHTML = `
|
||||
<div id="map-wrap" style="position:relative;width:100%;height:100%;">
|
||||
<div id="leaflet-map" style="width:100%;height:100%;"></div>
|
||||
@@ -75,6 +92,7 @@
|
||||
<legend class="mc-label">Display</legend>
|
||||
<label for="mcClusters"><input type="checkbox" id="mcClusters"> Show clusters</label>
|
||||
<label for="mcHeatmap"><input type="checkbox" id="mcHeatmap"> Heat map</label>
|
||||
<label for="mcHashLabels"><input type="checkbox" id="mcHashLabels"> Hash prefix labels</label>
|
||||
</fieldset>
|
||||
<fieldset class="mc-section">
|
||||
<legend class="mc-label">Filters</legend>
|
||||
@@ -98,9 +116,14 @@
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Init Leaflet — restore saved position or default to Bay Area
|
||||
const defaultCenter = [37.6, -122.1];
|
||||
const defaultZoom = 9;
|
||||
// Init Leaflet — restore saved position or use configurable defaults (#115)
|
||||
let defaultCenter = [37.6, -122.1];
|
||||
let defaultZoom = 9;
|
||||
try {
|
||||
const mapCfg = await (await fetch('/api/config/map')).json();
|
||||
if (Array.isArray(mapCfg.center) && mapCfg.center.length === 2) defaultCenter = mapCfg.center;
|
||||
if (typeof mapCfg.zoom === 'number') defaultZoom = mapCfg.zoom;
|
||||
} catch {}
|
||||
let initCenter = defaultCenter;
|
||||
let initZoom = defaultZoom;
|
||||
const savedView = localStorage.getItem('map-view');
|
||||
@@ -129,6 +152,10 @@
|
||||
userHasMoved = true;
|
||||
});
|
||||
|
||||
map.on('zoomend', () => {
|
||||
if (filters.hashLabels && !_renderingMarkers) renderMarkers();
|
||||
});
|
||||
|
||||
markerLayer = L.layerGroup().addTo(map);
|
||||
routeLayer = L.layerGroup().addTo(map);
|
||||
|
||||
@@ -154,6 +181,13 @@
|
||||
document.getElementById('mcClusters').addEventListener('change', e => { filters.clusters = e.target.checked; renderMarkers(); });
|
||||
document.getElementById('mcHeatmap').addEventListener('change', e => { toggleHeatmap(e.target.checked); });
|
||||
document.getElementById('mcNeighbors').addEventListener('change', e => { filters.neighbors = e.target.checked; renderMarkers(); });
|
||||
|
||||
// Hash Labels toggle
|
||||
const hashLabelEl = document.getElementById('mcHashLabels');
|
||||
if (hashLabelEl) {
|
||||
hashLabelEl.checked = filters.hashLabels;
|
||||
hashLabelEl.addEventListener('change', e => { filters.hashLabels = e.target.checked; localStorage.setItem('meshcore-map-hash-labels', filters.hashLabels); renderMarkers(); });
|
||||
}
|
||||
document.getElementById('mcLastHeard').addEventListener('change', e => { filters.lastHeard = e.target.value; loadNodes(); });
|
||||
|
||||
// WS for live advert updates
|
||||
@@ -169,14 +203,42 @@
|
||||
if (routeHopsJson) {
|
||||
sessionStorage.removeItem('map-route-hops');
|
||||
try {
|
||||
const hopKeys = JSON.parse(routeHopsJson);
|
||||
drawPacketRoute(hopKeys);
|
||||
const parsed = JSON.parse(routeHopsJson);
|
||||
// Support new format {origin, hops} and legacy plain array
|
||||
if (Array.isArray(parsed)) {
|
||||
drawPacketRoute(parsed, null);
|
||||
} else {
|
||||
drawPacketRoute(parsed.hops || [], parsed.origin || null);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function drawPacketRoute(hopKeys) {
|
||||
function drawPacketRoute(hopKeys, origin) {
|
||||
// Hide default markers so only the route is visible
|
||||
if (markerLayer) map.removeLayer(markerLayer);
|
||||
if (clusterGroup) map.removeLayer(clusterGroup);
|
||||
if (heatLayer) map.removeLayer(heatLayer);
|
||||
|
||||
routeLayer.clearLayers();
|
||||
|
||||
// Add close route button
|
||||
const closeBtn = L.control({ position: 'topright' });
|
||||
closeBtn.onAdd = function () {
|
||||
const div = L.DomUtil.create('div', 'leaflet-bar');
|
||||
div.innerHTML = '<a href="#" title="Close route" style="font-size:18px;font-weight:bold;text-decoration:none;display:block;width:36px;height:36px;line-height:36px;text-align:center;background:var(--bg-secondary,#1e293b);color:var(--text-primary,#e2e8f0);border-radius:4px">✕</a>';
|
||||
L.DomEvent.on(div, 'click', function (e) {
|
||||
L.DomEvent.preventDefault(e);
|
||||
routeLayer.clearLayers();
|
||||
if (markerLayer) map.addLayer(markerLayer);
|
||||
if (clusterGroup) map.addLayer(clusterGroup);
|
||||
map.removeControl(closeBtn);
|
||||
});
|
||||
return div;
|
||||
};
|
||||
closeBtn.addTo(map);
|
||||
|
||||
// Resolve hop short hashes to node positions with geographic disambiguation
|
||||
const raw = hopKeys.map(hop => {
|
||||
const hopLower = hop.toLowerCase();
|
||||
@@ -213,29 +275,52 @@
|
||||
}
|
||||
|
||||
const positions = raw.filter(h => h && h.resolved);
|
||||
|
||||
// Resolve and prepend origin node
|
||||
if (origin) {
|
||||
let originPos = null;
|
||||
if (origin.lat != null && origin.lon != null) {
|
||||
originPos = { lat: origin.lat, lon: origin.lon, name: origin.name || 'Sender', pubkey: origin.pubkey, isOrigin: true };
|
||||
} else if (origin.pubkey) {
|
||||
const pk = origin.pubkey.toLowerCase();
|
||||
const match = nodes.find(n => n.public_key.toLowerCase() === pk || n.public_key.toLowerCase().startsWith(pk));
|
||||
if (match && match.lat != null && match.lon != null) {
|
||||
originPos = { lat: match.lat, lon: match.lon, name: origin.name || match.name || 'Sender', pubkey: match.public_key, role: match.role, isOrigin: true };
|
||||
}
|
||||
}
|
||||
if (originPos) positions.unshift(originPos);
|
||||
}
|
||||
|
||||
if (positions.length < 1) return;
|
||||
|
||||
// Even a single node is worth showing (zoom to it)
|
||||
const coords = positions.map(p => [p.lat, p.lon]);
|
||||
|
||||
if (positions.length >= 2) {
|
||||
// Draw route polyline
|
||||
L.polyline(coords, {
|
||||
color: '#f59e0b', weight: 3, opacity: 0.8, dashArray: '8 4'
|
||||
}).addTo(routeLayer);
|
||||
}
|
||||
|
||||
// Add numbered markers at each hop
|
||||
var labelItems = [];
|
||||
positions.forEach((p, i) => {
|
||||
const color = i === 0 ? '#22c55e' : i === positions.length - 1 ? '#ef4444' : '#f59e0b';
|
||||
const label = i === 0 ? 'Origin' : i === positions.length - 1 ? 'Destination' : `Hop ${i}`;
|
||||
const isOrigin = i === 0 && p.isOrigin;
|
||||
const isLast = i === positions.length - 1 && positions.length > 1;
|
||||
const color = isOrigin ? '#06b6d4' : isLast ? '#ef4444' : i === 0 ? '#22c55e' : '#f59e0b';
|
||||
const radius = isOrigin ? 14 : 10;
|
||||
const label = isOrigin ? 'Sender' : isLast ? 'Last Hop' : `Hop ${isOrigin ? i : i}`;
|
||||
|
||||
if (isOrigin) {
|
||||
L.circleMarker([p.lat, p.lon], {
|
||||
radius: radius + 4, fillColor: 'transparent', fillOpacity: 0, color: '#06b6d4', weight: 2, opacity: 0.6
|
||||
}).addTo(routeLayer);
|
||||
}
|
||||
|
||||
const marker = L.circleMarker([p.lat, p.lon], {
|
||||
radius: 10, fillColor: color,
|
||||
radius: radius, fillColor: color,
|
||||
fillOpacity: 0.9, color: '#fff', weight: 2
|
||||
}).addTo(routeLayer);
|
||||
|
||||
marker.bindTooltip(`${i + 1}. ${p.name}`, { permanent: true, direction: 'top', className: 'route-tooltip' });
|
||||
|
||||
|
||||
const popupHtml = `<div style="font-size:12px;min-width:160px">
|
||||
<div style="font-weight:700;margin-bottom:4px">${label}: ${safeEsc(p.name)}</div>
|
||||
<div style="color:#9ca3af;font-size:11px;margin-bottom:4px">${p.role || 'unknown'}</div>
|
||||
@@ -244,6 +329,19 @@
|
||||
${p.pubkey ? `<div style="margin-top:6px"><a href="#/nodes/${p.pubkey}" style="color:var(--accent);font-size:11px">View Node →</a></div>` : ''}
|
||||
</div>`;
|
||||
marker.bindPopup(popupHtml, { className: 'route-popup' });
|
||||
|
||||
labelItems.push({ latLng: L.latLng(p.lat, p.lon), isLabel: true, text: `${i + 1}. ${p.name}` });
|
||||
});
|
||||
|
||||
// Deconflict labels so overlapping hop names spread out
|
||||
deconflictLabels(labelItems, map);
|
||||
labelItems.forEach(function (m) {
|
||||
var pos = m.adjustedLatLng || m.latLng;
|
||||
var icon = L.divIcon({ className: 'route-tooltip', html: m.text, iconSize: [null, null], iconAnchor: [0, 0] });
|
||||
L.marker(pos, { icon: icon, interactive: false }).addTo(routeLayer);
|
||||
if (m.offset > 2) {
|
||||
L.polyline([m.latLng, pos], { weight: 1, color: '#475569', opacity: 0.5, dashArray: '3 3' }).addTo(routeLayer);
|
||||
}
|
||||
});
|
||||
|
||||
// Fit map to route
|
||||
@@ -345,7 +443,65 @@
|
||||
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
|
||||
}
|
||||
|
||||
var _renderingMarkers = false;
|
||||
var _lastDeconflictZoom = null;
|
||||
|
||||
function deconflictLabels(markers, mapRef) {
|
||||
const placed = [];
|
||||
const PAD = 4;
|
||||
|
||||
var overlaps = function(b) {
|
||||
for (var k = 0; k < placed.length; k++) {
|
||||
var p = placed[k];
|
||||
if (b.x < p.x + p.w + PAD && b.x + b.w + PAD > p.x &&
|
||||
b.y < p.y + p.h + PAD && b.y + b.h + PAD > p.y) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Spiral offsets — 6 rings, 8 directions, up to ~132px
|
||||
var offsets = [];
|
||||
for (var ring = 1; ring <= 6; ring++) {
|
||||
var dist = ring * 22;
|
||||
for (var angle = 0; angle < 360; angle += 45) {
|
||||
var rad = angle * Math.PI / 180;
|
||||
offsets.push([Math.round(Math.cos(rad) * dist), Math.round(Math.sin(rad) * dist)]);
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < markers.length; i++) {
|
||||
var m = markers[i];
|
||||
var w = m.isLabel ? 38 : 20;
|
||||
var h = m.isLabel ? 24 : 20;
|
||||
var pt = mapRef.latLngToLayerPoint(m.latLng);
|
||||
var bestPt = pt;
|
||||
var box = { x: pt.x - w / 2, y: pt.y - h / 2, w: w, h: h };
|
||||
|
||||
if (overlaps(box)) {
|
||||
for (var j = 0; j < offsets.length; j++) {
|
||||
var tryPt = L.point(pt.x + offsets[j][0], pt.y + offsets[j][1]);
|
||||
var tryBox = { x: tryPt.x - w / 2, y: tryPt.y - h / 2, w: w, h: h };
|
||||
if (!overlaps(tryBox)) {
|
||||
bestPt = tryPt;
|
||||
box = tryBox;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
placed.push(box);
|
||||
m.adjustedLatLng = mapRef.layerPointToLatLng(bestPt);
|
||||
m.offset = Math.sqrt(Math.pow(bestPt.x - pt.x, 2) + Math.pow(bestPt.y - pt.y, 2));
|
||||
}
|
||||
}
|
||||
|
||||
function renderMarkers() {
|
||||
if (_renderingMarkers) return;
|
||||
_renderingMarkers = true;
|
||||
try { _renderMarkersInner(); } finally { _renderingMarkers = false; }
|
||||
}
|
||||
|
||||
function _renderMarkersInner() {
|
||||
markerLayer.clearLayers();
|
||||
|
||||
const filtered = nodes.filter(n => {
|
||||
@@ -354,15 +510,13 @@
|
||||
return true;
|
||||
});
|
||||
|
||||
for (const node of filtered) {
|
||||
const icon = makeMarkerIcon(node.role || 'companion');
|
||||
const marker = L.marker([node.lat, node.lon], {
|
||||
icon,
|
||||
alt: `${node.name || 'Unknown'} (${node.role || 'node'})`,
|
||||
});
|
||||
const allMarkers = [];
|
||||
|
||||
marker.bindPopup(buildPopup(node), { maxWidth: 280 });
|
||||
markerLayer.addLayer(marker);
|
||||
for (const node of filtered) {
|
||||
const useLabel = node.role === 'repeater' && filters.hashLabels;
|
||||
const icon = useLabel ? makeRepeaterLabelIcon(node) : makeMarkerIcon(node.role || 'companion');
|
||||
const latLng = L.latLng(node.lat, node.lon);
|
||||
allMarkers.push({ latLng, node, icon, isLabel: useLabel, popupFn: function() { return buildPopup(node); }, alt: (node.name || 'Unknown') + ' (' + (node.role || 'node') + ')' });
|
||||
}
|
||||
|
||||
// Add observer markers
|
||||
@@ -370,12 +524,32 @@
|
||||
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)`,
|
||||
const latLng = L.latLng(obs.lat, obs.lon);
|
||||
allMarkers.push({ latLng, node: obs, icon, isLabel: false, popupFn: function() { return buildObserverPopup(obs); }, alt: (obs.name || obs.id || 'Unknown') + ' (observer)' });
|
||||
}
|
||||
}
|
||||
|
||||
// Deconflict ALL markers
|
||||
if (allMarkers.length > 0) {
|
||||
deconflictLabels(allMarkers, map);
|
||||
}
|
||||
|
||||
for (const m of allMarkers) {
|
||||
const pos = m.adjustedLatLng || m.latLng;
|
||||
const marker = L.marker(pos, { icon: m.icon, alt: m.alt });
|
||||
marker.bindPopup(m.popupFn(), { maxWidth: 280 });
|
||||
markerLayer.addLayer(marker);
|
||||
|
||||
if (m.offset > 10) {
|
||||
const line = L.polyline([m.latLng, pos], {
|
||||
color: '#ef4444', weight: 2, dashArray: '6,4', opacity: 0.85
|
||||
});
|
||||
marker.bindPopup(buildObserverPopup(obs), { maxWidth: 280 });
|
||||
markerLayer.addLayer(marker);
|
||||
markerLayer.addLayer(line);
|
||||
// Small dot at true GPS position
|
||||
const dot = L.circleMarker(m.latLng, {
|
||||
radius: 3, fillColor: '#ef4444', fillOpacity: 0.9, stroke: true, color: '#fff', weight: 1
|
||||
});
|
||||
markerLayer.addLayer(dot);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -409,12 +583,17 @@
|
||||
const loc = (node.lat && node.lon) ? `${node.lat.toFixed(5)}, ${node.lon.toFixed(5)}` : '—';
|
||||
const lastAdvert = node.last_seen ? timeAgo(node.last_seen) : '—';
|
||||
const roleBadge = `<span style="display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600;background:${ROLE_COLORS[node.role] || '#4b5563'};color:#fff;">${(node.role || 'unknown').toUpperCase()}</span>`;
|
||||
const hs = node.hash_size || 1;
|
||||
const hashPrefix = node.public_key ? node.public_key.slice(0, hs * 2).toUpperCase() : '—';
|
||||
const hashPrefixRow = `<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Hash Prefix</dt>
|
||||
<dd style="font-family:var(--mono);font-size:11px;font-weight:700;margin-left:88px;padding:2px 0;">${safeEsc(hashPrefix)} <span style="font-weight:400;color:var(--text-muted);">(${hs}B)</span></dd>`;
|
||||
|
||||
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;">${safeEsc(node.name || 'Unknown')}</h3>
|
||||
${roleBadge}
|
||||
<dl style="margin-top:8px;font-size:12px;">
|
||||
${hashPrefixRow}
|
||||
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Key</dt>
|
||||
<dd style="font-family:var(--mono);font-size:11px;margin-left:88px;padding:2px 0;">${safeEsc(key)}</dd>
|
||||
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Location</dt>
|
||||
|
||||
107
public/nodes.js
107
public/nodes.js
@@ -35,6 +35,8 @@
|
||||
|
||||
let directNode = null; // set when navigating directly to #/nodes/:pubkey
|
||||
|
||||
let regionChangeHandler = null;
|
||||
|
||||
function init(app, routeParam) {
|
||||
directNode = routeParam || null;
|
||||
|
||||
@@ -66,12 +68,16 @@
|
||||
<input type="text" class="nodes-search" id="nodeSearch" placeholder="Search nodes by name…" aria-label="Search nodes by name">
|
||||
<div class="nodes-counts" id="nodeCounts"></div>
|
||||
</div>
|
||||
<div id="nodesRegionFilter" class="region-filter-container"></div>
|
||||
<div class="split-layout">
|
||||
<div class="panel-left" id="nodesLeft"></div>
|
||||
<div class="panel-right empty" id="nodesRight"><span>Select a node to view details</span></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
RegionFilter.init(document.getElementById('nodesRegionFilter'));
|
||||
regionChangeHandler = RegionFilter.onChange(function () { loadNodes(); });
|
||||
|
||||
document.getElementById('nodeSearch').addEventListener('input', debounce(e => {
|
||||
search = e.target.value;
|
||||
loadNodes();
|
||||
@@ -151,6 +157,11 @@
|
||||
</table>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="node-full-card" id="fullPathsSection">
|
||||
<h4>Paths Through This Node</h4>
|
||||
<div id="fullPathsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading paths…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="node-full-card">
|
||||
<h4>Recent Packets (${adverts.length})</h4>
|
||||
<div class="node-activity-list">
|
||||
@@ -208,15 +219,55 @@
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Fetch paths through this node (full-screen view)
|
||||
api('/nodes/' + encodeURIComponent(n.public_key) + '/paths', { ttl: CLIENT_TTL.nodeDetail }).then(pathData => {
|
||||
const el = document.getElementById('fullPathsContent');
|
||||
if (!el) return;
|
||||
if (!pathData || !pathData.paths || !pathData.paths.length) {
|
||||
el.innerHTML = '<div class="text-muted" style="padding:8px">No paths observed through this node</div>';
|
||||
return;
|
||||
}
|
||||
document.querySelector('#fullPathsSection h4').textContent = `Paths Through This Node (${pathData.totalPaths} unique, ${pathData.totalTransmissions} transmissions)`;
|
||||
const COLLAPSE_LIMIT = 10;
|
||||
function renderPaths(paths) {
|
||||
return paths.map(p => {
|
||||
const chain = p.hops.map(h => {
|
||||
const isThis = h.pubkey === n.public_key;
|
||||
const name = escapeHtml(h.name || h.prefix);
|
||||
const link = h.pubkey ? `<a href="#/nodes/${encodeURIComponent(h.pubkey)}" style="${isThis ? 'font-weight:700;color:var(--accent, #3b82f6)' : ''}">${name}</a>` : `<span>${name}</span>`;
|
||||
return link;
|
||||
}).join(' → ');
|
||||
return `<div style="padding:6px 0;border-bottom:1px solid var(--border);font-size:12px">
|
||||
<div>${chain}</div>
|
||||
<div style="color:var(--text-muted);margin-top:2px">${p.count}× · last ${timeAgo(p.lastSeen)} · <a href="#/packets/${p.sampleHash}" class="ch-analyze-link">Analyze →</a></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
if (pathData.paths.length <= COLLAPSE_LIMIT) {
|
||||
el.innerHTML = renderPaths(pathData.paths);
|
||||
} else {
|
||||
el.innerHTML = renderPaths(pathData.paths.slice(0, COLLAPSE_LIMIT)) +
|
||||
`<button id="showAllFullPaths" class="btn-primary" style="margin-top:8px;font-size:11px;padding:4px 12px">Show all ${pathData.paths.length} paths</button>`;
|
||||
document.getElementById('showAllFullPaths').addEventListener('click', function() {
|
||||
el.innerHTML = renderPaths(pathData.paths);
|
||||
});
|
||||
}
|
||||
}).catch(() => {
|
||||
const el = document.getElementById('fullPathsContent');
|
||||
if (el) el.innerHTML = '<div class="text-muted" style="padding:8px">Failed to load paths</div>';
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
body.innerHTML = `<div class="text-muted" style="padding:40px">Failed to load node: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
function destroy() {
|
||||
if (wsHandler) offWS(wsHandler);
|
||||
wsHandler = null;
|
||||
if (detailMap) { detailMap.remove(); detailMap = null; }
|
||||
if (regionChangeHandler) RegionFilter.offChange(regionChangeHandler);
|
||||
regionChangeHandler = null;
|
||||
nodes = [];
|
||||
selectedKey = null;
|
||||
}
|
||||
@@ -227,10 +278,19 @@
|
||||
if (activeTab !== 'all') params.set('role', activeTab);
|
||||
if (search) params.set('search', search);
|
||||
if (lastHeard) params.set('lastHeard', lastHeard);
|
||||
const rp = RegionFilter.getRegionParam();
|
||||
if (rp) params.set('region', rp);
|
||||
const data = await api('/nodes?' + params, { ttl: CLIENT_TTL.nodeList });
|
||||
nodes = data.nodes || [];
|
||||
counts = data.counts || {};
|
||||
|
||||
// Defensive filter: hide nodes with obviously corrupted data
|
||||
nodes = nodes.filter(n => {
|
||||
if (n.public_key && n.public_key.length < 16) return false;
|
||||
if (!n.name && !n.advert_count) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Ensure claimed nodes are always present even if not in current page
|
||||
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
|
||||
const existingKeys = new Set(nodes.map(n => n.public_key));
|
||||
@@ -467,6 +527,11 @@
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="node-detail-section" id="pathsSection">
|
||||
<h4>Paths Through This Node</h4>
|
||||
<div id="pathsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading paths…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="node-detail-section">
|
||||
<h4>Recent Packets (${adverts.length})</h4>
|
||||
<div id="advertTimeline">
|
||||
@@ -528,6 +593,46 @@
|
||||
setTimeout(() => btn.textContent = '📋 Copy URL', 2000);
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
// Fetch paths through this node
|
||||
api('/nodes/' + encodeURIComponent(n.public_key) + '/paths', { ttl: CLIENT_TTL.nodeDetail }).then(pathData => {
|
||||
const el = document.getElementById('pathsContent');
|
||||
if (!el) return;
|
||||
if (!pathData || !pathData.paths || !pathData.paths.length) {
|
||||
el.innerHTML = '<div class="text-muted" style="padding:8px">No paths observed through this node</div>';
|
||||
document.querySelector('#pathsSection h4').textContent = 'Paths Through This Node';
|
||||
return;
|
||||
}
|
||||
document.querySelector('#pathsSection h4').textContent = `Paths Through This Node (${pathData.totalPaths} unique path${pathData.totalPaths !== 1 ? 's' : ''}, ${pathData.totalTransmissions} transmissions)`;
|
||||
const COLLAPSE_LIMIT = 10;
|
||||
const showAll = pathData.paths.length <= COLLAPSE_LIMIT;
|
||||
function renderPaths(paths) {
|
||||
return paths.map(p => {
|
||||
const chain = p.hops.map(h => {
|
||||
const isThis = h.pubkey === n.public_key;
|
||||
const name = escapeHtml(h.name || h.prefix);
|
||||
const link = h.pubkey ? `<a href="#/nodes/${encodeURIComponent(h.pubkey)}" style="${isThis ? 'font-weight:700;color:var(--accent, #3b82f6)' : ''}">${name}</a>` : `<span>${name}</span>`;
|
||||
return link;
|
||||
}).join(' → ');
|
||||
return `<div style="padding:6px 0;border-bottom:1px solid var(--border);font-size:12px">
|
||||
<div>${chain}</div>
|
||||
<div style="color:var(--text-muted);margin-top:2px">${p.count}× · last ${timeAgo(p.lastSeen)} · <a href="#/packets/${p.sampleHash}" class="ch-analyze-link">Analyze →</a></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
if (showAll) {
|
||||
el.innerHTML = renderPaths(pathData.paths);
|
||||
} else {
|
||||
el.innerHTML = renderPaths(pathData.paths.slice(0, COLLAPSE_LIMIT)) +
|
||||
`<button id="showAllPaths" class="btn-primary" style="margin-top:8px;font-size:11px;padding:4px 12px">Show all ${pathData.paths.length} paths</button>`;
|
||||
document.getElementById('showAllPaths').addEventListener('click', function() {
|
||||
el.innerHTML = renderPaths(pathData.paths);
|
||||
});
|
||||
}
|
||||
}).catch(() => {
|
||||
const el = document.getElementById('pathsContent');
|
||||
if (el) el.innerHTML = '<div class="text-muted" style="padding:8px">Failed to load paths</div>';
|
||||
});
|
||||
}
|
||||
|
||||
registerPage('nodes', { init, destroy });
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
let observers = [];
|
||||
let wsHandler = null;
|
||||
let refreshTimer = null;
|
||||
let regionChangeHandler = null;
|
||||
|
||||
function init(app) {
|
||||
app.innerHTML = `
|
||||
@@ -13,8 +14,11 @@
|
||||
<h2>Observer Status</h2>
|
||||
<button class="btn-icon" data-action="obs-refresh" title="Refresh" aria-label="Refresh observers">🔄</button>
|
||||
</div>
|
||||
<div id="obsRegionFilter" class="region-filter-container"></div>
|
||||
<div id="obsContent"><div class="text-center text-muted" style="padding:40px">Loading…</div></div>
|
||||
</div>`;
|
||||
RegionFilter.init(document.getElementById('obsRegionFilter'));
|
||||
regionChangeHandler = RegionFilter.onChange(function () { render(); });
|
||||
loadObservers();
|
||||
// Event delegation for data-action buttons
|
||||
app.addEventListener('click', function (e) {
|
||||
@@ -33,6 +37,8 @@
|
||||
wsHandler = null;
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
if (regionChangeHandler) RegionFilter.offChange(regionChangeHandler);
|
||||
regionChangeHandler = null;
|
||||
observers = [];
|
||||
}
|
||||
|
||||
@@ -78,24 +84,30 @@
|
||||
const el = document.getElementById('obsContent');
|
||||
if (!el) return;
|
||||
|
||||
if (observers.length === 0) {
|
||||
// Apply region filter
|
||||
const selectedRegions = RegionFilter.getSelected();
|
||||
const filtered = selectedRegions
|
||||
? observers.filter(o => o.iata && selectedRegions.includes(o.iata))
|
||||
: observers;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">No observers found.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const maxPktsHr = Math.max(1, ...observers.map(o => o.packetsLastHour || 0));
|
||||
const maxPktsHr = Math.max(1, ...filtered.map(o => o.packetsLastHour || 0));
|
||||
|
||||
// Summary counts
|
||||
const online = observers.filter(o => healthStatus(o.last_seen).cls === 'health-green').length;
|
||||
const stale = observers.filter(o => healthStatus(o.last_seen).cls === 'health-yellow').length;
|
||||
const offline = observers.filter(o => healthStatus(o.last_seen).cls === 'health-red').length;
|
||||
const online = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-green').length;
|
||||
const stale = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-yellow').length;
|
||||
const offline = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-red').length;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="obs-summary">
|
||||
<span class="obs-stat"><span class="health-dot health-green">●</span> ${online} Online</span>
|
||||
<span class="obs-stat"><span class="health-dot health-yellow">▲</span> ${stale} Stale</span>
|
||||
<span class="obs-stat"><span class="health-dot health-red">✕</span> ${offline} Offline</span>
|
||||
<span class="obs-stat">📡 ${observers.length} Total</span>
|
||||
<span class="obs-stat">📡 ${filtered.length} Total</span>
|
||||
</div>
|
||||
<div class="obs-table-scroll"><table class="data-table obs-table" id="obsTable">
|
||||
<caption class="sr-only">Observer status and statistics</caption>
|
||||
@@ -103,7 +115,7 @@
|
||||
<th>Status</th><th>Name</th><th>Region</th><th>Last Seen</th>
|
||||
<th>Packets</th><th>Packets/Hour</th><th>Uptime</th>
|
||||
</tr></thead>
|
||||
<tbody>${observers.map(o => {
|
||||
<tbody>${filtered.map(o => {
|
||||
const h = healthStatus(o.last_seen);
|
||||
const shape = h.cls === 'health-green' ? '●' : h.cls === 'health-yellow' ? '▲' : '✕';
|
||||
return `<tr style="cursor:pointer" onclick="location.hash='#/observers/${encodeURIComponent(o.id)}'">
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
let selectedId = null;
|
||||
let groupByHash = true;
|
||||
let filters = {};
|
||||
{ const o = localStorage.getItem('meshcore-observer-filter'); if (o) filters.observer = o;
|
||||
const t = localStorage.getItem('meshcore-type-filter'); if (t) filters.type = t; }
|
||||
let wsHandler = null;
|
||||
let packetsPaused = false;
|
||||
let pauseBuffer = [];
|
||||
let observers = [];
|
||||
let regionMap = {};
|
||||
const TYPE_NAMES = { 0:'Request', 1:'Response', 2:'Direct Msg', 3:'ACK', 4:'Advert', 5:'Channel Msg', 7:'Anon Req', 8:'Path', 9:'Trace', 11:'Control' };
|
||||
@@ -21,6 +25,7 @@
|
||||
let totalCount = 0;
|
||||
let expandedHashes = new Set();
|
||||
let hopNameCache = {};
|
||||
let showHexHashes = localStorage.getItem('meshcore-hex-hashes') === 'true';
|
||||
let filtersBuilt = false;
|
||||
const PANEL_WIDTH_KEY = 'meshcore-panel-width';
|
||||
|
||||
@@ -93,20 +98,32 @@
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
// Resolve hop hex prefixes to node names (cached)
|
||||
async function resolveHops(hops) {
|
||||
const unknown = hops.filter(h => !(h in hopNameCache));
|
||||
if (unknown.length) {
|
||||
// Ensure HopResolver is initialized with the nodes list
|
||||
async function ensureHopResolver() {
|
||||
if (!HopResolver.ready()) {
|
||||
try {
|
||||
const data = await api('/resolve-hops?hops=' + unknown.join(','));
|
||||
Object.assign(hopNameCache, data.resolved || {});
|
||||
// Cache misses as null so we don't re-query
|
||||
unknown.forEach(h => { if (!(h in hopNameCache)) hopNameCache[h] = null; });
|
||||
const data = await api('/nodes?limit=2000', { ttl: 60000 });
|
||||
HopResolver.init(data.nodes || []);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve hop hex prefixes to node names (cached, client-side)
|
||||
async function resolveHops(hops) {
|
||||
const unknown = hops.filter(h => !(h in hopNameCache));
|
||||
if (unknown.length) {
|
||||
await ensureHopResolver();
|
||||
const resolved = HopResolver.resolve(unknown);
|
||||
Object.assign(hopNameCache, resolved || {});
|
||||
// Cache misses as null so we don't re-query
|
||||
unknown.forEach(h => { if (!(h in hopNameCache)) hopNameCache[h] = null; });
|
||||
}
|
||||
}
|
||||
|
||||
function renderHop(h) {
|
||||
if (showHexHashes) {
|
||||
return `<span class="hop">${escapeHtml(h)}</span>`;
|
||||
}
|
||||
const entry = hopNameCache[h];
|
||||
const name = entry ? (typeof entry === 'string' ? entry : entry.name) : null;
|
||||
const pubkey = entry?.pubkey || h;
|
||||
@@ -124,16 +141,27 @@
|
||||
}
|
||||
|
||||
let directPacketId = null;
|
||||
let directPacketHash = null;
|
||||
let initGeneration = 0;
|
||||
|
||||
let directObsId = null;
|
||||
|
||||
async function init(app, routeParam) {
|
||||
const gen = ++initGeneration;
|
||||
// Parse ?obs=OBSERVER_ID from routeParam
|
||||
if (routeParam && routeParam.includes('?')) {
|
||||
const qIdx = routeParam.indexOf('?');
|
||||
const qs = new URLSearchParams(routeParam.substring(qIdx));
|
||||
directObsId = qs.get('obs');
|
||||
routeParam = routeParam.substring(0, qIdx);
|
||||
}
|
||||
// Detect route param type: "id/123" for direct packet, short hex for hash, long hex for node
|
||||
if (routeParam) {
|
||||
if (routeParam.startsWith('id/')) {
|
||||
directPacketId = routeParam.slice(3);
|
||||
} else if (routeParam.length <= 16) {
|
||||
filters.hash = routeParam;
|
||||
directPacketHash = routeParam;
|
||||
} else {
|
||||
filters.node = routeParam;
|
||||
}
|
||||
@@ -147,8 +175,38 @@
|
||||
</div>`;
|
||||
initPanelResize();
|
||||
await loadObservers();
|
||||
// Restore saved time window before first load
|
||||
const fTW = document.getElementById('fTimeWindow');
|
||||
const savedTW = localStorage.getItem('meshcore-time-window');
|
||||
if (savedTW !== null && fTW) fTW.value = savedTW;
|
||||
loadPackets();
|
||||
|
||||
// Auto-select packet detail when arriving via hash URL
|
||||
if (directPacketHash) {
|
||||
const h = directPacketHash;
|
||||
const obsTarget = directObsId;
|
||||
directPacketHash = null;
|
||||
directObsId = null;
|
||||
try {
|
||||
const data = await api(`/packets/${h}`);
|
||||
if (gen === initGeneration && data?.packet) {
|
||||
if (obsTarget && data.observations) {
|
||||
// Find the matching observation by its unique id
|
||||
const obs = data.observations.find(o => String(o.id) === String(obsTarget));
|
||||
if (obs) {
|
||||
expandedHashes.add(h);
|
||||
const obsPacket = {...data.packet, observer_id: obs.observer_id, observer_name: obs.observer_name, snr: obs.snr, rssi: obs.rssi, path_json: obs.path_json, timestamp: obs.timestamp, first_seen: obs.timestamp};
|
||||
selectPacket(obs.id, h, {packet: obsPacket, breakdown: data.breakdown, observations: data.observations}, obs.id);
|
||||
} else {
|
||||
selectPacket(data.packet.id, h, data);
|
||||
}
|
||||
} else {
|
||||
selectPacket(data.packet.id, h, data);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Event delegation for data-action buttons
|
||||
app.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-action]');
|
||||
@@ -157,6 +215,17 @@
|
||||
else if (btn.dataset.action === 'pkt-byop') showBYOP();
|
||||
});
|
||||
|
||||
document.getElementById('pktPauseBtn').addEventListener('click', function() {
|
||||
packetsPaused = !packetsPaused;
|
||||
this.textContent = packetsPaused ? '▶' : '⏸';
|
||||
this.title = packetsPaused ? 'Resume live updates' : 'Pause live updates';
|
||||
this.classList.toggle('active', packetsPaused);
|
||||
if (!packetsPaused && pauseBuffer.length) {
|
||||
pauseBuffer.forEach(msg => wsHandler(msg));
|
||||
pauseBuffer = [];
|
||||
}
|
||||
});
|
||||
|
||||
// If linked directly to a packet by ID, load its detail and filter list
|
||||
if (directPacketId) {
|
||||
const pktId = Number(directPacketId);
|
||||
@@ -189,6 +258,12 @@
|
||||
} catch {}
|
||||
}
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
if (packetsPaused) {
|
||||
pauseBuffer.push(...msgs);
|
||||
const btn = document.getElementById('pktPauseBtn');
|
||||
if (btn) btn.textContent = '▶ ' + pauseBuffer.length;
|
||||
return;
|
||||
}
|
||||
const newPkts = msgs
|
||||
.filter(m => m.type === 'packet' && m.data?.packet)
|
||||
.map(m => m.data.packet);
|
||||
@@ -196,12 +271,13 @@
|
||||
|
||||
// Check if new packets pass current filters
|
||||
const filtered = newPkts.filter(p => {
|
||||
if (filters.type !== undefined && filters.type !== '' && p.payload_type !== Number(filters.type)) return false;
|
||||
if (filters.observer && p.observer_id !== filters.observer) return false;
|
||||
if (filters.type) { const types = filters.type.split(',').map(Number); if (!types.includes(p.payload_type)) return false; }
|
||||
if (filters.observer) { const obsSet = new Set(filters.observer.split(',')); if (!obsSet.has(p.observer_id)) return false; }
|
||||
if (filters.hash && p.hash !== filters.hash) return false;
|
||||
if (filters.region) {
|
||||
if (RegionFilter.getRegionParam()) {
|
||||
const selectedRegions = RegionFilter.getRegionParam().split(',');
|
||||
const obs = observers.find(o => o.id === p.observer_id);
|
||||
if (!obs || obs.iata !== filters.region) return false;
|
||||
if (!obs || !selectedRegions.includes(obs.iata)) return false;
|
||||
}
|
||||
if (filters.node && !(p.decoded_json || '').includes(filters.node)) return false;
|
||||
return true;
|
||||
@@ -227,16 +303,13 @@
|
||||
if (p.observer_id && p.observer_id !== existing.observer_id) {
|
||||
existing.observer_count = (existing.observer_count || 1) + 1;
|
||||
}
|
||||
// Keep longest path
|
||||
if (p.path_json && (!existing.path_json || p.path_json.length > existing.path_json.length)) {
|
||||
existing.path_json = p.path_json;
|
||||
existing.raw_hex = p.raw_hex;
|
||||
}
|
||||
// Don't update path — header always shows first observer's path
|
||||
// Update decoded_json to latest
|
||||
if (p.decoded_json) existing.decoded_json = p.decoded_json;
|
||||
// Update expanded children if this group is expanded
|
||||
if (expandedHashes.has(h) && existing._children) {
|
||||
existing._children.unshift(p);
|
||||
sortGroupChildren(existing);
|
||||
}
|
||||
} else {
|
||||
// New group
|
||||
@@ -279,6 +352,7 @@
|
||||
totalCount = 0;
|
||||
observers = [];
|
||||
directPacketId = null;
|
||||
directPacketHash = null;
|
||||
groupByHash = true;
|
||||
filters = {};
|
||||
regionMap = {};
|
||||
@@ -294,18 +368,44 @@
|
||||
async function loadPackets() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', '100');
|
||||
if (filters.type !== undefined && filters.type !== '') params.set('type', filters.type);
|
||||
if (filters.region) params.set('region', filters.region);
|
||||
if (filters.observer) params.set('observer', filters.observer);
|
||||
const windowMin = Number(document.getElementById('fTimeWindow')?.value || 15);
|
||||
if (windowMin > 0) {
|
||||
const since = new Date(Date.now() - windowMin * 60000).toISOString();
|
||||
params.set('since', since);
|
||||
}
|
||||
params.set('limit', '50000');
|
||||
const regionParam = RegionFilter.getRegionParam();
|
||||
if (regionParam) params.set('region', regionParam);
|
||||
if (filters.hash) params.set('hash', filters.hash);
|
||||
if (filters.node) params.set('node', filters.node);
|
||||
if (groupByHash) params.set('groupByHash', 'true');
|
||||
params.set('groupByHash', 'true'); // always fetch grouped
|
||||
|
||||
const data = await api('/packets?' + params.toString());
|
||||
packets = data.packets || [];
|
||||
totalCount = data.total || packets.length;
|
||||
|
||||
// When ungrouped, fetch observations for all multi-obs packets and flatten
|
||||
if (!groupByHash) {
|
||||
const multiObs = packets.filter(p => (p.observation_count || p.count || 1) > 1);
|
||||
await Promise.all(multiObs.map(async (p) => {
|
||||
try {
|
||||
const d = await api(`/packets/${p.hash}`);
|
||||
if (d?.observations) p._children = d.observations.map(o => ({...d.packet, ...o, _isObservation: true}));
|
||||
} catch {}
|
||||
}));
|
||||
// Flatten: replace grouped packets with individual observations
|
||||
const flat = [];
|
||||
for (const p of packets) {
|
||||
if (p._children && p._children.length > 1) {
|
||||
for (const c of p._children) flat.push(c);
|
||||
} else {
|
||||
flat.push(p);
|
||||
}
|
||||
}
|
||||
packets = flat;
|
||||
totalCount = flat.length;
|
||||
}
|
||||
|
||||
// Pre-resolve all path hops to node names
|
||||
const allHops = new Set();
|
||||
for (const p of packets) {
|
||||
@@ -321,6 +421,7 @@
|
||||
try {
|
||||
const childData = await api(`/packets?hash=${hash}&limit=20`);
|
||||
group._children = childData.packets || [];
|
||||
sortGroupChildren(group);
|
||||
} catch {}
|
||||
} else {
|
||||
// Group no longer in results — remove from expanded
|
||||
@@ -353,24 +454,60 @@
|
||||
<h2>Latest Packets <span class="count">(${totalCount})</span></h2>
|
||||
<div>
|
||||
<button class="btn-icon" data-action="pkt-refresh" title="Refresh">🔄</button>
|
||||
<button class="btn-icon" data-action="pkt-byop" title="Bring Your Own Packet">📦 BYOP</button>
|
||||
<button class="btn-icon" id="pktPauseBtn" title="Pause live updates">⏸</button>
|
||||
<button class="btn-icon" data-action="pkt-byop" title="Bring Your Own Packet" aria-label="Bring Your Own Packet - paste raw packet hex for analysis" aria-haspopup="dialog">📦 BYOP</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-bar" id="pktFilters">
|
||||
<button class="btn filter-toggle-btn" id="filterToggleBtn">Filters ▾</button>
|
||||
<input type="text" placeholder="Packet hash…" id="fHash" aria-label="Filter by packet hash">
|
||||
<div class="node-filter-wrap" style="position:relative">
|
||||
<input type="text" placeholder="Node name…" id="fNode" autocomplete="off" role="combobox" aria-expanded="false" aria-owns="fNodeDropdown" aria-activedescendant="" aria-autocomplete="list">
|
||||
<div class="node-filter-dropdown hidden" id="fNodeDropdown" role="listbox"></div>
|
||||
<div class="filter-group">
|
||||
<input type="text" placeholder="Packet hash…" id="fHash" aria-label="Filter by packet hash" title="Filter packets by hex hash prefix">
|
||||
<div class="node-filter-wrap" style="position:relative">
|
||||
<input type="text" placeholder="Node name…" id="fNode" autocomplete="off" role="combobox" aria-expanded="false" aria-owns="fNodeDropdown" aria-activedescendant="" aria-autocomplete="list" title="Filter packets involving this node (sender or path)">
|
||||
<div class="node-filter-dropdown hidden" id="fNodeDropdown" role="listbox"></div>
|
||||
</div>
|
||||
<div class="multi-select-wrap" id="observerFilterWrap">
|
||||
<button class="multi-select-trigger" id="observerTrigger" title="Show only packets seen by selected observer stations">All Observers ▾</button>
|
||||
<div class="multi-select-menu" id="observerMenu"></div>
|
||||
</div>
|
||||
<div id="packetsRegionFilter" class="region-filter-container" style="display:inline-block;vertical-align:middle"></div>
|
||||
<div class="multi-select-wrap" id="typeFilterWrap">
|
||||
<button class="multi-select-trigger" id="typeTrigger" title="Filter by packet type">All Types ▾</button>
|
||||
<div class="multi-select-menu" id="typeMenu"></div>
|
||||
</div>
|
||||
</div>
|
||||
<select id="fObserver" aria-label="Filter by observer"><option value="">All Observers</option></select>
|
||||
<select id="fRegion" aria-label="Filter by region"><option value="">All Regions</option></select>
|
||||
<select id="fType" aria-label="Filter by packet type"><option value="">All Types</option></select>
|
||||
<button class="btn ${groupByHash ? 'active' : ''}" id="fGroup">Group by Hash</button>
|
||||
<button class="btn" id="fMyNodes" title="Show only packets from claimed/favorited nodes">★ My Nodes</button>
|
||||
<div class="col-toggle-wrap">
|
||||
<button class="col-toggle-btn" id="colToggleBtn">Columns ▾</button>
|
||||
<div class="col-toggle-menu" id="colToggleMenu"></div>
|
||||
<div class="filter-group">
|
||||
<button class="btn ${groupByHash ? 'active' : ''}" id="fGroup" title="Collapse duplicate observations of the same packet into expandable groups">Group by Hash</button>
|
||||
<button class="btn" id="fMyNodes" title="Show only packets from your favorited/claimed nodes">★ My Nodes</button>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<select id="fTimeWindow" class="filter-select">
|
||||
<option value="15">Last 15 min</option>
|
||||
<option value="30">Last 30 min</option>
|
||||
<option value="60">Last 1 hour</option>
|
||||
<option value="180">Last 3 hours</option>
|
||||
<option value="360">Last 6 hours</option>
|
||||
<option value="720">Last 12 hours</option>
|
||||
<option value="1440">Last 24 hours</option>
|
||||
<option value="0">All time</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<select id="fObsSort" aria-label="Observation sort order" title="Controls how observations are ordered within packet groups and which observation appears in the header row. Observer: Groups by observer station, earliest first. Path: Orders by hop count. Time: Orders by observation timestamp.">
|
||||
<option value="observer">Sort: Observer</option>
|
||||
<option value="path-asc">Sort: Path ↑ (shortest)</option>
|
||||
<option value="path-desc">Sort: Path ↓ (longest)</option>
|
||||
<option value="chrono-asc">Sort: Time ↑ (earliest)</option>
|
||||
<option value="chrono-desc">Sort: Time ↓ (latest)</option>
|
||||
</select>
|
||||
<span class="sort-help" id="sortHelpIcon">ⓘ</span>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<div class="col-toggle-wrap">
|
||||
<button class="col-toggle-btn" id="colToggleBtn" title="Show/hide table columns">Columns ▾</button>
|
||||
<div class="col-toggle-menu" id="colToggleMenu"></div>
|
||||
</div>
|
||||
<button class="btn btn-icon${showHexHashes ? ' active' : ''}" id="hexHashToggle" title="Show raw hex hash prefixes instead of resolved node names in the path column">Hex Paths</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="data-table" id="pktTable">
|
||||
@@ -382,21 +519,98 @@
|
||||
</table>
|
||||
`;
|
||||
|
||||
// Populate filter dropdowns
|
||||
const regionSel = document.getElementById('fRegion');
|
||||
for (const [code, name] of Object.entries(regionMap || {})) {
|
||||
regionSel.innerHTML += `<option value="${code}" ${filters.region === code ? 'selected' : ''}>${code}</option>`;
|
||||
}
|
||||
// Init shared RegionFilter component
|
||||
RegionFilter.init(document.getElementById('packetsRegionFilter'), { dropdown: true });
|
||||
RegionFilter.onChange(function() { loadPackets(); });
|
||||
|
||||
const obsSel = document.getElementById('fObserver');
|
||||
for (const o of observers) {
|
||||
obsSel.innerHTML += `<option value="${o.id}" ${filters.observer === o.id ? 'selected' : ''}>${o.name || o.id}</option>`;
|
||||
// --- Observer multi-select ---
|
||||
const obsMenu = document.getElementById('observerMenu');
|
||||
const obsTrigger = document.getElementById('observerTrigger');
|
||||
const selectedObservers = new Set(filters.observer ? filters.observer.split(',') : []);
|
||||
function buildObserverMenu() {
|
||||
const allChecked = selectedObservers.size === 0;
|
||||
let html = `<label class="multi-select-item"><input type="checkbox" data-obs-id="__all__" ${allChecked ? 'checked' : ''}> All Observers</label>`;
|
||||
for (const o of observers) {
|
||||
const checked = selectedObservers.has(String(o.id)) ? 'checked' : '';
|
||||
html += `<label class="multi-select-item"><input type="checkbox" data-obs-id="${o.id}" ${checked}> ${o.name || o.id}</label>`;
|
||||
}
|
||||
obsMenu.innerHTML = html;
|
||||
}
|
||||
function updateObsTrigger() {
|
||||
if (selectedObservers.size === 0 || selectedObservers.size === observers.length) {
|
||||
obsTrigger.textContent = 'All Observers ▾';
|
||||
} else if (selectedObservers.size === 1) {
|
||||
const id = [...selectedObservers][0];
|
||||
const o = observers.find(x => String(x.id) === id);
|
||||
obsTrigger.textContent = (o ? (o.name || o.id) : id) + ' ▾';
|
||||
} else {
|
||||
obsTrigger.textContent = selectedObservers.size + ' Observers ▾';
|
||||
}
|
||||
}
|
||||
buildObserverMenu();
|
||||
updateObsTrigger();
|
||||
obsTrigger.addEventListener('click', (e) => { e.stopPropagation(); obsMenu.classList.toggle('open'); typeMenu.classList.remove('open'); });
|
||||
obsMenu.addEventListener('change', (e) => {
|
||||
const id = e.target.dataset.obsId;
|
||||
if (id === '__all__') {
|
||||
selectedObservers.clear();
|
||||
} else {
|
||||
if (e.target.checked) selectedObservers.add(id); else selectedObservers.delete(id);
|
||||
}
|
||||
filters.observer = selectedObservers.size > 0 ? [...selectedObservers].join(',') : undefined;
|
||||
if (filters.observer) localStorage.setItem('meshcore-observer-filter', filters.observer); else localStorage.removeItem('meshcore-observer-filter');
|
||||
buildObserverMenu();
|
||||
updateObsTrigger();
|
||||
renderTableRows();
|
||||
});
|
||||
|
||||
const typeSel = document.getElementById('fType');
|
||||
for (const [k, v] of Object.entries({0:'Request',1:'Response',2:'Direct Msg',3:'ACK',4:'Advert',5:'Channel Msg',7:'Anon Req',8:'Path',9:'Trace'})) {
|
||||
typeSel.innerHTML += `<option value="${k}" ${String(filters.type) === k ? 'selected' : ''}>${v}</option>`;
|
||||
// --- Type multi-select ---
|
||||
const typeMenu = document.getElementById('typeMenu');
|
||||
const typeTrigger = document.getElementById('typeTrigger');
|
||||
const typeMap = {0:'Request',1:'Response',2:'Direct Msg',3:'ACK',4:'Advert',5:'Channel Msg',7:'Anon Req',8:'Path',9:'Trace'};
|
||||
const selectedTypes = new Set(filters.type ? String(filters.type).split(',') : []);
|
||||
function buildTypeMenu() {
|
||||
const allChecked = selectedTypes.size === 0;
|
||||
let html = `<label class="multi-select-item"><input type="checkbox" data-type-id="__all__" ${allChecked ? 'checked' : ''}> All Types</label>`;
|
||||
for (const [k, v] of Object.entries(typeMap)) {
|
||||
const checked = selectedTypes.has(k) ? 'checked' : '';
|
||||
html += `<label class="multi-select-item"><input type="checkbox" data-type-id="${k}" ${checked}> ${v}</label>`;
|
||||
}
|
||||
typeMenu.innerHTML = html;
|
||||
}
|
||||
function updateTypeTrigger() {
|
||||
const total = Object.keys(typeMap).length;
|
||||
if (selectedTypes.size === 0 || selectedTypes.size === total) {
|
||||
typeTrigger.textContent = 'All Types ▾';
|
||||
} else if (selectedTypes.size === 1) {
|
||||
const k = [...selectedTypes][0];
|
||||
typeTrigger.textContent = (typeMap[k] || k) + ' ▾';
|
||||
} else {
|
||||
typeTrigger.textContent = selectedTypes.size + ' Types ▾';
|
||||
}
|
||||
}
|
||||
buildTypeMenu();
|
||||
updateTypeTrigger();
|
||||
typeTrigger.addEventListener('click', (e) => { e.stopPropagation(); typeMenu.classList.toggle('open'); obsMenu.classList.remove('open'); });
|
||||
typeMenu.addEventListener('change', (e) => {
|
||||
const id = e.target.dataset.typeId;
|
||||
if (id === '__all__') {
|
||||
selectedTypes.clear();
|
||||
} else {
|
||||
if (e.target.checked) selectedTypes.add(id); else selectedTypes.delete(id);
|
||||
}
|
||||
filters.type = selectedTypes.size > 0 ? [...selectedTypes].join(',') : undefined;
|
||||
if (filters.type) localStorage.setItem('meshcore-type-filter', filters.type); else localStorage.removeItem('meshcore-type-filter');
|
||||
buildTypeMenu();
|
||||
updateTypeTrigger();
|
||||
renderTableRows();
|
||||
});
|
||||
|
||||
// Close multi-select menus on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!document.getElementById('observerFilterWrap').contains(e.target)) obsMenu.classList.remove('open');
|
||||
if (!document.getElementById('typeFilterWrap').contains(e.target)) typeMenu.classList.remove('open');
|
||||
});
|
||||
|
||||
// Filter toggle button for mobile
|
||||
document.getElementById('filterToggleBtn').addEventListener('click', function() {
|
||||
@@ -408,9 +622,16 @@
|
||||
// Filter event listeners
|
||||
document.getElementById('fHash').value = filters.hash || '';
|
||||
document.getElementById('fHash').addEventListener('input', debounce((e) => { filters.hash = e.target.value || undefined; loadPackets(); }, 300));
|
||||
document.getElementById('fObserver').addEventListener('change', (e) => { filters.observer = e.target.value || undefined; loadPackets(); });
|
||||
document.getElementById('fRegion').addEventListener('change', (e) => { filters.region = e.target.value || undefined; loadPackets(); });
|
||||
document.getElementById('fType').addEventListener('change', (e) => { filters.type = e.target.value !== '' ? e.target.value : undefined; loadPackets(); });
|
||||
|
||||
// Time window dropdown — restore from localStorage and bind change
|
||||
const fTimeWindow = document.getElementById('fTimeWindow');
|
||||
const savedWindow = localStorage.getItem('meshcore-time-window');
|
||||
if (savedWindow !== null) fTimeWindow.value = savedWindow;
|
||||
fTimeWindow.addEventListener('change', () => {
|
||||
localStorage.setItem('meshcore-time-window', fTimeWindow.value);
|
||||
loadPackets();
|
||||
});
|
||||
|
||||
document.getElementById('fGroup').addEventListener('click', () => { groupByHash = !groupByHash; loadPackets(); });
|
||||
document.getElementById('fMyNodes').addEventListener('click', function () {
|
||||
filters.myNodes = !filters.myNodes;
|
||||
@@ -418,6 +639,45 @@
|
||||
loadPackets();
|
||||
});
|
||||
|
||||
// Observation sort dropdown
|
||||
const obsSortSel = document.getElementById('fObsSort');
|
||||
obsSortSel.value = obsSortMode;
|
||||
const sortHelpEl = document.getElementById('sortHelpIcon');
|
||||
if (sortHelpEl) {
|
||||
const tip = document.createElement('span');
|
||||
tip.className = 'sort-help-tip';
|
||||
tip.textContent = "Sort controls how observations are ordered within packet groups and which observation appears in the header row.\n\nObserver — Groups by observer station, earliest first.\nPath \u2191 — Shortest paths first.\nPath \u2193 — Longest paths first.\nTime \u2191 — Earliest observation first.\nTime \u2193 — Most recent first.";
|
||||
sortHelpEl.appendChild(tip);
|
||||
}
|
||||
obsSortSel.addEventListener('change', async function () {
|
||||
obsSortMode = this.value;
|
||||
localStorage.setItem('meshcore-obs-sort', obsSortMode);
|
||||
// For non-observer sorts, fetch children for visible groups that don't have them yet
|
||||
if (obsSortMode !== SORT_OBSERVER && groupByHash) {
|
||||
const toFetch = packets.filter(p => p.hash && !p._children && (p.observation_count || 0) > 1);
|
||||
await Promise.all(toFetch.map(async (p) => {
|
||||
try {
|
||||
const data = await api(`/packets/${p.hash}`);
|
||||
if (data?.packet && data.observations) {
|
||||
p._children = data.observations.map(o => ({...data.packet, ...o, _isObservation: true}));
|
||||
p._fetchedData = data;
|
||||
}
|
||||
} catch {}
|
||||
}));
|
||||
}
|
||||
// Re-sort all groups with children
|
||||
for (const p of packets) {
|
||||
if (p._children) sortGroupChildren(p);
|
||||
}
|
||||
// Resolve any new hops from updated header paths
|
||||
const newHops = new Set();
|
||||
for (const p of packets) {
|
||||
try { JSON.parse(p.path_json || '[]').forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
|
||||
}
|
||||
if (newHops.size) await resolveHops([...newHops]);
|
||||
renderTableRows();
|
||||
});
|
||||
|
||||
// Column visibility toggle (#71)
|
||||
const COL_DEFS = [
|
||||
{ key: 'region', label: 'Region' },
|
||||
@@ -463,6 +723,13 @@
|
||||
document.addEventListener('click', () => colMenu.classList.remove('open'));
|
||||
applyColVisibility();
|
||||
|
||||
document.getElementById('hexHashToggle').addEventListener('click', function () {
|
||||
showHexHashes = !showHexHashes;
|
||||
localStorage.setItem('meshcore-hex-hashes', showHexHashes);
|
||||
this.classList.toggle('active', showHexHashes);
|
||||
renderTableRows();
|
||||
});
|
||||
|
||||
// Node name filter with autocomplete
|
||||
const fNode = document.getElementById('fNode');
|
||||
const fNodeDrop = document.getElementById('fNodeDropdown');
|
||||
@@ -553,7 +820,21 @@
|
||||
if (e.type === 'keydown') e.preventDefault();
|
||||
const action = row.dataset.action;
|
||||
const value = row.dataset.value;
|
||||
if (action === 'select') selectPacket(Number(value));
|
||||
if (action === 'select') {
|
||||
const hash = row.dataset.hash;
|
||||
if (hash) selectPacket(null, hash);
|
||||
else selectPacket(Number(value));
|
||||
}
|
||||
else if (action === 'select-observation') {
|
||||
const parentHash = row.dataset.parentHash;
|
||||
const group = packets.find(p => p.hash === parentHash);
|
||||
const child = group?._children?.find(c => String(c.id) === String(value));
|
||||
if (child) {
|
||||
const parentData = group._fetchedData;
|
||||
const obsPacket = parentData ? {...parentData.packet, observer_id: child.observer_id, observer_name: child.observer_name, snr: child.snr, rssi: child.rssi, path_json: child.path_json, timestamp: child.timestamp, first_seen: child.timestamp} : child;
|
||||
selectPacket(child.id, parentHash, {packet: obsPacket, breakdown: parentData?.breakdown, observations: parentData?.observations}, child.id);
|
||||
}
|
||||
}
|
||||
else if (action === 'select-hash') pktSelectHash(value);
|
||||
else if (action === 'toggle-select') { pktToggleGroup(value); pktSelectHash(value); }
|
||||
};
|
||||
@@ -584,7 +865,6 @@
|
||||
|
||||
// Update dynamic parts of the header
|
||||
const countEl = document.querySelector('#pktLeft .count');
|
||||
if (countEl) countEl.textContent = `(${totalCount})`;
|
||||
const groupBtn = document.getElementById('fGroup');
|
||||
if (groupBtn) groupBtn.classList.toggle('active', groupByHash);
|
||||
|
||||
@@ -605,6 +885,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side type/observer filtering
|
||||
if (filters.type) {
|
||||
const types = filters.type.split(',').map(Number);
|
||||
displayPackets = displayPackets.filter(p => types.includes(p.payload_type));
|
||||
}
|
||||
if (filters.observer) {
|
||||
const obsIds = new Set(filters.observer.split(','));
|
||||
displayPackets = displayPackets.filter(p => obsIds.has(p.observer_id));
|
||||
}
|
||||
|
||||
if (countEl) countEl.textContent = `(${displayPackets.length})`;
|
||||
|
||||
if (!displayPackets.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-muted" style="padding:24px">' + (filters.myNodes ? 'No packets from your claimed/favorited nodes' : 'No packets found') + '</td></tr>';
|
||||
return;
|
||||
@@ -614,9 +906,20 @@
|
||||
let html = '';
|
||||
for (const p of displayPackets) {
|
||||
const isExpanded = expandedHashes.has(p.hash);
|
||||
const groupRegion = p.observer_id ? (observers.find(o => o.id === p.observer_id)?.iata || '') : '';
|
||||
// When observer filter is active, use first matching child's data for header
|
||||
let headerObserverId = p.observer_id;
|
||||
let headerPathJson = p.path_json;
|
||||
if (filters.observer && p._children?.length) {
|
||||
const obsIds = new Set(filters.observer.split(','));
|
||||
const match = p._children.find(c => obsIds.has(String(c.observer_id)));
|
||||
if (match) {
|
||||
headerObserverId = match.observer_id;
|
||||
headerPathJson = match.path_json;
|
||||
}
|
||||
}
|
||||
const groupRegion = headerObserverId ? (observers.find(o => o.id === headerObserverId)?.iata || '') : '';
|
||||
let groupPath = [];
|
||||
try { groupPath = JSON.parse(p.path_json || '[]'); } catch {}
|
||||
try { groupPath = JSON.parse(headerPathJson || '[]'); } catch {}
|
||||
const groupPathStr = renderPath(groupPath);
|
||||
const groupTypeName = payloadTypeName(p.payload_type);
|
||||
const groupTypeClass = payloadTypeColor(p.payload_type);
|
||||
@@ -629,14 +932,20 @@
|
||||
<td class="mono col-hash">${truncate(p.hash || '—', 8)}</td>
|
||||
<td class="col-size">${groupSize ? groupSize + 'B' : '—'}</td>
|
||||
<td class="col-type">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>` : '—'}</td>
|
||||
<td class="col-observer">${isSingle ? truncate(obsName(p.observer_id), 16) : truncate(obsName(p.observer_id), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
|
||||
<td class="col-observer">${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
|
||||
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
|
||||
<td class="col-rpt">${p.observation_count > 1 ? '<span class="badge badge-obs" title="Seen ' + p.observation_count + ' times">👁 ' + p.observation_count + '</span>' : (isSingle ? '' : p.count)}</td>
|
||||
<td class="col-details">${getDetailPreview((() => { try { return JSON.parse(p.decoded_json || '{}'); } catch { return {}; } })())}</td>
|
||||
</tr>`;
|
||||
// Child rows (loaded async when expanded)
|
||||
if (isExpanded && p._children) {
|
||||
for (const c of p._children) {
|
||||
let visibleChildren = p._children;
|
||||
// Filter children by selected observers
|
||||
if (filters.observer) {
|
||||
const obsSet = new Set(filters.observer.split(','));
|
||||
visibleChildren = visibleChildren.filter(c => obsSet.has(String(c.observer_id)));
|
||||
}
|
||||
for (const c of visibleChildren) {
|
||||
const typeName = payloadTypeName(c.payload_type);
|
||||
const typeClass = payloadTypeColor(c.payload_type);
|
||||
const size = c.raw_hex ? Math.floor(c.raw_hex.length / 2) : 0;
|
||||
@@ -644,7 +953,7 @@
|
||||
let childPath = [];
|
||||
try { childPath = JSON.parse(c.path_json || '[]'); } catch {}
|
||||
const childPathStr = renderPath(childPath);
|
||||
html += `<tr class="group-child" data-id="${c.id}" data-action="select" data-value="${c.id}" tabindex="0" role="row">
|
||||
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" tabindex="0" role="row">
|
||||
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : '—'}</td>
|
||||
<td class="col-time">${timeAgo(c.timestamp)}</td>
|
||||
<td class="mono col-hash">${truncate(c.hash || '', 8)}</td>
|
||||
@@ -674,7 +983,7 @@
|
||||
const pathStr = renderPath(pathHops);
|
||||
const detail = getDetailPreview(decoded);
|
||||
|
||||
return `<tr data-id="${p.id}" data-action="select" data-value="${p.id}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}">
|
||||
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}">
|
||||
<td></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : '—'}</td>
|
||||
<td class="col-time">${timeAgo(p.timestamp)}</td>
|
||||
<td class="mono col-hash">${truncate(p.hash || String(p.id), 8)}</td>
|
||||
@@ -690,15 +999,16 @@
|
||||
|
||||
function getDetailPreview(decoded) {
|
||||
if (!decoded) return '';
|
||||
// Channel messages (GRP_TXT) — show the message text
|
||||
// Channel messages (GRP_TXT) — show channel name and message text
|
||||
if (decoded.type === 'CHAN' && decoded.text) {
|
||||
const ch = decoded.channel ? `<span class="chan-tag">${escapeHtml(decoded.channel)}</span> ` : '';
|
||||
const t = decoded.text.length > 80 ? decoded.text.slice(0, 80) + '…' : decoded.text;
|
||||
return `💬 ${escapeHtml(t)}`;
|
||||
return `${ch}💬 ${escapeHtml(t)}`;
|
||||
}
|
||||
// Advertisements — show node name and role
|
||||
if (decoded.type === 'ADVERT' && decoded.name) {
|
||||
const role = decoded.flags?.repeater ? '📡' : decoded.flags?.room ? '🏠' : decoded.flags?.sensor ? '🌡' : '📻';
|
||||
return `${role} ${escapeHtml(decoded.name)}`;
|
||||
return `${role} <a href="#/nodes/${encodeURIComponent(decoded.pubKey)}" class="hop-link hop-named" data-hop-link="true">${escapeHtml(decoded.name)}</a>`;
|
||||
}
|
||||
// Direct messages
|
||||
if (decoded.type === 'TXT_MSG') return `✉️ ${decoded.srcHash?.slice(0,8) || '?'} → ${decoded.destHash?.slice(0,8) || '?'}`;
|
||||
@@ -715,9 +1025,17 @@
|
||||
return '';
|
||||
}
|
||||
|
||||
async function selectPacket(id) {
|
||||
let selectedObservationId = null;
|
||||
|
||||
async function selectPacket(id, hash, prefetchedData, obsRowId) {
|
||||
selectedId = id;
|
||||
history.replaceState(null, '', `#/packet/${id}`);
|
||||
selectedObservationId = obsRowId || null;
|
||||
const obsParam = selectedObservationId ? `?obs=${selectedObservationId}` : '';
|
||||
if (hash) {
|
||||
history.replaceState(null, '', `#/packets/${hash}${obsParam}`);
|
||||
} else {
|
||||
history.replaceState(null, '', `#/packets/${id}${obsParam}`);
|
||||
}
|
||||
renderTableRows();
|
||||
const isMobileNow = window.innerWidth <= 640;
|
||||
let panel;
|
||||
@@ -748,7 +1066,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api(`/packets/${id}`);
|
||||
const data = prefetchedData || await api(hash ? `/packets/${hash}` : `/packets/${id}`);
|
||||
// Resolve path hops for detail view
|
||||
const pkt = data.packet;
|
||||
try {
|
||||
@@ -799,6 +1117,29 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const observations = data.observations || [];
|
||||
const obsCount = data.observation_count || observations.length || 1;
|
||||
const uniqueObservers = new Set(observations.map(o => o.observer_id)).size;
|
||||
|
||||
// Propagation time: spread between first and last observation
|
||||
let propagationHtml = '—';
|
||||
if (observations.length >= 2) {
|
||||
const times = observations.map(o => new Date(o.timestamp).getTime()).filter(t => !isNaN(t));
|
||||
if (times.length >= 2) {
|
||||
const first = Math.min(...times);
|
||||
const last = Math.max(...times);
|
||||
const spread = last - first;
|
||||
if (spread < 1000) {
|
||||
propagationHtml = `${spread}ms`;
|
||||
} else if (spread < 60000) {
|
||||
propagationHtml = `${(spread / 1000).toFixed(1)}s`;
|
||||
} else {
|
||||
propagationHtml = `${(spread / 60000).toFixed(1)}m`;
|
||||
}
|
||||
propagationHtml += ` <span style="color:var(--text-muted);font-size:0.85em">(${obsCount} obs × ${uniqueObservers} observers)</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="detail-title">${hasRawHex ? `Packet Byte Breakdown (${size} bytes)` : typeName + ' Packet'}</div>
|
||||
<div class="detail-hash">${pkt.hash || 'Packet #' + pkt.id}</div>
|
||||
@@ -810,11 +1151,13 @@
|
||||
<dt>Payload Type</dt><dd><span class="badge badge-${payloadTypeColor(pkt.payload_type)}">${typeName}</span></dd>
|
||||
${hashSize ? `<dt>Hash Size</dt><dd>${hashSize} byte${hashSize !== 1 ? 's' : ''}</dd>` : ''}
|
||||
<dt>Timestamp</dt><dd>${pkt.timestamp}</dd>
|
||||
<dt>Propagation</dt><dd>${propagationHtml}</dd>
|
||||
<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>
|
||||
<button class="copy-link-btn" data-packet-hash="${pkt.hash || ''}" 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>` : ''}
|
||||
${pkt.hash ? `<a href="#/traces/${pkt.hash}" class="detail-map-link" style="text-decoration:none">🔍 Trace</a>` : ''}
|
||||
<button class="replay-live-btn" title="Replay this packet on the live map">▶ Replay</button>
|
||||
</div>
|
||||
|
||||
@@ -828,7 +1171,9 @@
|
||||
const copyLinkBtn = panel.querySelector('.copy-link-btn');
|
||||
if (copyLinkBtn) {
|
||||
copyLinkBtn.addEventListener('click', () => {
|
||||
const url = `${location.origin}/#/packet/${copyLinkBtn.dataset.packetId}`;
|
||||
const pktHash = copyLinkBtn.dataset.packetHash;
|
||||
const obsParam = selectedObservationId ? `?obs=${selectedObservationId}` : '';
|
||||
const url = pktHash ? `${location.origin}/#/packets/${pktHash}${obsParam}` : `${location.origin}/#/packets/${copyLinkBtn.dataset.packetId}${obsParam}`;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
copyLinkBtn.textContent = '✅ Copied!';
|
||||
setTimeout(() => { copyLinkBtn.textContent = '🔗 Copy Link'; }, 1500);
|
||||
@@ -842,13 +1187,31 @@
|
||||
const replayBtn = panel.querySelector('.replay-live-btn');
|
||||
if (replayBtn) {
|
||||
replayBtn.addEventListener('click', () => {
|
||||
const livePkt = {
|
||||
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: obsName(pkt.observer_id)
|
||||
};
|
||||
sessionStorage.setItem('replay-packet', JSON.stringify(livePkt));
|
||||
// Build replay packets for ALL observations of this transmission
|
||||
const obs = data.observations || [];
|
||||
const replayPackets = [];
|
||||
if (obs.length > 1) {
|
||||
for (const o of obs) {
|
||||
let oPath;
|
||||
try { oPath = JSON.parse(o.path_json || '[]'); } catch { oPath = pathHops; }
|
||||
let oDec;
|
||||
try { oDec = JSON.parse(o.decoded_json || '{}'); } catch { oDec = decoded; }
|
||||
replayPackets.push({
|
||||
id: o.id, hash: pkt.hash,
|
||||
_ts: new Date(o.timestamp).getTime(),
|
||||
decoded: { header: { payloadTypeName: typeName }, payload: oDec, path: { hops: oPath } },
|
||||
snr: o.snr, rssi: o.rssi, observer: obsName(o.observer_id)
|
||||
});
|
||||
}
|
||||
} else {
|
||||
replayPackets.push({
|
||||
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: obsName(pkt.observer_id)
|
||||
});
|
||||
}
|
||||
sessionStorage.setItem('replay-packet', JSON.stringify(replayPackets));
|
||||
window.location.hash = '#/live';
|
||||
});
|
||||
}
|
||||
@@ -858,16 +1221,32 @@
|
||||
if (routeBtn && pathHops.length) {
|
||||
routeBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
// Anchor disambiguation from sender's location if known (e.g. ADVERT lat/lon)
|
||||
const senderLat = decoded.lat || decoded.latitude;
|
||||
const senderLon = decoded.lon || decoded.longitude;
|
||||
// Resolve observer position for backward-pass anchor
|
||||
let obsLat = null, obsLon = null;
|
||||
const obsId = obsName(pkt.observer_id);
|
||||
const observerParam = obsId ? '&observer=' + encodeURIComponent(obsId) : '';
|
||||
const resp = await fetch('/api/resolve-hops?hops=' + encodeURIComponent(pathHops.join(',')) + observerParam);
|
||||
const data = await resp.json();
|
||||
// Pass full pubkeys (server-disambiguated) to map, falling back to short prefix
|
||||
if (obsId && HopResolver.ready()) {
|
||||
// Try to find observer in nodes list by name — best effort
|
||||
}
|
||||
await ensureHopResolver();
|
||||
const data = { resolved: HopResolver.resolve(pathHops, senderLat || null, senderLon || null, obsLat, obsLon) };
|
||||
// Pass full pubkeys (client-disambiguated) to map, falling back to short prefix
|
||||
const resolvedKeys = pathHops.map(h => {
|
||||
const r = data.resolved?.[h];
|
||||
return r?.pubkey || h;
|
||||
});
|
||||
sessionStorage.setItem('map-route-hops', JSON.stringify(resolvedKeys));
|
||||
// Build origin info for the sender node
|
||||
const origin = {};
|
||||
if (decoded.pubKey) origin.pubkey = decoded.pubKey;
|
||||
else if (decoded.srcHash) origin.pubkey = decoded.srcHash;
|
||||
if (decoded.adName || decoded.name) origin.name = decoded.adName || decoded.name;
|
||||
if (senderLat != null && senderLon != null) { origin.lat = senderLat; origin.lon = senderLon; }
|
||||
sessionStorage.setItem('map-route-hops', JSON.stringify({
|
||||
origin: origin,
|
||||
hops: resolvedKeys
|
||||
}));
|
||||
window.location.hash = '#/map?route=1';
|
||||
} catch {
|
||||
window.location.hash = '#/map';
|
||||
@@ -944,13 +1323,17 @@
|
||||
fOff += 8;
|
||||
}
|
||||
if (decoded.flags.hasName) {
|
||||
rows += fieldRow(fOff, 'Node Name', escapeHtml(decoded.name || ''), '');
|
||||
rows += fieldRow(fOff, 'Node Name', decoded.pubKey ? `<a href="#/nodes/${encodeURIComponent(decoded.pubKey)}" class="hop-link hop-named" data-hop-link="true">${escapeHtml(decoded.name || '')}</a>` : escapeHtml(decoded.name || ''), '');
|
||||
}
|
||||
}
|
||||
} else if (decoded.type === 'GRP_TXT') {
|
||||
rows += fieldRow(off, 'Channel Hash', decoded.channelHash, '');
|
||||
rows += fieldRow(off + 1, 'MAC (2B)', decoded.mac || '', '');
|
||||
rows += fieldRow(off + 3, 'Encrypted Data', truncate(decoded.encryptedData || '', 30), '');
|
||||
} else if (decoded.type === 'CHAN') {
|
||||
rows += fieldRow(off, 'Channel', decoded.channel || `0x${(decoded.channelHash || 0).toString(16)}`, '');
|
||||
rows += fieldRow(off + 1, 'Sender', decoded.sender || '—', '');
|
||||
if (decoded.sender_timestamp) rows += fieldRow(off + 2, 'Sender Time', decoded.sender_timestamp, '');
|
||||
} else if (decoded.type === 'ACK') {
|
||||
rows += fieldRow(off, 'Dest Hash (6B)', decoded.destHash || '', '');
|
||||
rows += fieldRow(off + 6, 'Src Hash (6B)', decoded.srcHash || '', '');
|
||||
@@ -983,11 +1366,11 @@
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.innerHTML = '<div class="modal byop-modal" role="dialog" aria-label="Decode a Packet" aria-modal="true">'
|
||||
+ '<div class="byop-header"><h3>📦 Decode a Packet</h3><button class="btn-icon byop-x" title="Close">✕</button></div>'
|
||||
+ '<div class="byop-header"><h3>📦 Decode a Packet</h3><button class="btn-icon byop-x" title="Close" aria-label="Close dialog">✕</button></div>'
|
||||
+ '<p class="text-muted" style="margin:0 0 12px;font-size:.85rem">Paste raw hex bytes from your radio or MQTT feed:</p>'
|
||||
+ '<textarea id="byopHex" class="byop-input" placeholder="e.g. 15C31A8D4674FEAE37..." spellcheck="false"></textarea>'
|
||||
+ '<textarea id="byopHex" class="byop-input" aria-label="Packet hex data" placeholder="e.g. 15C31A8D4674FEAE37..." spellcheck="false"></textarea>'
|
||||
+ '<button class="btn-primary byop-go" id="byopDecode" style="width:100%;margin:8px 0">Decode</button>'
|
||||
+ '<div id="byopResult"></div>'
|
||||
+ '<div id="byopResult" role="status" aria-live="polite"></div>'
|
||||
+ '</div>';
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
@@ -1030,7 +1413,7 @@
|
||||
const hex = textarea.value.trim().replace(/[\s\n]/g, '');
|
||||
const result = document.getElementById('byopResult');
|
||||
if (!hex) { result.innerHTML = '<p class="text-muted">Enter hex data</p>'; return; }
|
||||
if (!/^[0-9a-fA-F]+$/.test(hex)) { result.innerHTML = '<p class="byop-err">Invalid hex — only 0-9 and A-F allowed</p>'; return; }
|
||||
if (!/^[0-9a-fA-F]+$/.test(hex)) { result.innerHTML = '<p class="byop-err" role="alert">Invalid hex — only 0-9 and A-F allowed</p>'; return; }
|
||||
result.innerHTML = '<p class="text-muted">Decoding...</p>';
|
||||
try {
|
||||
const res = await fetch('/api/decode', {
|
||||
@@ -1041,7 +1424,7 @@
|
||||
if (data.error) throw new Error(data.error);
|
||||
result.innerHTML = renderDecodedPacket(data.decoded, hex);
|
||||
} catch (e) {
|
||||
result.innerHTML = '<p class="byop-err">❌ ' + e.message + '</p>';
|
||||
result.innerHTML = '<p class="byop-err" role="alert">❌ ' + e.message + '</p>';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1107,6 +1490,66 @@
|
||||
} catch {}
|
||||
})();
|
||||
|
||||
// Observation sort modes
|
||||
const SORT_OBSERVER = 'observer';
|
||||
const SORT_PATH_ASC = 'path-asc';
|
||||
const SORT_PATH_DESC = 'path-desc';
|
||||
const SORT_CHRONO_ASC = 'chrono-asc';
|
||||
const SORT_CHRONO_DESC = 'chrono-desc';
|
||||
let obsSortMode = localStorage.getItem('meshcore-obs-sort') || SORT_OBSERVER;
|
||||
|
||||
function getPathHopCount(c) {
|
||||
try { return JSON.parse(c.path_json || '[]').length; } catch { return 0; }
|
||||
}
|
||||
|
||||
function sortGroupChildren(group) {
|
||||
if (!group || !group._children || !group._children.length) return;
|
||||
const mode = obsSortMode;
|
||||
|
||||
if (mode === SORT_CHRONO_ASC || mode === SORT_CHRONO_DESC) {
|
||||
const dir = mode === SORT_CHRONO_ASC ? 1 : -1;
|
||||
group._children.sort((a, b) => {
|
||||
const tA = a.timestamp || '', tB = b.timestamp || '';
|
||||
return tA < tB ? -dir : tA > tB ? dir : 0;
|
||||
});
|
||||
} else if (mode === SORT_PATH_ASC || mode === SORT_PATH_DESC) {
|
||||
const dir = mode === SORT_PATH_ASC ? 1 : -1;
|
||||
group._children.sort((a, b) => {
|
||||
const lenA = getPathHopCount(a), lenB = getPathHopCount(b);
|
||||
if (lenA !== lenB) return (lenA - lenB) * dir;
|
||||
const oA = (a.observer_name || '').toLowerCase(), oB = (b.observer_name || '').toLowerCase();
|
||||
return oA < oB ? -1 : oA > oB ? 1 : 0;
|
||||
});
|
||||
} else {
|
||||
// Default: group by observer, earliest-observer first, then ascending time within each
|
||||
const earliest = {};
|
||||
for (const c of group._children) {
|
||||
const obs = c.observer_name || c.observer || '';
|
||||
const t = c.timestamp || c.rx_at || c.created_at || '';
|
||||
if (!earliest[obs] || t < earliest[obs]) earliest[obs] = t;
|
||||
}
|
||||
group._children.sort((a, b) => {
|
||||
const oA = a.observer_name || a.observer || '', oB = b.observer_name || b.observer || '';
|
||||
const eA = earliest[oA] || '', eB = earliest[oB] || '';
|
||||
if (eA !== eB) return eA < eB ? -1 : 1;
|
||||
if (oA !== oB) return oA < oB ? -1 : 1;
|
||||
const tA = a.timestamp || a.rx_at || '', tB = b.timestamp || b.rx_at || '';
|
||||
return tA < tB ? -1 : tA > tB ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Update header row to match first sorted child
|
||||
const first = group._children[0];
|
||||
if (first) {
|
||||
group.observer_id = first.observer_id;
|
||||
group.observer_name = first.observer_name;
|
||||
group.snr = first.snr;
|
||||
group.rssi = first.rssi;
|
||||
group.path_json = first.path_json;
|
||||
group.direction = first.direction;
|
||||
}
|
||||
}
|
||||
|
||||
// Global handlers
|
||||
async function pktToggleGroup(hash) {
|
||||
if (expandedHashes.has(hash)) {
|
||||
@@ -1114,12 +1557,18 @@
|
||||
renderTableRows();
|
||||
return;
|
||||
}
|
||||
// Load children (observations) for this hash
|
||||
// Single fetch — gets packet + observations + path + breakdown
|
||||
try {
|
||||
const data = await api(`/packets?hash=${hash}&limit=1&expand=observations`);
|
||||
const pkt = (data.packets || [])[0];
|
||||
const data = await api(`/packets/${hash}`);
|
||||
const pkt = data.packet;
|
||||
if (!pkt) return;
|
||||
const group = packets.find(p => p.hash === hash);
|
||||
if (group && pkt) group._children = (pkt.observations || []).map(o => ({...pkt, ...o, _isObservation: true}));
|
||||
if (group && data.observations) {
|
||||
group._children = data.observations.map(o => ({...pkt, ...o, _isObservation: true}));
|
||||
group._fetchedData = data;
|
||||
// Sort children based on current sort mode
|
||||
sortGroupChildren(group);
|
||||
}
|
||||
// Resolve any new hops from children
|
||||
const childHops = new Set();
|
||||
for (const c of (group?._children || [])) {
|
||||
@@ -1129,26 +1578,28 @@
|
||||
if (newHops.length) await resolveHops(newHops);
|
||||
expandedHashes.add(hash);
|
||||
renderTableRows();
|
||||
// Also open detail panel — no extra fetch needed
|
||||
selectPacket(pkt.id, hash, data);
|
||||
} catch {}
|
||||
}
|
||||
async function pktSelectHash(hash) {
|
||||
// When grouped, find first packet with this hash
|
||||
// When grouped, select packet — reuse cached detail endpoint
|
||||
try {
|
||||
const data = await api(`/packets?hash=${hash}&limit=1`);
|
||||
if (data.packets?.[0]) selectPacket(data.packets[0].id);
|
||||
const data = await api(`/packets/${hash}`);
|
||||
if (data?.packet) selectPacket(data.packet.id, hash, data);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
registerPage('packets', { init, destroy });
|
||||
|
||||
// Standalone packet detail page: #/packet/123
|
||||
// Standalone packet detail page: #/packet/123 or #/packet/HASH
|
||||
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>`;
|
||||
const param = routeParam;
|
||||
app.innerHTML = `<div style="max-width:800px;margin:0 auto;padding:20px"><div class="text-center text-muted" style="padding:40px">Loading packet…</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 data = await api(`/packets/${param}`);
|
||||
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 ${param} 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));
|
||||
|
||||
216
public/region-filter.js
Normal file
216
public/region-filter.js
Normal file
@@ -0,0 +1,216 @@
|
||||
/* === MeshCore Analyzer — region-filter.js (shared region filter component) === */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
var LS_KEY = 'meshcore-region-filter';
|
||||
var _regions = {}; // { code: label }
|
||||
var _selected = null; // Set of selected region codes, null = all
|
||||
var _listeners = [];
|
||||
var _loaded = false;
|
||||
|
||||
function loadFromStorage() {
|
||||
try {
|
||||
var stored = JSON.parse(localStorage.getItem(LS_KEY));
|
||||
if (Array.isArray(stored) && stored.length > 0) return new Set(stored);
|
||||
} catch (e) { /* ignore */ }
|
||||
return null; // null = all selected
|
||||
}
|
||||
|
||||
function saveToStorage() {
|
||||
if (!_selected) {
|
||||
localStorage.removeItem(LS_KEY);
|
||||
} else {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(Array.from(_selected)));
|
||||
}
|
||||
}
|
||||
|
||||
_selected = loadFromStorage();
|
||||
|
||||
/** Fetch regions from server */
|
||||
async function fetchRegions() {
|
||||
if (_loaded) return _regions;
|
||||
try {
|
||||
var data = await fetch('/api/config/regions').then(function (r) { return r.json(); });
|
||||
_regions = data || {};
|
||||
_loaded = true;
|
||||
// If stored selection has codes no longer valid, clean up
|
||||
if (_selected) {
|
||||
var codes = Object.keys(_regions);
|
||||
var cleaned = new Set();
|
||||
_selected.forEach(function (c) { if (codes.includes(c)) cleaned.add(c); });
|
||||
_selected = cleaned.size > 0 ? cleaned : null;
|
||||
saveToStorage();
|
||||
}
|
||||
} catch (e) {
|
||||
_regions = {};
|
||||
}
|
||||
return _regions;
|
||||
}
|
||||
|
||||
/** Get selected regions as array, or null if all */
|
||||
function getSelected() {
|
||||
if (!_selected || _selected.size === 0) return null;
|
||||
return Array.from(_selected);
|
||||
}
|
||||
|
||||
/** Get region query param string for API calls: "SJC,SFO" or empty */
|
||||
function getRegionParam() {
|
||||
var sel = getSelected();
|
||||
return sel ? sel.join(',') : '';
|
||||
}
|
||||
|
||||
/** Build query string fragment: "®ion=SJC,SFO" or "" */
|
||||
function regionQueryString() {
|
||||
var p = getRegionParam();
|
||||
return p ? '®ion=' + encodeURIComponent(p) : '';
|
||||
}
|
||||
|
||||
/** Handle a region toggle (shared logic for both pill and dropdown modes) */
|
||||
function toggleRegion(region, codes, container) {
|
||||
if (region === '__all__') {
|
||||
_selected = null;
|
||||
} else {
|
||||
if (!_selected) {
|
||||
_selected = new Set([region]);
|
||||
} else if (_selected.has(region)) {
|
||||
_selected.delete(region);
|
||||
if (_selected.size === 0) _selected = null;
|
||||
} else {
|
||||
_selected.add(region);
|
||||
}
|
||||
if (_selected && _selected.size === codes.length) _selected = null;
|
||||
}
|
||||
saveToStorage();
|
||||
render(container);
|
||||
_listeners.forEach(function (fn) { fn(getSelected()); });
|
||||
}
|
||||
|
||||
/** Build summary label for dropdown trigger */
|
||||
function dropdownLabel(codes) {
|
||||
if (!_selected) return 'All Regions';
|
||||
var sel = Array.from(_selected);
|
||||
if (sel.length === 0) return 'All Regions';
|
||||
if (sel.length <= 2) return sel.join(', ');
|
||||
return sel.length + ' Regions';
|
||||
}
|
||||
|
||||
/** Render pill bar mode (≤4 regions) */
|
||||
function renderPills(container, codes) {
|
||||
var allSelected = !_selected;
|
||||
var html = '<div class="region-filter-bar" role="group" aria-label="Region filter">';
|
||||
html += '<span class="region-filter-label" id="region-filter-label">Region:</span>';
|
||||
html += '<button class="region-pill' + (allSelected ? ' region-pill-active' : '') +
|
||||
'" data-region="__all__" role="checkbox" aria-checked="' + allSelected + '">All</button>';
|
||||
codes.forEach(function (code) {
|
||||
var label = _regions[code] || code;
|
||||
var active = allSelected || (_selected && _selected.has(code));
|
||||
html += '<button class="region-pill' + (active ? ' region-pill-active' : '') +
|
||||
'" data-region="' + code + '" role="checkbox" aria-checked="' + !!active + '">' + label + '</button>';
|
||||
});
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
|
||||
container.onclick = function (e) {
|
||||
var btn = e.target.closest('[data-region]');
|
||||
if (!btn) return;
|
||||
toggleRegion(btn.dataset.region, codes, container);
|
||||
};
|
||||
}
|
||||
|
||||
/** Render dropdown mode (>4 regions) */
|
||||
function renderDropdown(container, codes) {
|
||||
var allSelected = !_selected;
|
||||
var html = '<div class="region-dropdown-wrap" role="group" aria-label="Region filter">';
|
||||
html += '<button class="region-dropdown-trigger" aria-haspopup="listbox" aria-expanded="false">' +
|
||||
dropdownLabel(codes) + ' ▾</button>';
|
||||
html += '<div class="region-dropdown-menu" role="listbox" aria-label="Select regions" hidden>';
|
||||
html += '<label class="region-dropdown-item"><input type="checkbox" data-region="__all__"' +
|
||||
(allSelected ? ' checked' : '') + '> <strong>All</strong></label>';
|
||||
codes.forEach(function (code) {
|
||||
var label = _regions[code] ? (code + ' - ' + _regions[code]) : code;
|
||||
var active = allSelected || (_selected && _selected.has(code));
|
||||
html += '<label class="region-dropdown-item"><input type="checkbox" data-region="' + code + '"' +
|
||||
(active ? ' checked' : '') + '> ' + label + '</label>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
container.innerHTML = html;
|
||||
|
||||
var trigger = container.querySelector('.region-dropdown-trigger');
|
||||
var menu = container.querySelector('.region-dropdown-menu');
|
||||
|
||||
trigger.onclick = function () {
|
||||
var open = !menu.hidden;
|
||||
menu.hidden = open;
|
||||
trigger.setAttribute('aria-expanded', String(!open));
|
||||
};
|
||||
|
||||
menu.onchange = function (e) {
|
||||
var input = e.target;
|
||||
if (!input.dataset.region) return;
|
||||
toggleRegion(input.dataset.region, codes, container);
|
||||
};
|
||||
|
||||
// Close on outside click
|
||||
function onDocClick(e) {
|
||||
if (!container.contains(e.target)) {
|
||||
menu.hidden = true;
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', onDocClick, true);
|
||||
container._regionCleanup = function () {
|
||||
document.removeEventListener('click', onDocClick, true);
|
||||
};
|
||||
}
|
||||
|
||||
/** Render the filter bar into a container element */
|
||||
function render(container) {
|
||||
// Clean up previous outside-click listener if any
|
||||
if (container._regionCleanup) { container._regionCleanup(); container._regionCleanup = null; }
|
||||
|
||||
var codes = Object.keys(_regions);
|
||||
if (codes.length < 2) {
|
||||
container.innerHTML = '';
|
||||
container.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
container.style.display = '';
|
||||
|
||||
if (codes.length > 4 || container._forceDropdown) {
|
||||
renderDropdown(container, codes);
|
||||
} else {
|
||||
renderPills(container, codes);
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to selection changes. Callback receives selected array or null */
|
||||
function onChange(fn) {
|
||||
_listeners.push(fn);
|
||||
return fn;
|
||||
}
|
||||
|
||||
/** Unsubscribe */
|
||||
function offChange(fn) {
|
||||
_listeners = _listeners.filter(function (f) { return f !== fn; });
|
||||
}
|
||||
|
||||
/** Initialize filter in a container, fetch regions, render, return promise.
|
||||
* Options: { dropdown: true } to force dropdown mode regardless of region count */
|
||||
async function initFilter(container, opts) {
|
||||
if (opts && opts.dropdown) container._forceDropdown = true;
|
||||
await fetchRegions();
|
||||
render(container);
|
||||
}
|
||||
|
||||
// Expose globally
|
||||
window.RegionFilter = {
|
||||
init: initFilter,
|
||||
render: render,
|
||||
getSelected: getSelected,
|
||||
getRegionParam: getRegionParam,
|
||||
regionQueryString: regionQueryString,
|
||||
onChange: onChange,
|
||||
offChange: offChange,
|
||||
fetchRegions: fetchRegions
|
||||
};
|
||||
})();
|
||||
@@ -87,6 +87,9 @@
|
||||
// ─── WebSocket reconnect delay (ms) ───
|
||||
window.WS_RECONNECT_MS = 3000;
|
||||
|
||||
// ─── Propagation buffer (ms) for realistic mode ───
|
||||
window.PROPAGATION_BUFFER_MS = 5000;
|
||||
|
||||
// ─── Cache invalidation debounce (ms) ───
|
||||
window.CACHE_INVALIDATE_MS = 5000;
|
||||
|
||||
@@ -119,6 +122,7 @@
|
||||
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);
|
||||
if (cfg.propagationBufferMs != null) window.PROPAGATION_BUFFER_MS = cfg.propagationBufferMs;
|
||||
// Sync ROLE_STYLE colors with ROLE_COLORS
|
||||
for (var role in ROLE_STYLE) {
|
||||
if (ROLE_COLORS[role]) ROLE_STYLE[role].color = ROLE_COLORS[role];
|
||||
|
||||
@@ -188,22 +188,34 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; align-items: center;
|
||||
}
|
||||
.filter-bar input, .filter-bar select {
|
||||
padding: 4px 8px; border: 1px solid var(--border); border-radius: 4px;
|
||||
font-size: 12px; background: var(--input-bg); color: var(--text); font-family: var(--font);
|
||||
padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px;
|
||||
font-size: 13px; background: var(--input-bg); color: var(--text); font-family: var(--font);
|
||||
height: 34px; box-sizing: border-box; line-height: 1;
|
||||
}
|
||||
.filter-bar input { width: 120px; }
|
||||
.filter-bar select { min-width: 90px; }
|
||||
.filter-bar .btn {
|
||||
padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--input-bg); cursor: pointer; font-size: 13px; transition: all .15s;
|
||||
font-family: var(--font); color: var(--text);
|
||||
font-family: var(--font); color: var(--text); height: 34px; box-sizing: border-box; line-height: 1;
|
||||
}
|
||||
.filter-group { display: flex; gap: 6px; align-items: center; }
|
||||
.filter-group + .filter-group { border-left: 1px solid var(--border); padding-left: 12px; margin-left: 6px; }
|
||||
.sort-help { cursor: help; font-size: 14px; color: var(--text-muted, #888); position: relative; display: inline-block; }
|
||||
.sort-help-tip {
|
||||
display: none; position: absolute; top: 130%; left: 50%; transform: translateX(-50%);
|
||||
background: var(--card-bg, #222); color: var(--text, #eee); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 8px 12px; font-size: 12px; line-height: 1.5;
|
||||
white-space: pre-line; width: 260px; z-index: 100;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.3); pointer-events: none;
|
||||
}
|
||||
.sort-help:hover .sort-help-tip { display: block; }
|
||||
.filter-bar .btn:hover { background: var(--row-hover); }
|
||||
.filter-bar .btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
|
||||
.btn-icon {
|
||||
background: none; border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 6px 10px; cursor: pointer; font-size: 14px; transition: all .15s;
|
||||
color: var(--text); padding: 6px 10px; cursor: pointer; font-size: 14px; transition: all .15s;
|
||||
}
|
||||
.btn-icon:hover { background: var(--row-hover); }
|
||||
|
||||
@@ -711,6 +723,7 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
[data-theme="dark"] .trace-search input,
|
||||
[data-theme="dark"] .mc-jump-btn,
|
||||
[data-theme="dark"] .filter-bar .btn { background: var(--input-bg); color: var(--text); border-color: var(--border); }
|
||||
[data-theme="dark"] .filter-bar .btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
[data-theme="dark"] .ch-item.selected,
|
||||
[data-theme="dark"] .data-table tbody tr.selected { background: var(--selected-bg); }
|
||||
[data-theme="dark"] .tl-bar-container { background: #334155; }
|
||||
@@ -852,6 +865,8 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
.filter-bar.filters-expanded > .col-toggle-wrap { display: inline-block; }
|
||||
.filter-bar.filters-expanded input { width: 100%; }
|
||||
.filter-bar.filters-expanded select { width: 100%; }
|
||||
.filter-group { flex-wrap: wrap; }
|
||||
.filter-group + .filter-group { border-left: none; padding-left: 0; margin-left: 0; }
|
||||
.filter-bar .btn { min-height: 36px; }
|
||||
.node-filter-wrap { width: 100%; }
|
||||
|
||||
@@ -919,7 +934,7 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
border: 1px solid var(--border); border-radius: 6px; background: var(--surface-1);
|
||||
color: var(--text);
|
||||
}
|
||||
.byop-input:focus { border-color: var(--accent); outline: none; }
|
||||
.byop-input:focus { border-color: var(--accent); outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.byop-err { color: #ef4444; font-size: .85rem; }
|
||||
.byop-decoded { margin-top: 8px; }
|
||||
.byop-section { margin-bottom: 14px; }
|
||||
@@ -1270,10 +1285,11 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
|
||||
/* #71 — Column visibility toggle */
|
||||
.col-toggle-wrap { position: relative; display: inline-block; }
|
||||
.col-toggle-btn { font-size: .8rem; padding: 4px 8px; cursor: pointer; background: var(--input-bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text); }
|
||||
.col-toggle-btn { font-size: 13px; padding: 6px 10px; cursor: pointer; background: var(--input-bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); height: 34px; box-sizing: border-box; line-height: 1; }
|
||||
.col-toggle-menu { display: none; position: absolute; top: 100%; left: 0; z-index: 50; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; padding: 6px 0; min-width: 150px; box-shadow: 0 4px 12px rgba(0,0,0,.15); }
|
||||
.col-toggle-menu.open { display: block; }
|
||||
.col-toggle-menu label { display: flex; align-items: center; gap: 6px; padding: 4px 12px; font-size: .82rem; cursor: pointer; color: var(--text); }
|
||||
.col-toggle-menu label input[type="checkbox"] { width: 14px; height: 14px; margin: 0; flex-shrink: 0; }
|
||||
.col-toggle-menu label:hover { background: var(--row-hover); }
|
||||
|
||||
/* Column hide classes */
|
||||
@@ -1447,3 +1463,70 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
.perf-table .perf-slow td { color: #ef4444; }
|
||||
.perf-table .perf-warn { background: rgba(251, 191, 36, 0.06); }
|
||||
.perf-table .perf-warn td { color: #f59e0b; }
|
||||
|
||||
/* ─── Region filter bar ─── */
|
||||
.region-filter-bar { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0; }
|
||||
.region-filter-container { margin: 0; padding: 0; display: inline-flex; align-items: center; }
|
||||
.region-pill {
|
||||
display: inline-flex; align-items: center; padding: 4px 12px; border-radius: 16px;
|
||||
font-size: 12px; font-weight: 500; cursor: pointer; border: 1.5px solid var(--border);
|
||||
background: transparent; color: var(--text-muted); transition: all 0.15s;
|
||||
}
|
||||
.region-pill:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.region-pill-active {
|
||||
background: var(--accent); color: #fff; border-color: var(--accent);
|
||||
}
|
||||
.region-pill-active:hover { opacity: 0.85; }
|
||||
.region-filter-label {
|
||||
font-size: 12px; font-weight: 600; color: var(--text-muted); align-self: center;
|
||||
margin-right: 2px; user-select: none;
|
||||
}
|
||||
.region-dropdown-wrap { position: relative; display: inline-flex; align-items: center; }
|
||||
.region-dropdown-trigger {
|
||||
display: inline-flex; align-items: center; padding: 6px 10px; border-radius: 6px;
|
||||
font-size: 13px; font-weight: 500; cursor: pointer; border: 1px solid var(--border);
|
||||
background: var(--input-bg); color: var(--text); transition: all 0.15s;
|
||||
height: 34px; box-sizing: border-box; white-space: nowrap; line-height: 1;
|
||||
}
|
||||
.region-dropdown-trigger:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.region-dropdown-menu {
|
||||
position: absolute; top: 100%; left: 0; z-index: 90;
|
||||
min-width: 220px; max-height: 260px; overflow-y: auto;
|
||||
background: var(--card-bg, #fff); border: 1px solid var(--border); border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.12); padding: 4px 0;
|
||||
}
|
||||
.region-dropdown-item {
|
||||
display: flex; align-items: center; gap: 6px; padding: 6px 12px;
|
||||
font-size: 13px; cursor: pointer; color: var(--text); white-space: nowrap;
|
||||
}
|
||||
.region-dropdown-item input[type="checkbox"] {
|
||||
width: 14px; height: 14px; margin: 0; flex-shrink: 0;
|
||||
}
|
||||
.region-dropdown-item:hover { background: var(--row-hover, #f5f5f5); }
|
||||
|
||||
/* Generic multi-select dropdown (Observer, Type filters) */
|
||||
.multi-select-wrap { position: relative; display: inline-flex; align-items: center; }
|
||||
.multi-select-trigger {
|
||||
display: inline-flex; align-items: center; padding: 6px 10px; border-radius: 6px;
|
||||
font-size: 13px; font-weight: 500; cursor: pointer; border: 1px solid var(--border);
|
||||
background: var(--input-bg); color: var(--text); transition: all 0.15s;
|
||||
height: 34px; box-sizing: border-box; white-space: nowrap; line-height: 1;
|
||||
}
|
||||
.multi-select-trigger:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.multi-select-menu {
|
||||
position: absolute; top: 100%; left: 0; z-index: 90;
|
||||
min-width: 220px; max-height: 260px; overflow-y: auto;
|
||||
background: var(--card-bg, #fff); border: 1px solid var(--border); border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.12); padding: 4px 0; display: none;
|
||||
}
|
||||
.multi-select-menu.open { display: block; }
|
||||
.multi-select-item {
|
||||
display: flex; align-items: center; gap: 6px; padding: 6px 12px;
|
||||
font-size: 13px; cursor: pointer; color: var(--text); white-space: nowrap;
|
||||
}
|
||||
.multi-select-item input[type="checkbox"] {
|
||||
width: 14px; height: 14px; margin: 0; flex-shrink: 0;
|
||||
}
|
||||
.multi-select-item:hover { background: var(--row-hover, #f5f5f5); }
|
||||
|
||||
.chan-tag { background: var(--accent, #3b82f6); color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 0.9em; font-weight: 600; }
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
let currentHash = null;
|
||||
let traceData = [];
|
||||
let packetMeta = null;
|
||||
|
||||
function init(app) {
|
||||
// Check URL for pre-filled hash
|
||||
function init(app, routeParam) {
|
||||
// Check URL for pre-filled hash — support both route param and query param
|
||||
const params = new URLSearchParams(location.hash.split('?')[1] || '');
|
||||
const urlHash = params.get('hash') || '';
|
||||
const urlHash = routeParam || params.get('hash') || '';
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="traces-page">
|
||||
|
||||
Reference in New Issue
Block a user