Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95b59d1792 | ||
|
|
e7aa4246ac | ||
|
|
f1aa6caf93 | ||
|
|
a882aae681 | ||
|
|
aa35164252 | ||
|
|
84f33aef7b | ||
|
|
baa60cac0f | ||
|
|
d7e415daa7 | ||
|
|
2c6148fd2d | ||
|
|
2feb2c5b94 | ||
|
|
10b11106f6 | ||
|
|
326d411c4a | ||
|
|
15a93d5ea4 | ||
|
|
055467ca43 | ||
|
|
4f7b02a91c | ||
|
|
f0db317051 | ||
|
|
9bf78bd28d | ||
|
|
5fe275b3f8 | ||
|
|
74a08d99b0 | ||
|
|
76d63ffe75 | ||
|
|
157dc9a979 | ||
|
|
2f07ae2e5c | ||
|
|
1f9cd3ead1 | ||
|
|
4b5b801def | ||
|
|
8ef65def7d | ||
|
|
9ef5c1a809 | ||
|
|
e31e4aa356 | ||
|
|
db884f12eb | ||
|
|
116f0c8dfb | ||
|
|
b3e8dcaa93 | ||
|
|
920eab04c1 | ||
|
|
d67b531bf2 | ||
|
|
5a6847bbf4 | ||
|
|
034c68c154 | ||
|
|
477dcde82f | ||
|
|
c997318cd2 | ||
|
|
fa40ede9e7 | ||
|
|
8ce2262813 | ||
|
|
90c4c03ac3 | ||
|
|
87bbd93d12 | ||
|
|
e837dba000 | ||
|
|
fa72e6242d | ||
|
|
2fcbcd97d1 | ||
|
|
311db0285d | ||
|
|
01df7f7871 | ||
|
|
90fa755e7d | ||
|
|
039d1fc28f | ||
|
|
d8c0e3a156 | ||
|
|
bee124e6d2 | ||
|
|
fd919a2a80 | ||
|
|
f58728118d | ||
|
|
4aa78305d3 | ||
|
|
8659cda7b7 | ||
|
|
2713d501b4 | ||
|
|
4ff72935ca | ||
|
|
a756517647 | ||
|
|
74983d3f74 | ||
|
|
ab35ced2bf | ||
|
|
ff86a78480 | ||
|
|
2a076dfb1d | ||
|
|
fceff15e2f | ||
|
|
9c87f0040e | ||
|
|
395abc2585 | ||
|
|
e82e4fe05f | ||
|
|
6cf9793706 | ||
|
|
1772b34e8f | ||
|
|
fea8a7e0b5 | ||
|
|
2e486e2a66 | ||
|
|
f0c29b38f1 | ||
|
|
46d9b690ee | ||
|
|
2e51e5f743 | ||
|
|
11b398cfe1 | ||
|
|
f4ac789ee9 | ||
|
|
2a2a80b4ea | ||
|
|
6dd077be13 | ||
|
|
2b3597dff1 | ||
|
|
77b7b218b1 | ||
|
|
0a499745ec | ||
|
|
c83eb099c9 | ||
|
|
cd01da5a64 | ||
|
|
0b4590e48d | ||
|
|
f5d377e396 | ||
|
|
dc703ebf28 | ||
|
|
89c1e84924 | ||
|
|
50b6124325 | ||
|
|
f08756a6ac | ||
|
|
c2bc07bb4a | ||
|
|
e589fd959a | ||
|
|
706227b106 | ||
|
|
44f9a95ec5 | ||
|
|
b481df424f | ||
|
|
2edcca77f1 | ||
|
|
cd678d492d | ||
|
|
4c6172bc6e | ||
|
|
d01fa7e17f | ||
|
|
35e86c34e0 | ||
|
|
f8638974c7 | ||
|
|
1be6b4f4ad | ||
|
|
d8d0572abb | ||
|
|
de658bfb0d | ||
|
|
720d019a28 | ||
|
|
ce030c91f7 | ||
|
|
99ef07ca05 | ||
|
|
141c28231e | ||
|
|
2b7ed064d1 | ||
|
|
415440d36d | ||
|
|
5832c73a0d | ||
|
|
e98e04553a | ||
|
|
8587286896 | ||
|
|
4fff11976e | ||
|
|
68b79d2d50 | ||
|
|
681cf82cd6 | ||
|
|
36598a3623 | ||
|
|
0dc2dd3f25 | ||
|
|
0106c8ebf9 | ||
|
|
cb4773b426 | ||
|
|
9c6608acc2 | ||
|
|
dc4e91a348 | ||
|
|
78034cbbc0 | ||
|
|
435a19057a | ||
|
|
90abb42904 | ||
|
|
93c7f4c9eb | ||
|
|
175d9269ec | ||
|
|
4fc9c25a5d | ||
|
|
d7f0e0c9fe | ||
|
|
f054841a99 | ||
|
|
0be9e8b4fd | ||
|
|
08063c1316 | ||
|
|
ffe26f7d03 | ||
|
|
d99aa3ac11 | ||
|
|
b407fa4f28 | ||
|
|
6cc2f3a8c7 | ||
|
|
c62902cd9c | ||
|
|
46abc7b11b | ||
|
|
80215f9d31 | ||
|
|
25ae36c4b6 | ||
|
|
6ca5336563 | ||
|
|
fda8d73588 | ||
|
|
ca21dc5608 | ||
|
|
f2b0145da0 | ||
|
|
3048166648 | ||
|
|
f947af5b01 | ||
|
|
ad5e12fc45 | ||
|
|
55ee3c6327 | ||
|
|
d1a4333b87 | ||
|
|
b91ba7e38a | ||
|
|
fb1cfae089 | ||
|
|
3be3a039f1 | ||
|
|
8549ac4ac9 | ||
|
|
a84a8b8bb0 | ||
|
|
77bc6c9391 | ||
|
|
bab0b6c441 | ||
|
|
2e48e5db2f | ||
|
|
4060ddd326 | ||
|
|
b62c8c7b43 | ||
|
|
963778632e | ||
|
|
21b1cbc332 | ||
|
|
1ecc95db5a | ||
|
|
d41477d1d8 | ||
|
|
58531e5da7 | ||
|
|
84b817745f | ||
|
|
989de353b5 | ||
|
|
d4c131ec1e | ||
|
|
3e38d88bed | ||
|
|
4431769b00 | ||
|
|
f8b05f15b9 | ||
|
|
2cf11bea54 | ||
|
|
fe34fc81a7 | ||
|
|
cfbdc8a9e0 | ||
|
|
8e3a860cb7 | ||
|
|
08d3fd3539 | ||
|
|
ca96d5dfbc | ||
|
|
6dd258be65 | ||
|
|
27ba362ace | ||
|
|
e44f288dab | ||
|
|
dac05aff1a | ||
|
|
472090aeb5 | ||
|
|
ec20906fe1 | ||
|
|
45590991dd | ||
|
|
5b4c741f19 | ||
|
|
016ba3bef6 | ||
|
|
6b8526548a | ||
|
|
5d536c382f | ||
|
|
71969785bf | ||
|
|
3cbf315e99 | ||
|
|
11e6973bca | ||
|
|
9e4308a1d0 | ||
|
|
b0f6ccf12e | ||
|
|
b36790445b | ||
|
|
767754fc10 | ||
|
|
924587e2dc | ||
|
|
780f8477e3 | ||
|
|
2414059bac | ||
|
|
1c25a2bc5b | ||
|
|
bd560b9e52 | ||
|
|
5255f7091e | ||
|
|
e8be570ff5 | ||
|
|
02ae79beba | ||
|
|
1f3b8756af | ||
|
|
cb2b67a8b5 | ||
|
|
3372870674 | ||
|
|
e1b382a5fe | ||
|
|
7260b36534 | ||
|
|
72743fd9ee | ||
|
|
e1a465b113 | ||
|
|
080d4bc3c1 | ||
|
|
b4d0d6a056 | ||
|
|
5205cf04fe | ||
|
|
fd77731b54 | ||
|
|
5b78a4a216 | ||
|
|
7c90a260ca | ||
|
|
8616145c98 | ||
|
|
e1873e1451 | ||
|
|
3f1f6b91c7 | ||
|
|
dbb7abb72c | ||
|
|
1c5dfd67a9 | ||
|
|
3831e3e4b9 | ||
|
|
169e8b0c02 | ||
|
|
01670e7671 | ||
|
|
a0c2429756 | ||
|
|
1159886285 | ||
|
|
c4e82551c2 | ||
|
|
02b8034a4c | ||
|
|
6e8c941396 | ||
|
|
81520e4660 | ||
|
|
ec87455b79 | ||
|
|
0eca0ce61c | ||
|
|
0f06fe881d | ||
|
|
53117c79a7 | ||
|
|
2dabad8fb9 | ||
|
|
4ae565b829 | ||
|
|
4726245988 | ||
|
|
fdbd6511ff | ||
|
|
850768395e | ||
|
|
69297d8eec | ||
|
|
b922c20604 | ||
|
|
25fc2924e0 | ||
|
|
a271696766 | ||
|
|
bcf1a6d90a | ||
|
|
49ed4b80e2 | ||
|
|
c969a0218f | ||
|
|
4a21f8419f | ||
|
|
b7d8ec0cad | ||
|
|
0c1e50499b | ||
|
|
932ac4807e | ||
|
|
74013c17a2 | ||
|
|
b22a9bccca | ||
|
|
aa7445e1fb | ||
|
|
f608f55c3e | ||
|
|
4d1f3ce09d | ||
|
|
338054d759 | ||
|
|
ecbfe4246b | ||
|
|
46c6a6337e | ||
|
|
07a6a0ecc2 | ||
|
|
fe7276815f | ||
|
|
e23169e918 | ||
|
|
a2cb5c4928 | ||
|
|
b95999200d | ||
|
|
0a55c5a84b | ||
|
|
307a9ea4e2 | ||
|
|
335af2874a | ||
|
|
dcfd4db318 | ||
|
|
600f24248f | ||
|
|
636c17aa89 | ||
|
|
43e62f9baf | ||
|
|
1c184d948d | ||
|
|
2cbd0ed1c4 | ||
|
|
3187425a3f | ||
|
|
8c9c49f9ab | ||
|
|
2b77c5c9f8 | ||
|
|
a0c4290535 | ||
|
|
f2631980e2 | ||
|
|
731d0a3a14 | ||
|
|
524fd8df49 | ||
|
|
ac31028b49 | ||
|
|
58a8d929f7 | ||
|
|
2c9953896a | ||
|
|
2e5748f031 | ||
|
|
d8189a5435 | ||
|
|
b89b8bb5a3 | ||
|
|
3334ed98b4 | ||
|
|
ef2ecf9998 | ||
|
|
73d6a08c07 | ||
|
|
075ffaf311 | ||
|
|
e66aaebc54 | ||
|
|
429f3542f1 | ||
|
|
a988ef67b0 | ||
|
|
df87f24b7f | ||
|
|
58ab38d5f5 | ||
|
|
41afca1959 | ||
|
|
fae8083745 | ||
|
|
9164ebf3d7 | ||
|
|
e525566080 | ||
|
|
4fb1f89bbc | ||
|
|
c7dc9e4b50 | ||
|
|
46349172f6 |
@@ -0,0 +1,14 @@
|
||||
# Docker
|
||||
.git
|
||||
node_modules
|
||||
data
|
||||
config.json
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.bak
|
||||
benchmark*.sh
|
||||
benchmark*.js
|
||||
PERFORMANCE.md
|
||||
docs/
|
||||
.gitignore
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
concurrency:
|
||||
group: deploy
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Validate JS
|
||||
run: sh scripts/validate.sh
|
||||
|
||||
- name: Build and deploy
|
||||
run: |
|
||||
set -e
|
||||
docker build -t meshcore-analyzer .
|
||||
docker stop meshcore-analyzer 2>/dev/null && docker rm meshcore-analyzer 2>/dev/null || true
|
||||
docker run -d \
|
||||
--name meshcore-analyzer \
|
||||
--restart unless-stopped \
|
||||
-p 80:80 -p 443:443 -p 1883:1883 \
|
||||
-v $HOME/meshcore-data:/app/data \
|
||||
-v $HOME/meshcore-config.json:/app/config.json:ro \
|
||||
-v $HOME/caddy-data:/data/caddy \
|
||||
-v $HOME/meshcore-analyzer/Caddyfile:/etc/caddy/Caddyfile \
|
||||
meshcore-analyzer
|
||||
echo "Deployed $(git rev-parse --short HEAD)"
|
||||
@@ -4,3 +4,5 @@ data/
|
||||
*.db
|
||||
*.db-journal
|
||||
config.json
|
||||
data-lincomatic/
|
||||
config-lincomatic.json
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Changelog
|
||||
|
||||
## 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).
|
||||
@@ -0,0 +1,33 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
RUN apk add --no-cache mosquitto mosquitto-clients supervisor caddy
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Node dependencies
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --production
|
||||
|
||||
# Copy application
|
||||
COPY *.js config.example.json ./
|
||||
COPY public/ ./public/
|
||||
|
||||
# Supervisor + Mosquitto + Caddy config
|
||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
COPY docker/mosquitto.conf /etc/mosquitto/mosquitto.conf
|
||||
COPY docker/Caddyfile /etc/caddy/Caddyfile
|
||||
|
||||
# Create data directory for SQLite + Mosquitto persistence + Caddy certs
|
||||
RUN mkdir -p /app/data /var/lib/mosquitto /data/caddy && \
|
||||
chown -R node:node /app/data && \
|
||||
chown -R mosquitto:mosquitto /var/lib/mosquitto
|
||||
|
||||
# Default config: copy example if no config mounted
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 80 443 1883
|
||||
|
||||
VOLUME ["/app/data", "/data/caddy"]
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -0,0 +1,251 @@
|
||||
# Node Analytics Page — Implementation Plan
|
||||
|
||||
## Overview
|
||||
A dedicated per-node analytics page (`#/nodes/:pubkey/analytics`) showing charts, breakdowns, and computed stats. Linked from node sidebar and full-screen detail views.
|
||||
|
||||
## Route & Navigation
|
||||
- **Hash route:** `#/nodes/:pubkey/analytics`
|
||||
- **Entry points:**
|
||||
- Sidebar detail: "📊 Analytics" button next to "📋 Copy URL"
|
||||
- Full-screen detail: same button placement
|
||||
- Direct URL (shareable)
|
||||
- **Back navigation:** "← Back to node" link returns to `#/nodes/:pubkey`
|
||||
|
||||
## API Endpoint
|
||||
|
||||
### `GET /api/nodes/:pubkey/analytics?days=7`
|
||||
|
||||
Returns all data needed for the page in a single request. Server computes aggregations in SQLite for efficiency.
|
||||
|
||||
```json
|
||||
{
|
||||
"node": { "public_key": "...", "name": "...", "role": "..." },
|
||||
"timeRange": { "from": "ISO", "to": "ISO", "days": 7 },
|
||||
"activityTimeline": [
|
||||
{ "bucket": "2026-03-19T10:00:00Z", "count": 5 }
|
||||
],
|
||||
"snrTrend": [
|
||||
{ "timestamp": "ISO", "snr": 11.5, "rssi": -44, "observer_id": "...", "observer_name": "..." }
|
||||
],
|
||||
"packetTypeBreakdown": [
|
||||
{ "payload_type": 4, "label": "Advert", "count": 120 },
|
||||
{ "payload_type": 5, "label": "Channel Msg", "count": 45 }
|
||||
],
|
||||
"observerCoverage": [
|
||||
{ "observer_id": "...", "observer_name": "...", "packetCount": 200, "avgSnr": 8.5, "avgRssi": -60, "firstSeen": "ISO", "lastSeen": "ISO" }
|
||||
],
|
||||
"hopDistribution": [
|
||||
{ "hops": 1, "count": 150 },
|
||||
{ "hops": 2, "count": 30 }
|
||||
],
|
||||
"peerInteractions": [
|
||||
{ "peer_key": "...", "peer_name": "...", "messageCount": 15, "lastContact": "ISO" }
|
||||
],
|
||||
"computedStats": {
|
||||
"availabilityPct": 92.5,
|
||||
"longestSilenceMs": 14400000,
|
||||
"longestSilenceStart": "ISO",
|
||||
"signalGrade": "B+",
|
||||
"snrMean": 8.2,
|
||||
"snrStdDev": 3.1,
|
||||
"relayPct": 22.5,
|
||||
"totalPackets": 450,
|
||||
"uniqueObservers": 3,
|
||||
"uniquePeers": 8,
|
||||
"avgPacketsPerDay": 64.3
|
||||
},
|
||||
"uptimeHeatmap": [
|
||||
{ "dayOfWeek": 0, "hour": 14, "count": 12 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Server Implementation (`server.js`)
|
||||
|
||||
Add route handler at `/api/nodes/:pubkey/analytics`. All queries use the same LIKE-based matching as existing `getNodeHealth()`. Key queries:
|
||||
|
||||
1. **activityTimeline** — `SELECT strftime('%Y-%m-%dT%H:00:00Z', timestamp) as bucket, COUNT(*) as count FROM packets WHERE ... AND timestamp > ? GROUP BY bucket ORDER BY bucket`
|
||||
2. **snrTrend** — `SELECT timestamp, snr, rssi, observer_id, observer_name FROM packets WHERE ... AND snr IS NOT NULL ORDER BY timestamp` (raw points, chart.js handles rendering)
|
||||
3. **packetTypeBreakdown** — `SELECT payload_type, COUNT(*) as count FROM packets WHERE ... GROUP BY payload_type`
|
||||
4. **observerCoverage** — `SELECT observer_id, observer_name, COUNT(*), AVG(snr), AVG(rssi), MIN(timestamp), MAX(timestamp) FROM packets WHERE ... GROUP BY observer_id ORDER BY COUNT(*) DESC`
|
||||
5. **hopDistribution** — Parse `path_json` in JS, count hop lengths
|
||||
6. **peerInteractions** — Parse `decoded_json`, extract sender/recipient pubkeys and names, aggregate
|
||||
7. **uptimeHeatmap** — `SELECT strftime('%w', timestamp) as dow, strftime('%H', timestamp) as hour, COUNT(*) FROM packets WHERE ... GROUP BY dow, hour`
|
||||
8. **computedStats** — Derived from above data:
|
||||
- `availabilityPct`: count distinct hours with packets / total hours in range × 100
|
||||
- `longestSilenceMs`: iterate timestamps, find max gap
|
||||
- `signalGrade`: A (snr>15, stddev<2), B (snr>8), C (snr>3), D (snr<=3)
|
||||
- `relayPct`: packets with hop count > 1 / total with path data × 100
|
||||
|
||||
Add a helper function `getNodeAnalytics(pubkey, days)` in `db.js` to keep it organized.
|
||||
|
||||
## Frontend
|
||||
|
||||
### New File: `public/node-analytics.js`
|
||||
|
||||
IIFE pattern matching existing pages. Registers with the router for `#/nodes/:pubkey/analytics`.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ ← Back to SomeNodeName │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │
|
||||
│ │ Availability│ │ Signal Grade│ │ Packets/Day│ │
|
||||
│ │ 92.5% │ │ B+ │ │ 64.3 │ │
|
||||
│ └─────────────┘ └─────────────┘ └────────────┘ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │
|
||||
│ │ Observers │ │ Relay % │ │ Longest │ │
|
||||
│ │ 3 │ │ 22.5% │ │ Silence 4h │ │
|
||||
│ └─────────────┘ └─────────────┘ └────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ Activity Timeline (bar chart, hourly) │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────┐ ┌────────────────────┐ │
|
||||
│ │ SNR Trend (line) │ │ Packet Types (pie) │ │
|
||||
│ └──────────────────────┘ └────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────┐ ┌────────────────────┐ │
|
||||
│ │ Observer Coverage │ │ Hop Distribution │ │
|
||||
│ │ (horizontal bar) │ │ (bar chart) │ │
|
||||
│ └──────────────────────┘ └────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ Uptime Heatmap (7×24 grid, GitHub-style) │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ Peer Interactions (ranked list) │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Time Range Selector
|
||||
- Buttons: 24h | 7d | 30d | All
|
||||
- Default: 7d
|
||||
- Reloads data via API when changed
|
||||
|
||||
### Chart Library
|
||||
- **Chart.js v4** from CDN (unpkg): `https://unpkg.com/chart.js@4/dist/chart.umd.min.js`
|
||||
- Add `<script>` tag in `index.html` (with cache buster)
|
||||
- Chart.js is ~70KB gzipped, handles all chart types needed
|
||||
|
||||
### Chart Specifications
|
||||
|
||||
1. **Activity Timeline** (bar chart, full width)
|
||||
- X: time buckets (hourly for ≤3d, daily for >3d)
|
||||
- Y: packet count
|
||||
- Color: role color with 50% opacity
|
||||
- Tooltip: exact count + timestamp
|
||||
|
||||
2. **SNR Trend** (line chart, half width)
|
||||
- One line per observer (different colors)
|
||||
- X: timestamp, Y: SNR (dB)
|
||||
- Include a horizontal reference line at 0 dB
|
||||
- Legend shows observer names
|
||||
|
||||
3. **Packet Type Breakdown** (doughnut chart, half width)
|
||||
- Segments: Advert, Channel Msg, DM, ACK, Request, Response, etc.
|
||||
- Colors: match existing PAYLOAD badge colors
|
||||
- Center text: total count
|
||||
|
||||
4. **Observer Coverage** (horizontal bar chart, half width)
|
||||
- Bars: one per observer, length = packet count
|
||||
- Color intensity mapped to avg SNR (brighter = better signal)
|
||||
- Labels: observer name + avg SNR
|
||||
|
||||
5. **Hop Distribution** (bar chart, half width)
|
||||
- X: hop count (1, 2, 3, 4+)
|
||||
- Y: packet count
|
||||
- Simple, clean
|
||||
|
||||
6. **Uptime Heatmap** (custom canvas/div grid, full width)
|
||||
- 7 rows (Sun–Sat) × 24 columns (hours)
|
||||
- Cell color intensity = packet count for that slot
|
||||
- Tooltip: "Monday 14:00 — 12 packets"
|
||||
- Use CSS grid with inline background colors (no chart.js needed)
|
||||
|
||||
7. **Peer Interactions** (table/list, full width)
|
||||
- Ranked by message count
|
||||
- Columns: peer name, messages, last contact
|
||||
- Peer name links to their node detail page
|
||||
|
||||
### Stat Cards
|
||||
- Use CSS grid, 3 columns on desktop, 2 on tablet, 1 on mobile
|
||||
- Each card: label (small, muted), value (large, bold), optional trend arrow
|
||||
- Signal grade uses color coding: A=green, B=blue, C=yellow, D=red
|
||||
|
||||
### CSS (add to `style.css`)
|
||||
```css
|
||||
.analytics-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 24px; }
|
||||
.analytics-stat-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px; text-align: center; }
|
||||
.analytics-stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-muted); margin-bottom: 4px; }
|
||||
.analytics-stat-value { font-size: 28px; font-weight: 700; }
|
||||
.analytics-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
|
||||
.analytics-chart-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
|
||||
.analytics-chart-card.full { grid-column: 1 / -1; }
|
||||
.analytics-chart-card h4 { font-size: 12px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-muted); margin-bottom: 12px; }
|
||||
.analytics-heatmap { display: grid; grid-template-columns: 40px repeat(24, 1fr); gap: 2px; }
|
||||
.analytics-heatmap-cell { aspect-ratio: 1; border-radius: 2px; }
|
||||
.analytics-heatmap-label { font-size: 10px; color: var(--text-muted); display: flex; align-items: center; }
|
||||
.analytics-time-range { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
.analytics-time-range button { padding: 4px 12px; border-radius: 4px; border: 1px solid var(--border); background: var(--card-bg); color: var(--text); cursor: pointer; font-size: 12px; }
|
||||
.analytics-time-range button.active { background: var(--accent); color: white; border-color: var(--accent); }
|
||||
@media (max-width: 768px) { .analytics-stats { grid-template-columns: repeat(2, 1fr); } .analytics-charts { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 480px) { .analytics-stats { grid-template-columns: 1fr; } }
|
||||
```
|
||||
|
||||
### Dark Mode
|
||||
All colors use CSS variables. Chart.js text/grid colors should reference `--text-muted` and `--border`. Set via:
|
||||
```js
|
||||
Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--text-muted').trim();
|
||||
Chart.defaults.borderColor = getComputedStyle(document.documentElement).getPropertyValue('--border').trim();
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. **`db.js`** — Add `getNodeAnalytics(pubkey, days)` function
|
||||
2. **`server.js`** — Add `GET /api/nodes/:pubkey/analytics` route
|
||||
3. **`public/node-analytics.js`** — New file, full page implementation
|
||||
4. **`public/style.css`** — Add analytics CSS classes
|
||||
5. **`public/index.html`** — Add Chart.js CDN script + `node-analytics.js` script tag (with cache buster)
|
||||
6. **`public/app.js`** — Add route for `#/nodes/:pubkey/analytics` in the router
|
||||
7. **`public/nodes.js`** — Add "📊 Analytics" button to sidebar and full-screen detail views
|
||||
|
||||
## Constraints — DO NOT TOUCH
|
||||
|
||||
These files/behaviors have been manually tuned. Do not modify unless explicitly part of the plan:
|
||||
|
||||
1. **`public/map.js`** — Map markers, disambiguation logic, route drawing. OFF LIMITS.
|
||||
2. **`public/packets.js`** — Panel resize, VCR replay logic. OFF LIMITS.
|
||||
3. **`public/app.js` `makeColumnsResizable()`** (line ~463) — Column resize steals proportionally from all right columns with 50px minimum. Do not change.
|
||||
4. **Existing node detail rendering in `nodes.js`** — Only ADD the analytics button. Do not reorganize, rename, or restructure existing sections.
|
||||
5. **Cache busters** — When modifying `index.html`, bump cache busters on ALL changed files using `?v=TIMESTAMP`.
|
||||
6. **`escapeHtml` and `timeAgo`** — Globals defined in `app.js`. Do not redefine them anywhere.
|
||||
7. **Router in `app.js`** — Follow existing pattern exactly when adding the analytics route.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Add CSS to `style.css`
|
||||
2. Add Chart.js to `index.html`
|
||||
3. Add `getNodeAnalytics()` to `db.js`
|
||||
4. Add API route to `server.js`
|
||||
5. Create `node-analytics.js`
|
||||
6. Register route in `app.js`
|
||||
7. Add analytics button to `nodes.js` (sidebar + full-screen)
|
||||
8. Add `node-analytics.js` script tag to `index.html` with cache buster
|
||||
9. Bump all modified file cache busters
|
||||
10. Test: `node -c` on all JS files, verify no syntax errors
|
||||
|
||||
## Testing
|
||||
|
||||
After implementation:
|
||||
- Navigate to any node → click Analytics → page loads with charts
|
||||
- Switch time ranges → data reloads
|
||||
- Dark mode → charts readable
|
||||
- Mobile → responsive layout
|
||||
- Direct URL → page loads correctly
|
||||
- Back button → returns to node detail
|
||||
@@ -0,0 +1,67 @@
|
||||
# Performance — v2.1.0
|
||||
|
||||
**Dataset:** 28,014 packets, ~650 nodes, 2 observers
|
||||
**Hardware:** ARM64 (MikroTik CCR2116), single-core Node.js
|
||||
|
||||
## A/B Benchmark: v2.0.1 (before) vs v2.1.0 (after)
|
||||
|
||||
All times are averages over 3 runs. "Cached" = warm TTL cache hit.
|
||||
|
||||
| Endpoint | v2.0.1 | v2.1.0 (cold) | v2.1.0 (cached) | Speedup |
|
||||
|---|---|---|---|---|
|
||||
| **Bulk Health** | 7,059 ms | 3 ms | 1 ms | **7,059×** |
|
||||
| **Node Analytics** | 381 ms | 2 ms | 1 ms | **381×** |
|
||||
| **Hash Sizes** | 353 ms | 193 ms | 1 ms | **353×** |
|
||||
| **Topology** | 685 ms | 579 ms | 2 ms | **342×** |
|
||||
| **RF Analytics** | 253 ms | 235 ms | 1 ms | **253×** |
|
||||
| **Channels** | 206 ms | 77 ms | 1 ms | **206×** |
|
||||
| **Node Health** | 195 ms | 1 ms | 1 ms | **195×** |
|
||||
| **Node Detail** | 133 ms | 1 ms | 1 ms | **133×** |
|
||||
| **Channel Analytics** | 95 ms | 73 ms | 2 ms | **47×** |
|
||||
| **Packets (grouped)** | 76 ms | 33 ms | 28 ms | **2×** |
|
||||
| **Stats** | 2 ms | 1 ms | 1 ms | 2× |
|
||||
| **Nodes List** | 3 ms | 2 ms | 2 ms | 1× |
|
||||
| **Observers** | 1 ms | 8 ms | 1 ms | 1× |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Two-Layer Performance Stack
|
||||
|
||||
1. **In-Memory Packet Store** (`packet-store.js`)
|
||||
- All packets loaded from SQLite into RAM on startup (~28K packets = ~12MB)
|
||||
- Indexed by `id`, `hash`, `observer`, and `node` (Map-based O(1) lookup)
|
||||
- Ring buffer with configurable max memory (default 1GB, ~2.3M packets)
|
||||
- SQLite becomes **write-only** for packets — reads never touch disk
|
||||
- New packets from MQTT written to both RAM + SQLite
|
||||
|
||||
2. **TTL Cache** (`server.js`)
|
||||
- Computed API responses cached with configurable TTLs (via `config.json`)
|
||||
- Smart invalidation: packet bursts only invalidate channels/observers; analytics expire by TTL only
|
||||
- Pre-warmed on startup: subpaths, RF, topology, channels, hash-sizes, bulk-health
|
||||
- Result: most API responses served in **1-2ms** from cache
|
||||
|
||||
### Key Optimizations
|
||||
|
||||
- **Eliminated all `LIKE '%pubkey%'` queries**: Every node-specific endpoint was doing full-table scans on the packets table via `decoded_json LIKE '%pubkey%'`. Replaced with O(1) `pktStore.byNode` Map lookups.
|
||||
- **Single-pass computations**: Channels, analytics, and subpaths computed in one loop instead of multiple SQL queries.
|
||||
- **Client-side WebSocket prepend**: New packets appended to the table without re-fetching the API.
|
||||
- **RF response compression**: Server-side histograms + scatter downsampling (1MB → 15KB).
|
||||
- **Configurable everything**: All TTLs, packet store limits, and thresholds in `config.json`.
|
||||
|
||||
### What Didn't Work
|
||||
|
||||
- **Background refresh (`setInterval`)**: Attempted to re-warm caches at 80% TTL. Blocked the event loop — Node.js is single-threaded. Response times went from 3ms to 1,200ms. Reverted immediately.
|
||||
- **Worker threads**: `structuredClone` overhead of 416ms for 28K packets negated the compute savings. Only viable at 10× data growth or with `SharedArrayBuffer` (zero-copy).
|
||||
|
||||
## Running the Benchmark
|
||||
|
||||
```bash
|
||||
# Stop the production server first
|
||||
supervisorctl stop meshcore-analyzer
|
||||
|
||||
# Run A/B benchmark (launches two servers: old v2.0.1 vs current)
|
||||
./benchmark-ab.sh
|
||||
|
||||
# Restart production
|
||||
supervisorctl start meshcore-analyzer
|
||||
```
|
||||
@@ -1,23 +1,134 @@
|
||||
# MeshCore Analyzer
|
||||
|
||||
Self-hosted, open-source MeshCore packet analyzer — a community alternative to the closed-source `analyzer.letsmesh.net`.
|
||||
> Self-hosted, open-source MeshCore packet analyzer — a community alternative to the closed-source `analyzer.letsmesh.net`.
|
||||
|
||||
Collects MeshCore packets via MQTT, decodes them, and presents a full web UI with live packet feed, node map, channel chat, packet tracing, and more.
|
||||
Collects MeshCore packets via MQTT, decodes them, and presents a full web UI with live packet feed, node map, channel chat, packet tracing, per-node analytics, and more.
|
||||
|
||||
## Features
|
||||
## ✨ Features
|
||||
|
||||
- **Live Packet Feed** — real-time WebSocket updates, filterable by type/region/observer
|
||||
- **Interactive Map** — Leaflet map with node markers by role, clustering, last-heard filters
|
||||
- **Channel Chat** — decoded group messages with sender names, @mentions, timestamps
|
||||
- **Node Directory** — searchable node list with role tabs, detail panel, advert timeline
|
||||
- **Packet Tracing** — follow packets across observers with SNR/RSSI timeline
|
||||
### 📡 Live Trace Map
|
||||
Real-time animated map with packet route visualization, VCR-style playback controls, and a retro LCD clock. Replay the last 24 hours of mesh activity, scrub through the timeline, or watch packets flow live at up to 4× speed.
|
||||
|
||||

|
||||
|
||||
### 📦 Packet Feed
|
||||
Filterable real-time packet stream with byte-level breakdown, Excel-like resizable columns, and a detail pane. Toggle "My Nodes" to focus on your mesh.
|
||||
|
||||

|
||||
|
||||
### 🗺️ Network Overview
|
||||
At-a-glance mesh stats — node counts, packet volume, observer coverage.
|
||||
|
||||

|
||||
|
||||
### 🔀 Route Patterns
|
||||
Visualize how packets traverse the mesh — see which repeaters carry the most traffic and identify routing patterns.
|
||||
|
||||

|
||||
|
||||
### 📊 Node Analytics
|
||||
Per-node deep dive with 6 interactive charts: activity timeline, packet type breakdown, SNR distribution, hop count analysis, peer network graph, and hourly heatmap.
|
||||
|
||||

|
||||
|
||||
### 💬 Channel Chat
|
||||
Decoded group messages with sender names, @mentions, timestamps — like reading a Discord channel for your mesh.
|
||||
|
||||

|
||||
|
||||
### 📱 Mobile Ready
|
||||
Full experience on your phone — proper touch controls, iOS safe area support, and a compact VCR bar that doesn't fight your thumb.
|
||||
|
||||
<img src="docs/screenshots/Live-view-iOS.png" alt="Live view on iOS" width="300">
|
||||
|
||||
### And More
|
||||
|
||||
- **Node Directory** — searchable list with role tabs, detail panel, QR codes, advert timeline, "Heard By" observer table
|
||||
- **Packet Tracing** — follow individual packets across observers with SNR/RSSI timeline
|
||||
- **Observer Status** — health monitoring, packet counts, uptime
|
||||
- **Dark Mode** — toggle with sun/moon icon, persisted in localStorage
|
||||
- **Hash Collision Matrix** — detect address collisions across the mesh
|
||||
- **Claimed Nodes** — star your nodes, always sorted to top, visual distinction
|
||||
- **Dark / Light Mode** — auto-detects system preference, instant toggle, map tiles swap too
|
||||
- **Multi-Broker MQTT** — connect to multiple MQTT brokers simultaneously with per-source IATA filtering
|
||||
- **Observer Detail Pages** — click any observer for analytics, charts, status, radio info, recent packets
|
||||
- **Channel Key Auto-Derivation** — hashtag channels (`#channel`) keys derived automatically via SHA256
|
||||
- **Global Search** — search packets, nodes, and channels (Ctrl+K)
|
||||
- **Shareable URLs** — deep links to individual packets, channels, and observer detail pages
|
||||
- **Mobile Responsive** — proper two-row VCR bar, iOS safe area support, touch-friendly
|
||||
- **Accessible** — ARIA patterns, keyboard navigation, screen reader support, distinct marker shapes
|
||||
|
||||
### ⚡ Performance (v2.1.1)
|
||||
|
||||
Two-layer caching architecture: in-memory packet store + TTL response cache. All packet reads served from RAM — SQLite is write-only. Heavy endpoints pre-warmed on startup.
|
||||
|
||||
| Endpoint | Before | After | Speedup |
|
||||
|---|---|---|---|
|
||||
| Bulk Health | 7,059 ms | 1 ms | **7,059×** |
|
||||
| Node Analytics | 381 ms | 1 ms | **381×** |
|
||||
| Topology | 685 ms | 2 ms | **342×** |
|
||||
| Node Health | 195 ms | 1 ms | **195×** |
|
||||
| Node Detail | 133 ms | 1 ms | **133×** |
|
||||
|
||||
See [PERFORMANCE.md](PERFORMANCE.md) for the full benchmark.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
### Docker (Recommended)
|
||||
|
||||
The easiest way to run MeshCore Analyzer. Includes Mosquitto MQTT broker — everything in one container.
|
||||
|
||||
```bash
|
||||
docker build -t meshcore-analyzer .
|
||||
docker run -d \
|
||||
--name meshcore-analyzer \
|
||||
-p 80:80 \
|
||||
-p 443:443 \
|
||||
-p 1883:1883 \
|
||||
-v meshcore-data:/app/data \
|
||||
-v caddy-certs:/data/caddy \
|
||||
meshcore-analyzer
|
||||
```
|
||||
|
||||
Open `http://localhost`. Point your MeshCore gateway's MQTT to `<host-ip>:1883`.
|
||||
|
||||
**With a domain (automatic HTTPS):**
|
||||
```bash
|
||||
# Create a Caddyfile with your domain
|
||||
echo 'analyzer.example.com { reverse_proxy localhost:3000 }' > Caddyfile
|
||||
|
||||
docker run -d \
|
||||
--name meshcore-analyzer \
|
||||
-p 80:80 \
|
||||
-p 443:443 \
|
||||
-p 1883:1883 \
|
||||
-v meshcore-data:/app/data \
|
||||
-v caddy-certs:/data/caddy \
|
||||
-v $(pwd)/Caddyfile:/etc/caddy/Caddyfile \
|
||||
meshcore-analyzer
|
||||
```
|
||||
|
||||
Caddy automatically provisions Let's Encrypt TLS certificates.
|
||||
|
||||
**Custom config:**
|
||||
```bash
|
||||
# Copy and edit the example config
|
||||
cp config.example.json config.json
|
||||
# Edit config.json with your channel keys, regions, etc.
|
||||
|
||||
docker run -d \
|
||||
--name meshcore-analyzer \
|
||||
-p 3000:3000 \
|
||||
-p 1883:1883 \
|
||||
-v meshcore-data:/app/data \
|
||||
-v $(pwd)/config.json:/app/config.json \
|
||||
meshcore-analyzer
|
||||
```
|
||||
|
||||
**Persist your database** across container rebuilds by using a named volume (`meshcore-data`) or bind mount (`-v ./data:/app/data`).
|
||||
|
||||
### Manual Install
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- **Node.js** 18+ (tested with 22.x)
|
||||
- **MQTT broker** (Mosquitto recommended) — optional, can inject packets via API
|
||||
@@ -25,7 +136,7 @@ Collects MeshCore packets via MQTT, decodes them, and presents a full web UI wit
|
||||
### Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/youruser/meshcore-analyzer.git
|
||||
git clone https://github.com/Kpa-clawbot/meshcore-analyzer.git
|
||||
cd meshcore-analyzer
|
||||
npm install
|
||||
```
|
||||
@@ -41,6 +152,17 @@ Edit `config.json`:
|
||||
"broker": "mqtt://localhost:1883",
|
||||
"topic": "meshcore/+/+/packets"
|
||||
},
|
||||
"mqttSources": [
|
||||
{
|
||||
"name": "remote-feed",
|
||||
"broker": "mqtts://remote-broker:8883",
|
||||
"topics": ["meshcore/+/+/packets", "meshcore/+/+/status"],
|
||||
"username": "user",
|
||||
"password": "pass",
|
||||
"rejectUnauthorized": false,
|
||||
"iataFilter": ["SJC", "SFO", "OAK"]
|
||||
}
|
||||
],
|
||||
"channelKeys": {
|
||||
"public": "8b3387e9c5cdea6ac9e5edbaa115cd72"
|
||||
},
|
||||
@@ -56,9 +178,16 @@ Edit `config.json`:
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `port` | HTTP server port (default: 3000) |
|
||||
| `mqtt.broker` | MQTT broker URL. Set to `""` to disable MQTT and use API-only mode |
|
||||
| `mqtt.broker` | Local MQTT broker URL. Set to `""` to disable |
|
||||
| `mqtt.topic` | MQTT topic pattern for packet ingestion |
|
||||
| `channelKeys` | Named channel decryption keys (hex). `public` is the default MeshCore public channel |
|
||||
| `mqttSources` | Array of external MQTT broker connections (optional) |
|
||||
| `mqttSources[].name` | Friendly name for logging |
|
||||
| `mqttSources[].broker` | Broker URL (`mqtt://` or `mqtts://` for TLS) |
|
||||
| `mqttSources[].topics` | Array of MQTT topic patterns to subscribe to |
|
||||
| `mqttSources[].username` / `password` | Broker credentials |
|
||||
| `mqttSources[].rejectUnauthorized` | Set `false` for self-signed TLS certs |
|
||||
| `mqttSources[].iataFilter` | Only accept packets from these IATA regions |
|
||||
| `channelKeys` | Named channel decryption keys (hex). Hashtag channels auto-derived via SHA256 |
|
||||
| `defaultRegion` | Default IATA region code for the UI |
|
||||
| `regions` | Map of IATA codes to human-readable region names |
|
||||
|
||||
@@ -79,8 +208,6 @@ Open `http://localhost:3000` in your browser.
|
||||
|
||||
### Generate Test Data
|
||||
|
||||
To populate the analyzer with synthetic packets for testing/demo:
|
||||
|
||||
```bash
|
||||
# Generate and inject 200 packets via API
|
||||
node tools/generate-packets.js --api --count 200
|
||||
@@ -92,10 +219,10 @@ node tools/generate-packets.js --json --count 50
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
# End-to-end test (starts server, injects packets, validates all APIs)
|
||||
# End-to-end test
|
||||
DB_PATH=/tmp/test-e2e.db PORT=13590 node tools/e2e-test.js
|
||||
|
||||
# Frontend smoke test (validates pages load and render correctly)
|
||||
# Frontend smoke test
|
||||
DB_PATH=/tmp/test-fe.db PORT=13591 node tools/frontend-test.js
|
||||
```
|
||||
|
||||
@@ -122,22 +249,37 @@ Observer Node → USB → meshcoretomqtt → MQTT Broker → Analyzer Server →
|
||||
|
||||
```
|
||||
meshcore-analyzer/
|
||||
├── config.json # MQTT, channel keys, regions
|
||||
├── Dockerfile # Single-container build (Node + Mosquitto + Caddy)
|
||||
├── .dockerignore
|
||||
├── config.example.json # Example config (copy to config.json)
|
||||
├── config.json # MQTT, channel keys, regions (gitignored)
|
||||
├── server.js # Express + WebSocket + MQTT + REST API
|
||||
├── decoder.js # Custom MeshCore packet decoder
|
||||
├── db.js # SQLite schema + queries
|
||||
├── packet-store.js # In-memory packet store (ring buffer, indexed)
|
||||
├── docker/
|
||||
│ ├── supervisord.conf # Process manager config
|
||||
│ ├── mosquitto.conf # MQTT broker config
|
||||
│ ├── Caddyfile # Default Caddy config (localhost)
|
||||
│ └── entrypoint.sh # Container entrypoint
|
||||
├── data/
|
||||
│ └── meshcore.db # Packet database (auto-created)
|
||||
├── public/
|
||||
│ ├── index.html # SPA shell
|
||||
│ ├── style.css # Theme (light/dark)
|
||||
│ ├── app.js # Router, WebSocket, utilities
|
||||
│ ├── packets.js # Packet feed + byte breakdown
|
||||
│ ├── map.js # Leaflet map
|
||||
│ ├── packets.js # Packet feed + byte breakdown + detail page
|
||||
│ ├── map.js # Leaflet map with route visualization
|
||||
│ ├── live.js # Live trace page with VCR playback
|
||||
│ ├── channels.js # Channel chat
|
||||
│ ├── nodes.js # Node directory
|
||||
│ ├── nodes.js # Node directory + detail views
|
||||
│ ├── analytics.js # Global analytics dashboard
|
||||
│ ├── node-analytics.js # Per-node analytics with charts
|
||||
│ ├── traces.js # Packet tracing
|
||||
│ └── observers.js # Observer status
|
||||
│ ├── observers.js # Observer status
|
||||
│ ├── observer-detail.js # Observer detail with analytics
|
||||
│ ├── home.js # Dashboard home page
|
||||
│ └── perf.js # Performance monitoring dashboard
|
||||
└── tools/
|
||||
├── generate-packets.js # Synthetic packet generator
|
||||
├── e2e-test.js # End-to-end API tests
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/bin/bash
|
||||
# A/B benchmark: old (pre-perf) vs new (current)
|
||||
# Usage: ./benchmark-ab.sh
|
||||
set -e
|
||||
|
||||
PORT_OLD=13003
|
||||
PORT_NEW=13004
|
||||
RUNS=3
|
||||
DB_PATH="$(pwd)/data/meshcore.db"
|
||||
|
||||
OLD_COMMIT="23caae4"
|
||||
NEW_COMMIT="$(git rev-parse HEAD)"
|
||||
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo " A/B Benchmark: Pre-optimization vs Current"
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo "OLD: $OLD_COMMIT (v2.0.1 — before any perf work)"
|
||||
echo "NEW: $NEW_COMMIT (current)"
|
||||
echo "Runs per endpoint: $RUNS"
|
||||
echo ""
|
||||
|
||||
# Get a real node pubkey for testing
|
||||
ORIG_DIR="$(pwd)"
|
||||
PUBKEY=$(sqlite3 "$DB_PATH" "SELECT public_key FROM nodes ORDER BY last_seen DESC LIMIT 1")
|
||||
echo "Test node: ${PUBKEY:0:16}..."
|
||||
echo ""
|
||||
|
||||
# Setup old version in temp dir
|
||||
OLD_DIR=$(mktemp -d)
|
||||
echo "Cloning old version to $OLD_DIR..."
|
||||
git worktree add "$OLD_DIR" "$OLD_COMMIT" --quiet 2>/dev/null || {
|
||||
git worktree add "$OLD_DIR" "$OLD_COMMIT" --detach --quiet
|
||||
}
|
||||
# Copy config + db symlink
|
||||
# Copy config + db + share node_modules
|
||||
cp config.json "$OLD_DIR/"
|
||||
mkdir -p "$OLD_DIR/data"
|
||||
cp "$ORIG_DIR/data/meshcore.db" "$OLD_DIR/data/meshcore.db"
|
||||
ln -sf "$ORIG_DIR/node_modules" "$OLD_DIR/node_modules"
|
||||
|
||||
ENDPOINTS=(
|
||||
"Stats|/api/stats"
|
||||
"Packets(50)|/api/packets?limit=50"
|
||||
"PacketsGrouped|/api/packets?limit=50&groupByHash=true"
|
||||
"NodesList|/api/nodes?limit=50"
|
||||
"NodeDetail|/api/nodes/$PUBKEY"
|
||||
"NodeHealth|/api/nodes/$PUBKEY/health"
|
||||
"NodeAnalytics|/api/nodes/$PUBKEY/analytics?days=7"
|
||||
"BulkHealth|/api/nodes/bulk-health?limit=50"
|
||||
"NetworkStatus|/api/nodes/network-status"
|
||||
"Channels|/api/channels"
|
||||
"Observers|/api/observers"
|
||||
"RF|/api/analytics/rf"
|
||||
"Topology|/api/analytics/topology"
|
||||
"ChannelAnalytics|/api/analytics/channels"
|
||||
"HashSizes|/api/analytics/hash-sizes"
|
||||
)
|
||||
|
||||
bench_endpoint() {
|
||||
local port=$1 path=$2 runs=$3 nocache=$4
|
||||
local total=0
|
||||
for i in $(seq 1 $runs); do
|
||||
local url="http://127.0.0.1:$port$path"
|
||||
if [ "$nocache" = "1" ]; then
|
||||
if echo "$path" | grep -q '?'; then
|
||||
url="${url}&nocache=1"
|
||||
else
|
||||
url="${url}?nocache=1"
|
||||
fi
|
||||
fi
|
||||
local ms=$(curl -s -o /dev/null -w "%{time_total}" "$url" 2>/dev/null)
|
||||
local ms_int=$(echo "$ms * 1000" | bc | cut -d. -f1)
|
||||
total=$((total + ms_int))
|
||||
done
|
||||
echo $((total / runs))
|
||||
}
|
||||
|
||||
# Launch old server
|
||||
echo "Starting OLD server (port $PORT_OLD)..."
|
||||
cd "$OLD_DIR"
|
||||
PORT=$PORT_OLD node server.js &>/dev/null &
|
||||
OLD_PID=$!
|
||||
cd - >/dev/null
|
||||
|
||||
# Launch new server
|
||||
echo "Starting NEW server (port $PORT_NEW)..."
|
||||
PORT=$PORT_NEW node server.js &>/dev/null &
|
||||
NEW_PID=$!
|
||||
|
||||
# Wait for both
|
||||
sleep 12 # old server has no memory store; new needs prewarm
|
||||
|
||||
# Verify
|
||||
curl -s "http://127.0.0.1:$PORT_OLD/api/stats" >/dev/null 2>&1 || { echo "OLD server failed to start"; kill $OLD_PID $NEW_PID 2>/dev/null; exit 1; }
|
||||
curl -s "http://127.0.0.1:$PORT_NEW/api/stats" >/dev/null 2>&1 || { echo "NEW server failed to start"; kill $OLD_PID $NEW_PID 2>/dev/null; exit 1; }
|
||||
|
||||
echo ""
|
||||
echo "Warming up caches on new server..."
|
||||
for ep in "${ENDPOINTS[@]}"; do
|
||||
path="${ep#*|}"
|
||||
curl -s -o /dev/null "http://127.0.0.1:$PORT_NEW$path" 2>/dev/null
|
||||
done
|
||||
sleep 2
|
||||
|
||||
printf "\n%-22s %9s %9s %9s %9s\n" "Endpoint" "Old(ms)" "New-cold" "New-cache" "Speedup"
|
||||
printf "%-22s %9s %9s %9s %9s\n" "──────────────────────" "─────────" "─────────" "─────────" "─────────"
|
||||
|
||||
for ep in "${ENDPOINTS[@]}"; do
|
||||
name="${ep%%|*}"
|
||||
path="${ep#*|}"
|
||||
|
||||
old_ms=$(bench_endpoint $PORT_OLD "$path" $RUNS 0)
|
||||
new_cold=$(bench_endpoint $PORT_NEW "$path" $RUNS 1)
|
||||
new_cached=$(bench_endpoint $PORT_NEW "$path" $RUNS 0)
|
||||
|
||||
if [ "$old_ms" -gt 0 ] && [ "$new_cached" -gt 0 ]; then
|
||||
speedup="${old_ms}/${new_cached}"
|
||||
speedup_x=$(echo "scale=0; $old_ms / $new_cached" | bc 2>/dev/null || echo "?")
|
||||
printf "%-22s %7dms %7dms %7dms %7d×\n" "$name" "$old_ms" "$new_cold" "$new_cached" "$speedup_x"
|
||||
else
|
||||
printf "%-22s %7dms %7dms %7dms %9s\n" "$name" "$old_ms" "$new_cold" "$new_cached" "∞"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
|
||||
# Cleanup
|
||||
kill $OLD_PID $NEW_PID 2>/dev/null
|
||||
git worktree remove "$OLD_DIR" --force 2>/dev/null
|
||||
echo "Done."
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Benchmark suite for meshcore-analyzer.
|
||||
* Launches two server instances — one with in-memory store, one with pure SQLite —
|
||||
* and compares performance side by side.
|
||||
*
|
||||
* Usage: node benchmark.js [--runs 5] [--json]
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const RUNS = Number(args.find((a, i) => args[i - 1] === '--runs') || 5);
|
||||
const JSON_OUT = args.includes('--json');
|
||||
|
||||
const PORT_MEM = 13001; // In-memory store
|
||||
const PORT_SQL = 13002; // SQLite-only
|
||||
|
||||
const ENDPOINTS = [
|
||||
{ name: 'Stats', path: '/api/stats' },
|
||||
{ name: 'Packets (50)', path: '/api/packets?limit=50' },
|
||||
{ name: 'Packets (100)', path: '/api/packets?limit=100' },
|
||||
{ name: 'Packets grouped', path: '/api/packets?limit=100&groupByHash=true' },
|
||||
{ name: 'Packets filtered', path: '/api/packets?limit=50&type=5' },
|
||||
{ name: 'Packets timestamps', path: '/api/packets/timestamps?since=2020-01-01' },
|
||||
{ name: 'Nodes list', path: '/api/nodes?limit=50' },
|
||||
{ name: 'Node detail', path: '/api/nodes/__FIRST_NODE__' },
|
||||
{ name: 'Node health', path: '/api/nodes/__FIRST_NODE__/health' },
|
||||
{ name: 'Bulk health', path: '/api/nodes/bulk-health?limit=50' },
|
||||
{ name: 'Network status', path: '/api/nodes/network-status' },
|
||||
{ name: 'Observers', path: '/api/observers' },
|
||||
{ name: 'Channels', path: '/api/channels' },
|
||||
{ name: 'RF Analytics', path: '/api/analytics/rf' },
|
||||
{ name: 'Topology', path: '/api/analytics/topology' },
|
||||
{ name: 'Channel Analytics', path: '/api/analytics/channels' },
|
||||
{ name: 'Hash Sizes', path: '/api/analytics/hash-sizes' },
|
||||
{ name: 'Subpaths 2-hop', path: '/api/analytics/subpaths?minLen=2&maxLen=2&limit=50' },
|
||||
{ name: 'Subpaths 3-hop', path: '/api/analytics/subpaths?minLen=3&maxLen=3&limit=30' },
|
||||
{ name: 'Subpaths 4-hop', path: '/api/analytics/subpaths?minLen=4&maxLen=4&limit=20' },
|
||||
{ name: 'Subpaths 5-8 hop', path: '/api/analytics/subpaths?minLen=5&maxLen=8&limit=15' },
|
||||
];
|
||||
|
||||
function fetch(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t0 = process.hrtime.bigint();
|
||||
const req = http.get(url, (res) => {
|
||||
let body = '';
|
||||
res.on('data', c => body += c);
|
||||
res.on('end', () => {
|
||||
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
||||
resolve({ ms, bytes: Buffer.byteLength(body), status: res.statusCode, body });
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(60000, () => { req.destroy(); reject(new Error('timeout')); });
|
||||
});
|
||||
}
|
||||
|
||||
function median(arr) { const s = [...arr].sort((a,b)=>a-b); return s[Math.floor(s.length/2)]; }
|
||||
function p95(arr) { const s = [...arr].sort((a,b)=>a-b); return s[Math.floor(s.length*0.95)]; }
|
||||
function avg(arr) { return arr.reduce((a,b)=>a+b,0)/arr.length; }
|
||||
function fmt(ms) { return ms >= 1000 ? (ms/1000).toFixed(1)+'s' : ms.toFixed(1)+'ms'; }
|
||||
function fmtSize(b) { return b >= 1048576 ? (b/1048576).toFixed(1)+'MB' : b >= 1024 ? (b/1024).toFixed(0)+'KB' : b+'B'; }
|
||||
|
||||
function launchServer(port, env = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('node', ['server.js'], {
|
||||
cwd: __dirname,
|
||||
env: { ...process.env, PORT: String(port), ...env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let started = false;
|
||||
const timeout = setTimeout(() => { if (!started) { child.kill(); reject(new Error('Server start timeout')); } }, 30000);
|
||||
|
||||
child.stdout.on('data', (d) => {
|
||||
if (!started && (d.toString().includes('listening') || d.toString().includes('running'))) {
|
||||
started = true; clearTimeout(timeout); resolve(child);
|
||||
}
|
||||
});
|
||||
child.stderr.on('data', (d) => {
|
||||
if (!started && (d.toString().includes('listening') || d.toString().includes('running'))) {
|
||||
started = true; clearTimeout(timeout); resolve(child);
|
||||
}
|
||||
});
|
||||
child.on('exit', (code) => { if (!started) { clearTimeout(timeout); reject(new Error(`Server exited with ${code}`)); } });
|
||||
|
||||
// Fallback: wait longer (SQLite-only mode pre-warms subpaths ~6s)
|
||||
setTimeout(() => {
|
||||
if (!started) {
|
||||
started = true; clearTimeout(timeout);
|
||||
resolve(child);
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer(port, maxMs = 20000) {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < maxMs) {
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${port}/api/stats`);
|
||||
if (r.status === 200) return true;
|
||||
} catch {}
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`Server on port ${port} didn't start`);
|
||||
}
|
||||
|
||||
async function benchmarkEndpoints(port, endpoints, nocache = false) {
|
||||
const results = [];
|
||||
for (const ep of endpoints) {
|
||||
const suffix = nocache ? (ep.path.includes('?') ? '&nocache=1' : '?nocache=1') : '';
|
||||
const url = `http://127.0.0.1:${port}${ep.path}${suffix}`;
|
||||
|
||||
// Warm-up
|
||||
try { await fetch(url); } catch {}
|
||||
|
||||
const times = [];
|
||||
let bytes = 0;
|
||||
let failed = false;
|
||||
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
if (r.status !== 200) { failed = true; break; }
|
||||
times.push(r.ms);
|
||||
bytes = r.bytes;
|
||||
} catch { failed = true; break; }
|
||||
}
|
||||
|
||||
if (failed || !times.length) {
|
||||
results.push({ name: ep.name, failed: true });
|
||||
} else {
|
||||
results.push({
|
||||
name: ep.name,
|
||||
avg: Math.round(avg(times) * 10) / 10,
|
||||
p50: Math.round(median(times) * 10) / 10,
|
||||
p95: Math.round(p95(times) * 10) / 10,
|
||||
bytes
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log(`\nMeshCore Analyzer Benchmark — ${RUNS} runs per endpoint`);
|
||||
console.log('Launching servers...\n');
|
||||
|
||||
// Launch both servers
|
||||
let memServer, sqlServer;
|
||||
try {
|
||||
console.log(' Starting in-memory server (port ' + PORT_MEM + ')...');
|
||||
memServer = await launchServer(PORT_MEM, {});
|
||||
await waitForServer(PORT_MEM);
|
||||
console.log(' ✅ In-memory server ready');
|
||||
|
||||
console.log(' Starting SQLite-only server (port ' + PORT_SQL + ')...');
|
||||
sqlServer = await launchServer(PORT_SQL, { NO_MEMORY_STORE: '1' });
|
||||
await waitForServer(PORT_SQL);
|
||||
console.log(' ✅ SQLite-only server ready\n');
|
||||
} catch (e) {
|
||||
console.error('Failed to start servers:', e.message);
|
||||
if (memServer) memServer.kill();
|
||||
if (sqlServer) sqlServer.kill();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get first node pubkey
|
||||
let firstNode = '';
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${PORT_MEM}/api/nodes?limit=1`);
|
||||
const data = JSON.parse(r.body);
|
||||
firstNode = data.nodes?.[0]?.public_key || '';
|
||||
} catch {}
|
||||
|
||||
const endpoints = ENDPOINTS.map(e => ({
|
||||
...e,
|
||||
path: e.path.replace('__FIRST_NODE__', firstNode),
|
||||
}));
|
||||
|
||||
// Get packet count
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${PORT_MEM}/api/stats`);
|
||||
const stats = JSON.parse(r.body);
|
||||
console.log(`Dataset: ${(stats.totalPackets || '?').toLocaleString()} packets\n`);
|
||||
} catch {}
|
||||
|
||||
// Run benchmarks
|
||||
console.log('Benchmarking in-memory store (nocache for true compute cost)...');
|
||||
const memResults = await benchmarkEndpoints(PORT_MEM, endpoints, true);
|
||||
|
||||
console.log('Benchmarking SQLite-only (nocache)...');
|
||||
const sqlResults = await benchmarkEndpoints(PORT_SQL, endpoints, true);
|
||||
|
||||
// Also test cached in-memory for the full picture
|
||||
console.log('Benchmarking in-memory store (cached)...');
|
||||
const memCachedResults = await benchmarkEndpoints(PORT_MEM, endpoints, false);
|
||||
|
||||
// Kill servers
|
||||
memServer.kill();
|
||||
sqlServer.kill();
|
||||
|
||||
if (JSON_OUT) {
|
||||
console.log(JSON.stringify({ memoryNocache: memResults, sqliteNocache: sqlResults, memoryCached: memCachedResults }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
// Print results
|
||||
const W = 94;
|
||||
console.log(`\n${'═'.repeat(W)}`);
|
||||
console.log(' 🏁 BENCHMARK RESULTS: SQLite vs In-Memory Store');
|
||||
console.log(`${'═'.repeat(W)}`);
|
||||
console.log(`${'Endpoint'.padEnd(24)} ${'SQLite'.padStart(9)} ${'Memory'.padStart(9)} ${'Cached'.padStart(9)} ${'Speedup'.padStart(9)} ${'Size (SQL)'.padStart(10)} ${'Size (Mem)'.padStart(10)}`);
|
||||
console.log(`${'─'.repeat(24)} ${'─'.repeat(9)} ${'─'.repeat(9)} ${'─'.repeat(9)} ${'─'.repeat(9)} ${'─'.repeat(10)} ${'─'.repeat(10)}`);
|
||||
|
||||
for (let i = 0; i < endpoints.length; i++) {
|
||||
const sql = sqlResults[i];
|
||||
const mem = memResults[i];
|
||||
const cached = memCachedResults[i];
|
||||
if (!sql || sql.failed || !mem || mem.failed) {
|
||||
console.log(`${endpoints[i].name.padEnd(24)} ${'FAILED'.padStart(9)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const speedup = sql.avg > 0 && mem.avg > 0 ? Math.round(sql.avg / mem.avg) + '×' : '—';
|
||||
const cachedStr = cached && !cached.failed ? fmt(cached.avg) : '—';
|
||||
|
||||
console.log(
|
||||
`${sql.name.padEnd(24)} ${fmt(sql.avg).padStart(9)} ${fmt(mem.avg).padStart(9)} ${cachedStr.padStart(9)} ${speedup.padStart(9)} ${fmtSize(sql.bytes).padStart(10)} ${fmtSize(mem.bytes).padStart(10)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Summary
|
||||
const sqlTotal = sqlResults.filter(r => !r.failed).reduce((s, r) => s + r.avg, 0);
|
||||
const memTotal = memResults.filter(r => !r.failed).reduce((s, r) => s + r.avg, 0);
|
||||
console.log(`${'─'.repeat(24)} ${'─'.repeat(9)} ${'─'.repeat(9)} ${'─'.repeat(9)} ${'─'.repeat(9)}`);
|
||||
console.log(`${'TOTAL'.padEnd(24)} ${fmt(sqlTotal).padStart(9)} ${fmt(memTotal).padStart(9)} ${''.padStart(9)} ${(Math.round(sqlTotal/memTotal)+'×').padStart(9)}`);
|
||||
console.log(`\n${'═'.repeat(W)}\n`);
|
||||
}
|
||||
|
||||
run().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -4,6 +4,35 @@
|
||||
"broker": "mqtt://localhost:1883",
|
||||
"topic": "meshcore/+/+/packets"
|
||||
},
|
||||
"mqttSources": [
|
||||
{
|
||||
"name": "local",
|
||||
"broker": "mqtt://localhost:1883",
|
||||
"topics": [
|
||||
"meshcore/+/+/packets",
|
||||
"meshcore/#"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "lincomatic",
|
||||
"broker": "mqtts://mqtt.lincomatic.com:8883",
|
||||
"username": "your-username",
|
||||
"password": "your-password",
|
||||
"rejectUnauthorized": false,
|
||||
"topics": [
|
||||
"meshcore/SJC/#",
|
||||
"meshcore/SFO/#",
|
||||
"meshcore/OAK/#",
|
||||
"meshcore/MRY/#"
|
||||
],
|
||||
"iataFilter": [
|
||||
"SJC",
|
||||
"SFO",
|
||||
"OAK",
|
||||
"MRY"
|
||||
]
|
||||
}
|
||||
],
|
||||
"channelKeys": {
|
||||
"public": "8b3387e9c5cdea6ac9e5edbaa115cd72",
|
||||
"#test": "9cd8fcf22a47333b591d96a2b848b73f",
|
||||
@@ -20,7 +49,32 @@
|
||||
"SJC": "San Jose, US",
|
||||
"SFO": "San Francisco, US",
|
||||
"OAK": "Oakland, US",
|
||||
"MRY": "Monterey, US",
|
||||
"LAR": "Los Angeles, US"
|
||||
"MRY": "Monterey, US"
|
||||
},
|
||||
"cacheTTL": {
|
||||
"stats": 10,
|
||||
"nodeDetail": 300,
|
||||
"nodeHealth": 300,
|
||||
"nodeList": 90,
|
||||
"bulkHealth": 600,
|
||||
"networkStatus": 600,
|
||||
"observers": 300,
|
||||
"channels": 15,
|
||||
"channelMessages": 10,
|
||||
"analyticsRF": 1800,
|
||||
"analyticsTopology": 1800,
|
||||
"analyticsChannels": 1800,
|
||||
"analyticsHashSizes": 3600,
|
||||
"analyticsSubpaths": 3600,
|
||||
"analyticsSubpathDetail": 3600,
|
||||
"nodeAnalytics": 60,
|
||||
"nodeSearch": 10,
|
||||
"invalidationDebounce": 30,
|
||||
"_comment": "All values in seconds. Server uses these directly. Client fetches via /api/config/cache."
|
||||
},
|
||||
"packetStore": {
|
||||
"maxMemoryMB": 1024,
|
||||
"estimatedPacketBytes": 450,
|
||||
"_comment": "In-memory packet store. maxMemoryMB caps RAM usage. All packets loaded on startup, served from RAM."
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,14 @@ db.exec(`
|
||||
iata TEXT,
|
||||
last_seen TEXT,
|
||||
first_seen TEXT,
|
||||
packet_count INTEGER DEFAULT 0
|
||||
packet_count INTEGER DEFAULT 0,
|
||||
model TEXT,
|
||||
firmware TEXT,
|
||||
client_version TEXT,
|
||||
radio TEXT,
|
||||
battery_mv INTEGER,
|
||||
uptime_secs INTEGER,
|
||||
noise_floor INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paths (
|
||||
@@ -64,8 +71,53 @@ db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_packets_payload_type ON packets(payload_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_last_seen ON nodes(last_seen);
|
||||
CREATE INDEX IF NOT EXISTS idx_observers_last_seen ON observers(last_seen);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transmissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT NOT NULL,
|
||||
hash TEXT NOT NULL UNIQUE,
|
||||
first_seen TEXT NOT NULL,
|
||||
route_type INTEGER,
|
||||
payload_type INTEGER,
|
||||
payload_version INTEGER,
|
||||
decoded_json TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
|
||||
hash TEXT NOT NULL,
|
||||
observer_id TEXT,
|
||||
observer_name TEXT,
|
||||
direction TEXT,
|
||||
snr REAL,
|
||||
rssi REAL,
|
||||
score INTEGER,
|
||||
path_json TEXT,
|
||||
timestamp TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transmissions_hash ON transmissions(hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_transmissions_first_seen ON transmissions(first_seen);
|
||||
CREATE INDEX IF NOT EXISTS idx_transmissions_payload_type ON transmissions(payload_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_hash ON observations(hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_transmission_id ON observations(transmission_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_observer_id ON observations(observer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_timestamp ON observations(timestamp);
|
||||
`);
|
||||
|
||||
// --- Migrations for existing DBs ---
|
||||
const observerCols = db.pragma('table_info(observers)').map(c => c.name);
|
||||
for (const col of ['model', 'firmware', 'client_version', 'radio', 'battery_mv', 'uptime_secs', 'noise_floor']) {
|
||||
if (!observerCols.includes(col)) {
|
||||
const type = ['battery_mv', 'uptime_secs', 'noise_floor'].includes(col) ? 'INTEGER' : 'TEXT';
|
||||
db.exec(`ALTER TABLE observers ADD COLUMN ${col} ${type}`);
|
||||
console.log(`[migration] Added observers.${col}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Prepared statements ---
|
||||
const stmts = {
|
||||
insertPacket: db.prepare(`
|
||||
@@ -85,13 +137,35 @@ const stmts = {
|
||||
advert_count = advert_count + 1
|
||||
`),
|
||||
upsertObserver: db.prepare(`
|
||||
INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
|
||||
VALUES (@id, @name, @iata, @last_seen, @first_seen, 1)
|
||||
INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor)
|
||||
VALUES (@id, @name, @iata, @last_seen, @first_seen, 1, @model, @firmware, @client_version, @radio, @battery_mv, @uptime_secs, @noise_floor)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = COALESCE(@name, name),
|
||||
iata = COALESCE(@iata, iata),
|
||||
last_seen = @last_seen,
|
||||
packet_count = packet_count + 1
|
||||
packet_count = packet_count + 1,
|
||||
model = COALESCE(@model, model),
|
||||
firmware = COALESCE(@firmware, firmware),
|
||||
client_version = COALESCE(@client_version, client_version),
|
||||
radio = COALESCE(@radio, radio),
|
||||
battery_mv = COALESCE(@battery_mv, battery_mv),
|
||||
uptime_secs = COALESCE(@uptime_secs, uptime_secs),
|
||||
noise_floor = COALESCE(@noise_floor, noise_floor)
|
||||
`),
|
||||
updateObserverStatus: db.prepare(`
|
||||
INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor)
|
||||
VALUES (@id, @name, @iata, @last_seen, @first_seen, 0, @model, @firmware, @client_version, @radio, @battery_mv, @uptime_secs, @noise_floor)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = COALESCE(@name, name),
|
||||
iata = COALESCE(@iata, iata),
|
||||
last_seen = @last_seen,
|
||||
model = COALESCE(@model, model),
|
||||
firmware = COALESCE(@firmware, firmware),
|
||||
client_version = COALESCE(@client_version, client_version),
|
||||
radio = COALESCE(@radio, radio),
|
||||
battery_mv = COALESCE(@battery_mv, battery_mv),
|
||||
uptime_secs = COALESCE(@uptime_secs, uptime_secs),
|
||||
noise_floor = COALESCE(@noise_floor, noise_floor)
|
||||
`),
|
||||
getPacket: db.prepare(`SELECT * FROM packets WHERE id = ?`),
|
||||
getPathsForPacket: db.prepare(`SELECT * FROM paths WHERE packet_id = ? ORDER BY hop_index`),
|
||||
@@ -105,6 +179,16 @@ const stmts = {
|
||||
countNodes: db.prepare(`SELECT COUNT(*) as count FROM nodes`),
|
||||
countObservers: db.prepare(`SELECT COUNT(*) as count FROM observers`),
|
||||
countRecentPackets: db.prepare(`SELECT COUNT(*) as count FROM packets WHERE timestamp > ?`),
|
||||
getTransmissionByHash: db.prepare(`SELECT id, first_seen FROM transmissions WHERE hash = ?`),
|
||||
insertTransmission: db.prepare(`
|
||||
INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json)
|
||||
VALUES (@raw_hex, @hash, @first_seen, @route_type, @payload_type, @payload_version, @decoded_json)
|
||||
`),
|
||||
updateTransmissionFirstSeen: db.prepare(`UPDATE transmissions SET first_seen = @first_seen WHERE id = @id`),
|
||||
insertObservation: db.prepare(`
|
||||
INSERT INTO observations (transmission_id, hash, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp)
|
||||
VALUES (@transmission_id, @hash, @observer_id, @observer_name, @direction, @snr, @rssi, @score, @path_json, @timestamp)
|
||||
`),
|
||||
};
|
||||
|
||||
// --- Helper functions ---
|
||||
@@ -129,6 +213,49 @@ function insertPacket(data) {
|
||||
return stmts.insertPacket.run(d).lastInsertRowid;
|
||||
}
|
||||
|
||||
function insertTransmission(data) {
|
||||
const hash = data.hash;
|
||||
if (!hash) return null; // Can't deduplicate without a hash
|
||||
|
||||
const timestamp = data.timestamp || new Date().toISOString();
|
||||
let transmissionId;
|
||||
|
||||
const existing = stmts.getTransmissionByHash.get(hash);
|
||||
if (existing) {
|
||||
transmissionId = existing.id;
|
||||
// Update first_seen if this observation is earlier
|
||||
if (timestamp < existing.first_seen) {
|
||||
stmts.updateTransmissionFirstSeen.run({ id: transmissionId, first_seen: timestamp });
|
||||
}
|
||||
} else {
|
||||
const result = stmts.insertTransmission.run({
|
||||
raw_hex: data.raw_hex || '',
|
||||
hash,
|
||||
first_seen: timestamp,
|
||||
route_type: data.route_type ?? null,
|
||||
payload_type: data.payload_type ?? null,
|
||||
payload_version: data.payload_version ?? null,
|
||||
decoded_json: data.decoded_json || null,
|
||||
});
|
||||
transmissionId = result.lastInsertRowid;
|
||||
}
|
||||
|
||||
const obsResult = stmts.insertObservation.run({
|
||||
transmission_id: transmissionId,
|
||||
hash,
|
||||
observer_id: data.observer_id || null,
|
||||
observer_name: data.observer_name || null,
|
||||
direction: data.direction || null,
|
||||
snr: data.snr ?? null,
|
||||
rssi: data.rssi ?? null,
|
||||
score: data.score ?? null,
|
||||
path_json: data.path_json || null,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
return { transmissionId, observationId: obsResult.lastInsertRowid };
|
||||
}
|
||||
|
||||
function insertPath(packetId, hops) {
|
||||
const tx = db.transaction((hops) => {
|
||||
for (let i = 0; i < hops.length; i++) {
|
||||
@@ -159,6 +286,31 @@ function upsertObserver(data) {
|
||||
iata: data.iata || null,
|
||||
last_seen: data.last_seen || now,
|
||||
first_seen: data.first_seen || now,
|
||||
model: data.model || null,
|
||||
firmware: data.firmware || null,
|
||||
client_version: data.client_version || null,
|
||||
radio: data.radio || null,
|
||||
battery_mv: data.battery_mv || null,
|
||||
uptime_secs: data.uptime_secs || null,
|
||||
noise_floor: data.noise_floor || null,
|
||||
});
|
||||
}
|
||||
|
||||
function updateObserverStatus(data) {
|
||||
const now = new Date().toISOString();
|
||||
stmts.updateObserverStatus.run({
|
||||
id: data.id,
|
||||
name: data.name || null,
|
||||
iata: data.iata || null,
|
||||
last_seen: data.last_seen || now,
|
||||
first_seen: data.first_seen || now,
|
||||
model: data.model || null,
|
||||
firmware: data.firmware || null,
|
||||
client_version: data.client_version || null,
|
||||
radio: data.radio || null,
|
||||
battery_mv: data.battery_mv || null,
|
||||
uptime_secs: data.uptime_secs || null,
|
||||
noise_floor: data.noise_floor || null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -212,8 +364,15 @@ function getObservers() {
|
||||
|
||||
function getStats() {
|
||||
const oneHourAgo = new Date(Date.now() - 3600000).toISOString();
|
||||
// Try to get transmission count from normalized schema
|
||||
let totalTransmissions = null;
|
||||
try {
|
||||
totalTransmissions = db.prepare('SELECT COUNT(*) as count FROM transmissions').get().count;
|
||||
} catch {}
|
||||
return {
|
||||
totalPackets: stmts.countPackets.get().count,
|
||||
totalTransmissions,
|
||||
totalObservations: stmts.countPackets.get().count, // legacy packets = observations
|
||||
totalNodes: stmts.countNodes.get().count,
|
||||
totalObservers: stmts.countObservers.get().count,
|
||||
packetsLastHour: stmts.countRecentPackets.get(oneHourAgo).count,
|
||||
@@ -225,13 +384,13 @@ function seed() {
|
||||
const now = new Date().toISOString();
|
||||
const rawHex = '11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172';
|
||||
|
||||
upsertObserver({ id: 'obs-sjc-001', name: 'Iavor Observer', iata: 'SJC', last_seen: now, first_seen: now });
|
||||
upsertObserver({ id: 'obs-seed-001', name: 'Seed Observer', iata: 'UNK', last_seen: now, first_seen: now });
|
||||
|
||||
const pktId = insertPacket({
|
||||
raw_hex: rawHex,
|
||||
timestamp: now,
|
||||
observer_id: 'obs-sjc-001',
|
||||
observer_name: 'Iavor Observer',
|
||||
observer_id: 'obs-seed-001',
|
||||
observer_name: 'Seed Observer',
|
||||
direction: 'rx',
|
||||
snr: 10.5,
|
||||
rssi: -85,
|
||||
@@ -241,17 +400,17 @@ function seed() {
|
||||
payload_type: 4,
|
||||
payload_version: 1,
|
||||
path_json: JSON.stringify(['A1B2', 'C3D4']),
|
||||
decoded_json: JSON.stringify({ type: 'ADVERT', name: 'Kpa Roof Solar', role: 'repeater', lat: 37.31468, lon: -121.8921 }),
|
||||
decoded_json: JSON.stringify({ type: 'ADVERT', name: 'Test Repeater', role: 'repeater', lat: 0, lon: 0 }),
|
||||
});
|
||||
|
||||
insertPath(pktId, ['A1B2', 'C3D4']);
|
||||
|
||||
upsertNode({
|
||||
public_key: 'kpa-roof-solar-pubkey',
|
||||
name: 'Kpa Roof Solar',
|
||||
public_key: 'seed-test-pubkey',
|
||||
name: 'Test Repeater',
|
||||
role: 'repeater',
|
||||
lat: 37.31468,
|
||||
lon: -121.8921,
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
last_seen: now,
|
||||
first_seen: now,
|
||||
});
|
||||
@@ -328,6 +487,10 @@ function getNodeHealth(pubkey) {
|
||||
}
|
||||
const avgHops = hopCount > 0 ? Math.round(totalHops / hopCount) : 0;
|
||||
|
||||
const totalPackets = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM packets WHERE ${whereClause}
|
||||
`).get(params).count;
|
||||
|
||||
// Recent 10 packets
|
||||
const recentPackets = db.prepare(`
|
||||
SELECT * FROM packets WHERE ${whereClause} ORDER BY timestamp DESC LIMIT 10
|
||||
@@ -336,9 +499,157 @@ function getNodeHealth(pubkey) {
|
||||
return {
|
||||
node,
|
||||
observers,
|
||||
stats: { packetsToday, avgSnr: avgStats.avgSnr, avgHops, lastHeard },
|
||||
stats: { totalPackets, packetsToday, avgSnr: avgStats.avgSnr, avgHops, lastHeard },
|
||||
recentPackets,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { db, insertPacket, insertPath, upsertNode, upsertObserver, getPackets, getPacket, getNodes, getNode, getObservers, getStats, seed, searchNodes, getNodeHealth };
|
||||
function getNodeAnalytics(pubkey, days) {
|
||||
const node = stmts.getNode.get(pubkey);
|
||||
if (!node) return null;
|
||||
|
||||
const now = new Date();
|
||||
const from = new Date(now.getTime() - days * 86400000);
|
||||
const fromISO = from.toISOString();
|
||||
const toISO = now.toISOString();
|
||||
|
||||
const keyPattern = `%${pubkey}%`;
|
||||
const namePattern = node.name ? `%${node.name.replace(/[%_]/g, '')}%` : null;
|
||||
const whereClause = namePattern
|
||||
? `(decoded_json LIKE @keyPattern OR decoded_json LIKE @namePattern)`
|
||||
: `decoded_json LIKE @keyPattern`;
|
||||
const timeWhere = `${whereClause} AND timestamp > @fromISO`;
|
||||
const params = namePattern ? { keyPattern, namePattern, fromISO } : { keyPattern, fromISO };
|
||||
|
||||
// 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
|
||||
`).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
|
||||
`).all(params);
|
||||
|
||||
// Packet type breakdown
|
||||
const packetTypeBreakdown = db.prepare(`
|
||||
SELECT payload_type, COUNT(*) as count FROM packets 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
|
||||
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
|
||||
`).all(params);
|
||||
|
||||
const hopCounts = {};
|
||||
let totalWithPath = 0, relayedCount = 0;
|
||||
for (const row of pathRows) {
|
||||
try {
|
||||
const hops = JSON.parse(row.path_json);
|
||||
if (Array.isArray(hops)) {
|
||||
const h = hops.length;
|
||||
const key = h >= 4 ? '4+' : String(h);
|
||||
hopCounts[key] = (hopCounts[key] || 0) + 1;
|
||||
totalWithPath++;
|
||||
if (h > 1) relayedCount++;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const hopDistribution = Object.entries(hopCounts).map(([hops, count]) => ({ hops, count }))
|
||||
.sort((a, b) => a.hops.localeCompare(b.hops, undefined, { numeric: true }));
|
||||
|
||||
// Peer interactions from decoded_json
|
||||
const decodedRows = db.prepare(`
|
||||
SELECT decoded_json, timestamp FROM packets WHERE ${timeWhere} AND decoded_json IS NOT NULL
|
||||
`).all(params);
|
||||
|
||||
const peerMap = {};
|
||||
for (const row of decodedRows) {
|
||||
try {
|
||||
const d = JSON.parse(row.decoded_json);
|
||||
// Look for sender/recipient pubkeys that aren't this node
|
||||
const candidates = [];
|
||||
if (d.sender_key && d.sender_key !== pubkey) candidates.push({ key: d.sender_key, name: d.sender_name || d.sender_short_name });
|
||||
if (d.recipient_key && d.recipient_key !== pubkey) candidates.push({ key: d.recipient_key, name: d.recipient_name || d.recipient_short_name });
|
||||
if (d.pubkey && d.pubkey !== pubkey) candidates.push({ key: d.pubkey, name: d.name });
|
||||
for (const c of candidates) {
|
||||
if (!c.key) continue;
|
||||
if (!peerMap[c.key]) peerMap[c.key] = { peer_key: c.key, peer_name: c.name || c.key.slice(0, 12), messageCount: 0, lastContact: row.timestamp };
|
||||
peerMap[c.key].messageCount++;
|
||||
if (row.timestamp > peerMap[c.key].lastContact) peerMap[c.key].lastContact = row.timestamp;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const peerInteractions = Object.values(peerMap).sort((a, b) => b.messageCount - a.messageCount).slice(0, 20);
|
||||
|
||||
// Uptime heatmap
|
||||
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
|
||||
`).all(params);
|
||||
|
||||
// Computed stats
|
||||
const totalPackets = db.prepare(`SELECT COUNT(*) as count FROM packets WHERE ${timeWhere}`).get(params).count;
|
||||
const uniqueObservers = observerCoverage.length;
|
||||
const uniquePeers = peerInteractions.length;
|
||||
const avgPacketsPerDay = days > 0 ? Math.round(totalPackets / days * 10) / 10 : totalPackets;
|
||||
|
||||
// Availability: distinct hours with packets / total hours
|
||||
const distinctHours = activityTimeline.length;
|
||||
const totalHours = days * 24;
|
||||
const availabilityPct = totalHours > 0 ? Math.round(distinctHours / totalHours * 1000) / 10 : 0;
|
||||
|
||||
// Longest silence
|
||||
const timestamps = db.prepare(`
|
||||
SELECT timestamp FROM packets WHERE ${timeWhere} ORDER BY timestamp
|
||||
`).all(params).map(r => new Date(r.timestamp).getTime());
|
||||
|
||||
let longestSilenceMs = 0, longestSilenceStart = null;
|
||||
for (let i = 1; i < timestamps.length; i++) {
|
||||
const gap = timestamps[i] - timestamps[i - 1];
|
||||
if (gap > longestSilenceMs) { longestSilenceMs = gap; longestSilenceStart = new Date(timestamps[i - 1]).toISOString(); }
|
||||
}
|
||||
|
||||
// Signal grade
|
||||
const snrValues = snrTrend.map(r => r.snr);
|
||||
const snrMean = snrValues.length > 0 ? snrValues.reduce((a, b) => a + b, 0) / snrValues.length : 0;
|
||||
const snrStdDev = snrValues.length > 1 ? Math.sqrt(snrValues.reduce((s, v) => s + (v - snrMean) ** 2, 0) / snrValues.length) : 0;
|
||||
let signalGrade = 'D';
|
||||
if (snrMean > 15 && snrStdDev < 2) signalGrade = 'A';
|
||||
else if (snrMean > 15) signalGrade = 'A-';
|
||||
else if (snrMean > 12 && snrStdDev < 3) signalGrade = 'B+';
|
||||
else if (snrMean > 8) signalGrade = 'B';
|
||||
else if (snrMean > 3) signalGrade = 'C';
|
||||
|
||||
const relayPct = totalWithPath > 0 ? Math.round(relayedCount / totalWithPath * 1000) / 10 : 0;
|
||||
|
||||
return {
|
||||
node,
|
||||
timeRange: { from: fromISO, to: toISO, days },
|
||||
activityTimeline,
|
||||
snrTrend,
|
||||
packetTypeBreakdown,
|
||||
observerCoverage,
|
||||
hopDistribution,
|
||||
peerInteractions,
|
||||
uptimeHeatmap,
|
||||
computedStats: {
|
||||
availabilityPct, longestSilenceMs, longestSilenceStart, signalGrade,
|
||||
snrMean: Math.round(snrMean * 10) / 10, snrStdDev: Math.round(snrStdDev * 10) / 10,
|
||||
relayPct, totalPackets, uniqueObservers, uniquePeers, avgPacketsPerDay
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { db, insertPacket, insertTransmission, insertPath, upsertNode, upsertObserver, updateObserverStatus, getPackets, getPacket, getNodes, getNode, getObservers, getStats, seed, searchNodes, getNodeHealth, getNodeAnalytics };
|
||||
|
||||
@@ -269,7 +269,7 @@ module.exports = { decodePacket, ROUTE_TYPES, PAYLOAD_TYPES };
|
||||
|
||||
// --- Tests ---
|
||||
if (require.main === module) {
|
||||
console.log('=== Test 1: ADVERT, FLOOD, 5 hops (2-byte hashes), "Kpa Roof Solar" ===');
|
||||
console.log('=== Test 1: ADVERT, FLOOD, 5 hops (2-byte hashes), "Test Repeater" ===');
|
||||
const pkt1 = decodePacket(
|
||||
'11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172'
|
||||
);
|
||||
@@ -285,7 +285,7 @@ if (require.main === module) {
|
||||
assert(pkt1.path.hops[0] === '1000', 'first hop should be 1000');
|
||||
assert(pkt1.path.hops[1] === 'D818', 'second hop should be D818');
|
||||
assert(pkt1.transportCodes === null, 'FLOOD has no transport codes');
|
||||
assert(pkt1.payload.name === 'Kpa Roof Solar', 'name should be "Kpa Roof Solar"');
|
||||
assert(pkt1.payload.name === 'Test Repeater', 'name should be "Test Repeater"');
|
||||
console.log('✅ Test 1 passed\n');
|
||||
|
||||
console.log('=== Test 2: ADVERT, FLOOD, 0 hops (zero-path) ===');
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Default Caddyfile — reverse proxy to Node app
|
||||
# Override by mounting your own: -v ./Caddyfile:/etc/caddy/Caddyfile
|
||||
#
|
||||
# For automatic HTTPS, replace :80 with your domain:
|
||||
# analyzer.example.com {
|
||||
# reverse_proxy localhost:3000
|
||||
# }
|
||||
|
||||
:80 {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Copy example config if no config.json exists
|
||||
if [ ! -f /app/config.json ]; then
|
||||
echo "[entrypoint] No config.json found, copying from config.example.json"
|
||||
cp /app/config.example.json /app/config.json
|
||||
fi
|
||||
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||
@@ -0,0 +1,10 @@
|
||||
# Mosquitto config for MeshCore Analyzer
|
||||
listener 1883 0.0.0.0
|
||||
allow_anonymous true
|
||||
persistence true
|
||||
persistence_location /var/lib/mosquitto/
|
||||
|
||||
# Logging
|
||||
log_dest stdout
|
||||
log_type warning
|
||||
log_type error
|
||||
@@ -0,0 +1,36 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
logfile=/dev/stdout
|
||||
logfile_maxbytes=0
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:mosquitto]
|
||||
command=/usr/sbin/mosquitto -c /etc/mosquitto/mosquitto.conf
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:meshcore-analyzer]
|
||||
command=node /app/server.js
|
||||
directory=/app
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
environment=NODE_ENV="production"
|
||||
|
||||
[program:caddy]
|
||||
command=/usr/sbin/caddy run --config /etc/caddy/Caddyfile
|
||||
environment=XDG_DATA_HOME="/data"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 85 MiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 437 KiB |
|
After Width: | Height: | Size: 199 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "meshcore-analyzer",
|
||||
"version": "1.0.0",
|
||||
"version": "2.1.0",
|
||||
"description": "Community-run alternative to the closed-source `analyzer.letsmesh.net`. MQTT packet collection + open-source web analyzer for the Bay Area MeshCore mesh.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* In-memory packet store — loads transmissions + observations from SQLite on startup,
|
||||
* serves reads from RAM, writes to both RAM + SQLite.
|
||||
* M3: Restructured around transmissions (deduped by hash) with observations.
|
||||
* Caps memory at configurable limit (default 1GB).
|
||||
*/
|
||||
class PacketStore {
|
||||
constructor(dbModule, config = {}) {
|
||||
this.dbModule = dbModule; // The full db module (has .db, .insertPacket, .getPacket)
|
||||
this.db = dbModule.db; // Raw better-sqlite3 instance for queries
|
||||
this.maxBytes = (config.maxMemoryMB || 1024) * 1024 * 1024;
|
||||
this.estPacketBytes = config.estimatedPacketBytes || 450;
|
||||
this.maxPackets = Math.floor(this.maxBytes / this.estPacketBytes);
|
||||
|
||||
// SQLite-only mode: skip RAM loading, all reads go to DB
|
||||
this.sqliteOnly = process.env.NO_MEMORY_STORE === '1';
|
||||
|
||||
// Primary storage: transmissions sorted by first_seen DESC (newest first)
|
||||
// Each transmission looks like a packet for backward compat
|
||||
this.packets = [];
|
||||
|
||||
// Indexes
|
||||
this.byId = new Map(); // observation_id → observation object (backward compat for packet detail links)
|
||||
this.byHash = new Map(); // hash → transmission object (1:1)
|
||||
this.byObserver = new Map(); // observer_id → [observation objects]
|
||||
this.byNode = new Map(); // pubkey → [transmission objects] (deduped)
|
||||
this.byTransmission = new Map(); // hash → transmission object (same refs as byHash)
|
||||
|
||||
// Track which hashes are indexed per node pubkey (avoid dupes in byNode)
|
||||
this._nodeHashIndex = new Map(); // pubkey → Set<hash>
|
||||
|
||||
this.loaded = false;
|
||||
this.stats = { totalLoaded: 0, totalObservations: 0, evicted: 0, inserts: 0, queries: 0 };
|
||||
}
|
||||
|
||||
/** Load all packets from SQLite into memory */
|
||||
load() {
|
||||
if (this.sqliteOnly) {
|
||||
console.log('[PacketStore] SQLite-only mode (NO_MEMORY_STORE=1) — all reads go to database');
|
||||
this.loaded = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
|
||||
// Check if normalized schema exists
|
||||
const hasTransmissions = this.db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='transmissions'"
|
||||
).get();
|
||||
|
||||
if (hasTransmissions) {
|
||||
this._loadNormalized();
|
||||
} else {
|
||||
this._loadLegacy();
|
||||
}
|
||||
|
||||
this.stats.totalLoaded = this.packets.length;
|
||||
this.loaded = true;
|
||||
const elapsed = Date.now() - t0;
|
||||
console.log(`[PacketStore] Loaded ${this.packets.length} transmissions (${this.stats.totalObservations} observations) in ${elapsed}ms (${Math.round(this.packets.length * this.estPacketBytes / 1024 / 1024)}MB est)`);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Load from normalized transmissions + observations tables */
|
||||
_loadNormalized() {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT t.id AS transmission_id, t.raw_hex, t.hash, t.first_seen, t.route_type,
|
||||
t.payload_type, t.payload_version, t.decoded_json,
|
||||
o.id AS observation_id, o.observer_id, o.observer_name, o.direction,
|
||||
o.snr, o.rssi, o.score, o.path_json, o.timestamp AS obs_timestamp
|
||||
FROM transmissions t
|
||||
LEFT JOIN observations o ON o.transmission_id = t.id
|
||||
ORDER BY t.first_seen DESC, o.timestamp DESC
|
||||
`).all();
|
||||
|
||||
for (const row of rows) {
|
||||
if (this.packets.length >= this.maxPackets && !this.byTransmission.has(row.hash)) break;
|
||||
|
||||
let tx = this.byTransmission.get(row.hash);
|
||||
if (!tx) {
|
||||
tx = {
|
||||
id: row.transmission_id,
|
||||
raw_hex: row.raw_hex,
|
||||
hash: row.hash,
|
||||
first_seen: row.first_seen,
|
||||
timestamp: row.first_seen,
|
||||
route_type: row.route_type,
|
||||
payload_type: row.payload_type,
|
||||
decoded_json: row.decoded_json,
|
||||
observations: [],
|
||||
observation_count: 0,
|
||||
// Filled from first observation for backward compat
|
||||
observer_id: null,
|
||||
observer_name: null,
|
||||
snr: null,
|
||||
rssi: null,
|
||||
path_json: null,
|
||||
direction: null,
|
||||
};
|
||||
this.byTransmission.set(row.hash, tx);
|
||||
this.byHash.set(row.hash, tx);
|
||||
this.packets.push(tx);
|
||||
this._indexByNode(tx);
|
||||
}
|
||||
|
||||
if (row.observation_id != null) {
|
||||
const obs = {
|
||||
id: row.observation_id,
|
||||
observer_id: row.observer_id,
|
||||
observer_name: row.observer_name,
|
||||
direction: row.direction,
|
||||
snr: row.snr,
|
||||
rssi: row.rssi,
|
||||
score: row.score,
|
||||
path_json: row.path_json,
|
||||
timestamp: row.obs_timestamp,
|
||||
// Carry transmission fields for backward compat
|
||||
hash: row.hash,
|
||||
raw_hex: row.raw_hex,
|
||||
payload_type: row.payload_type,
|
||||
decoded_json: row.decoded_json,
|
||||
route_type: row.route_type,
|
||||
};
|
||||
|
||||
tx.observations.push(obs);
|
||||
tx.observation_count++;
|
||||
|
||||
// Fill first observation data into transmission for backward compat
|
||||
if (tx.observer_id == null && obs.observer_id) {
|
||||
tx.observer_id = obs.observer_id;
|
||||
tx.observer_name = obs.observer_name;
|
||||
tx.snr = obs.snr;
|
||||
tx.rssi = obs.rssi;
|
||||
tx.path_json = obs.path_json;
|
||||
tx.direction = obs.direction;
|
||||
}
|
||||
|
||||
// byId maps observation IDs for packet detail links
|
||||
this.byId.set(obs.id, obs);
|
||||
|
||||
// byObserver
|
||||
if (obs.observer_id) {
|
||||
if (!this.byObserver.has(obs.observer_id)) this.byObserver.set(obs.observer_id, []);
|
||||
this.byObserver.get(obs.observer_id).push(obs);
|
||||
}
|
||||
|
||||
this.stats.totalObservations++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fallback: load from legacy packets table */
|
||||
_loadLegacy() {
|
||||
const rows = this.db.prepare(
|
||||
'SELECT * FROM packets ORDER BY timestamp DESC'
|
||||
).all();
|
||||
|
||||
for (const row of rows) {
|
||||
if (this.packets.length >= this.maxPackets) break;
|
||||
this._indexLegacy(row);
|
||||
}
|
||||
}
|
||||
|
||||
/** Index a legacy packet row (old flat structure) — builds transmission + observation */
|
||||
_indexLegacy(pkt) {
|
||||
let tx = this.byTransmission.get(pkt.hash);
|
||||
if (!tx) {
|
||||
tx = {
|
||||
id: pkt.id,
|
||||
raw_hex: pkt.raw_hex,
|
||||
hash: pkt.hash,
|
||||
first_seen: pkt.timestamp,
|
||||
timestamp: pkt.timestamp,
|
||||
route_type: pkt.route_type,
|
||||
payload_type: pkt.payload_type,
|
||||
decoded_json: pkt.decoded_json,
|
||||
observations: [],
|
||||
observation_count: 0,
|
||||
observer_id: pkt.observer_id,
|
||||
observer_name: pkt.observer_name,
|
||||
snr: pkt.snr,
|
||||
rssi: pkt.rssi,
|
||||
path_json: pkt.path_json,
|
||||
direction: pkt.direction,
|
||||
};
|
||||
this.byTransmission.set(pkt.hash, tx);
|
||||
this.byHash.set(pkt.hash, tx);
|
||||
this.packets.push(tx);
|
||||
this._indexByNode(tx);
|
||||
}
|
||||
|
||||
if (pkt.timestamp < tx.first_seen) {
|
||||
tx.first_seen = pkt.timestamp;
|
||||
tx.timestamp = pkt.timestamp;
|
||||
}
|
||||
|
||||
const obs = {
|
||||
id: pkt.id,
|
||||
observer_id: pkt.observer_id,
|
||||
observer_name: pkt.observer_name,
|
||||
direction: pkt.direction,
|
||||
snr: pkt.snr,
|
||||
rssi: pkt.rssi,
|
||||
score: pkt.score,
|
||||
path_json: pkt.path_json,
|
||||
timestamp: pkt.timestamp,
|
||||
hash: pkt.hash,
|
||||
raw_hex: pkt.raw_hex,
|
||||
payload_type: pkt.payload_type,
|
||||
decoded_json: pkt.decoded_json,
|
||||
route_type: pkt.route_type,
|
||||
};
|
||||
tx.observations.push(obs);
|
||||
tx.observation_count++;
|
||||
|
||||
this.byId.set(pkt.id, obs);
|
||||
|
||||
if (pkt.observer_id) {
|
||||
if (!this.byObserver.has(pkt.observer_id)) this.byObserver.set(pkt.observer_id, []);
|
||||
this.byObserver.get(pkt.observer_id).push(obs);
|
||||
}
|
||||
|
||||
this.stats.totalObservations++;
|
||||
}
|
||||
|
||||
/** Extract node pubkeys from decoded_json and index transmission in byNode */
|
||||
_indexByNode(tx) {
|
||||
if (!tx.decoded_json) return;
|
||||
try {
|
||||
const decoded = JSON.parse(tx.decoded_json);
|
||||
const keys = new Set();
|
||||
if (decoded.pubKey) keys.add(decoded.pubKey);
|
||||
if (decoded.destPubKey) keys.add(decoded.destPubKey);
|
||||
if (decoded.srcPubKey) keys.add(decoded.srcPubKey);
|
||||
for (const k of keys) {
|
||||
if (!this._nodeHashIndex.has(k)) this._nodeHashIndex.set(k, new Set());
|
||||
if (this._nodeHashIndex.get(k).has(tx.hash)) continue; // already indexed
|
||||
this._nodeHashIndex.get(k).add(tx.hash);
|
||||
if (!this.byNode.has(k)) this.byNode.set(k, []);
|
||||
this.byNode.get(k).push(tx);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Remove oldest transmissions when over memory limit */
|
||||
_evict() {
|
||||
while (this.packets.length > this.maxPackets) {
|
||||
const old = this.packets.pop();
|
||||
this.byHash.delete(old.hash);
|
||||
this.byTransmission.delete(old.hash);
|
||||
// Remove observations from byId and byObserver
|
||||
for (const obs of old.observations) {
|
||||
this.byId.delete(obs.id);
|
||||
if (obs.observer_id && this.byObserver.has(obs.observer_id)) {
|
||||
const arr = this.byObserver.get(obs.observer_id).filter(o => o.id !== obs.id);
|
||||
if (arr.length) this.byObserver.set(obs.observer_id, arr); else this.byObserver.delete(obs.observer_id);
|
||||
}
|
||||
}
|
||||
// Skip node index cleanup (expensive, low value)
|
||||
this.stats.evicted++;
|
||||
}
|
||||
}
|
||||
|
||||
/** Insert a new packet (to both memory and SQLite) */
|
||||
insert(packetData) {
|
||||
const id = this.dbModule.insertPacket(packetData);
|
||||
const row = this.dbModule.getPacket(id);
|
||||
if (row && !this.sqliteOnly) {
|
||||
// Update or create transmission in memory
|
||||
let tx = this.byTransmission.get(row.hash);
|
||||
if (!tx) {
|
||||
tx = {
|
||||
id: row.id,
|
||||
raw_hex: row.raw_hex,
|
||||
hash: row.hash,
|
||||
first_seen: row.timestamp,
|
||||
timestamp: row.timestamp,
|
||||
route_type: row.route_type,
|
||||
payload_type: row.payload_type,
|
||||
decoded_json: row.decoded_json,
|
||||
observations: [],
|
||||
observation_count: 0,
|
||||
observer_id: row.observer_id,
|
||||
observer_name: row.observer_name,
|
||||
snr: row.snr,
|
||||
rssi: row.rssi,
|
||||
path_json: row.path_json,
|
||||
direction: row.direction,
|
||||
};
|
||||
this.byTransmission.set(row.hash, tx);
|
||||
this.byHash.set(row.hash, tx);
|
||||
this.packets.unshift(tx); // newest first
|
||||
this._indexByNode(tx);
|
||||
} else {
|
||||
// Update first_seen if earlier
|
||||
if (row.timestamp < tx.first_seen) {
|
||||
tx.first_seen = row.timestamp;
|
||||
tx.timestamp = row.timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
// Add observation
|
||||
const obs = {
|
||||
id: row.id,
|
||||
observer_id: row.observer_id,
|
||||
observer_name: row.observer_name,
|
||||
direction: row.direction,
|
||||
snr: row.snr,
|
||||
rssi: row.rssi,
|
||||
score: row.score,
|
||||
path_json: row.path_json,
|
||||
timestamp: row.timestamp,
|
||||
hash: row.hash,
|
||||
raw_hex: row.raw_hex,
|
||||
payload_type: row.payload_type,
|
||||
decoded_json: row.decoded_json,
|
||||
route_type: row.route_type,
|
||||
};
|
||||
tx.observations.push(obs);
|
||||
tx.observation_count++;
|
||||
|
||||
// Update transmission's display fields if this is first observation
|
||||
if (tx.observations.length === 1) {
|
||||
tx.observer_id = obs.observer_id;
|
||||
tx.observer_name = obs.observer_name;
|
||||
tx.snr = obs.snr;
|
||||
tx.rssi = obs.rssi;
|
||||
tx.path_json = obs.path_json;
|
||||
}
|
||||
|
||||
this.byId.set(obs.id, obs);
|
||||
if (obs.observer_id) {
|
||||
if (!this.byObserver.has(obs.observer_id)) this.byObserver.set(obs.observer_id, []);
|
||||
this.byObserver.get(obs.observer_id).push(obs);
|
||||
}
|
||||
|
||||
this.stats.totalObservations++;
|
||||
this._evict();
|
||||
this.stats.inserts++;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ALL packets referencing a node — by pubkey index + name + pubkey text search.
|
||||
* Returns unique transmissions (deduped).
|
||||
* @param {string} nodeIdOrName - pubkey or friendly name
|
||||
* @param {Array} [fromPackets] - packet array to filter (defaults to this.packets)
|
||||
* @returns {{ packets: Array, pubkey: string, nodeName: string }}
|
||||
*/
|
||||
findPacketsForNode(nodeIdOrName, fromPackets) {
|
||||
let pubkey = nodeIdOrName;
|
||||
let nodeName = nodeIdOrName;
|
||||
|
||||
// Always resolve to get both pubkey and name
|
||||
try {
|
||||
const row = this.db.prepare("SELECT public_key, name FROM nodes WHERE public_key = ? OR name = ? LIMIT 1").get(nodeIdOrName, nodeIdOrName);
|
||||
if (row) { pubkey = row.public_key; nodeName = row.name || nodeIdOrName; }
|
||||
} catch {}
|
||||
|
||||
// Combine: index hits + text search
|
||||
const indexed = this.byNode.get(pubkey);
|
||||
const hashSet = indexed ? new Set(indexed.map(t => t.hash)) : new Set();
|
||||
const source = fromPackets || this.packets;
|
||||
const packets = source.filter(t =>
|
||||
hashSet.has(t.hash) ||
|
||||
(t.decoded_json && (t.decoded_json.includes(nodeName) || t.decoded_json.includes(pubkey)))
|
||||
);
|
||||
|
||||
return { packets, pubkey, nodeName };
|
||||
}
|
||||
|
||||
/** Count transmissions and observations for a node */
|
||||
countForNode(pubkey) {
|
||||
const txs = this.byNode.get(pubkey) || [];
|
||||
let observations = 0;
|
||||
for (const tx of txs) observations += tx.observation_count;
|
||||
return { transmissions: txs.length, observations };
|
||||
}
|
||||
|
||||
/** Query packets with filters — all from memory (or SQLite in fallback mode) */
|
||||
query({ limit = 50, offset = 0, type, route, region, observer, hash, since, until, node, order = 'DESC' } = {}) {
|
||||
this.stats.queries++;
|
||||
|
||||
if (this.sqliteOnly) return this._querySQLite({ limit, offset, type, route, region, observer, hash, since, until, node, order });
|
||||
|
||||
let results = this.packets;
|
||||
|
||||
// Use indexes for single-key filters when possible
|
||||
if (hash && !type && !route && !region && !observer && !since && !until && !node) {
|
||||
const tx = this.byHash.get(hash);
|
||||
results = tx ? [tx] : [];
|
||||
} else if (observer && !type && !route && !region && !hash && !since && !until && !node) {
|
||||
// For observer filter, find unique transmissions where any observation matches
|
||||
results = this._transmissionsForObserver(observer);
|
||||
} else if (node && !type && !route && !region && !observer && !hash && !since && !until) {
|
||||
results = this.findPacketsForNode(node).packets;
|
||||
} else {
|
||||
// Apply filters sequentially
|
||||
if (type !== undefined) {
|
||||
const t = Number(type);
|
||||
results = results.filter(p => p.payload_type === t);
|
||||
}
|
||||
if (route !== undefined) {
|
||||
const r = Number(route);
|
||||
results = results.filter(p => p.route_type === r);
|
||||
}
|
||||
if (observer) results = this._transmissionsForObserver(observer, results);
|
||||
if (hash) {
|
||||
const tx = this.byHash.get(hash);
|
||||
results = tx ? results.filter(p => p.hash === hash) : [];
|
||||
}
|
||||
if (since) results = results.filter(p => p.timestamp > since);
|
||||
if (until) results = results.filter(p => p.timestamp < until);
|
||||
if (region) {
|
||||
const regionObservers = new Set();
|
||||
try {
|
||||
const obs = this.db.prepare('SELECT id FROM observers WHERE iata = ?').all(region);
|
||||
obs.forEach(o => regionObservers.add(o.id));
|
||||
} catch {}
|
||||
results = results.filter(p =>
|
||||
p.observations.some(o => regionObservers.has(o.observer_id))
|
||||
);
|
||||
}
|
||||
if (node) {
|
||||
results = this.findPacketsForNode(node, results).packets;
|
||||
}
|
||||
}
|
||||
|
||||
const total = results.length;
|
||||
|
||||
// Sort
|
||||
if (order === 'ASC') {
|
||||
results = results.slice().sort((a, b) => {
|
||||
if (a.timestamp < b.timestamp) return -1;
|
||||
if (a.timestamp > b.timestamp) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
// Default DESC — packets array is already sorted newest-first
|
||||
|
||||
// Paginate
|
||||
const paginated = results.slice(Number(offset), Number(offset) + Number(limit));
|
||||
return { packets: paginated, total };
|
||||
}
|
||||
|
||||
/** Find unique transmissions that have at least one observation from given observer */
|
||||
_transmissionsForObserver(observerId, fromTransmissions) {
|
||||
if (fromTransmissions) {
|
||||
return fromTransmissions.filter(tx =>
|
||||
tx.observations.some(o => o.observer_id === observerId)
|
||||
);
|
||||
}
|
||||
// Use byObserver index: get observations, then unique transmissions
|
||||
const obs = this.byObserver.get(observerId) || [];
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const o of obs) {
|
||||
if (!seen.has(o.hash)) {
|
||||
seen.add(o.hash);
|
||||
const tx = this.byTransmission.get(o.hash);
|
||||
if (tx) result.push(tx);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Query with groupByHash — now trivial since packets ARE transmissions */
|
||||
queryGrouped({ limit = 50, offset = 0, type, route, region, observer, hash, since, until, node } = {}) {
|
||||
this.stats.queries++;
|
||||
|
||||
if (this.sqliteOnly) return this._queryGroupedSQLite({ limit, offset, type, route, region, observer, hash, since, until, node });
|
||||
|
||||
// Get filtered transmissions
|
||||
const { packets: filtered, total: filteredTotal } = this.query({
|
||||
limit: 999999, offset: 0, type, route, region, observer, hash, since, until, node
|
||||
});
|
||||
|
||||
// Already grouped by hash — just format for backward compat
|
||||
const sorted = filtered.map(tx => ({
|
||||
hash: tx.hash,
|
||||
count: tx.observation_count,
|
||||
observer_count: new Set(tx.observations.map(o => o.observer_id).filter(Boolean)).size,
|
||||
latest: tx.observations.length ? tx.observations.reduce((max, o) => o.timestamp > max ? o.timestamp : max, tx.observations[0].timestamp) : tx.timestamp,
|
||||
observer_id: tx.observer_id,
|
||||
observer_name: tx.observer_name,
|
||||
path_json: tx.path_json,
|
||||
payload_type: tx.payload_type,
|
||||
raw_hex: tx.raw_hex,
|
||||
decoded_json: tx.decoded_json,
|
||||
observation_count: tx.observation_count,
|
||||
})).sort((a, b) => b.latest.localeCompare(a.latest));
|
||||
|
||||
const total = sorted.length;
|
||||
const paginated = sorted.slice(Number(offset), Number(offset) + Number(limit));
|
||||
return { packets: paginated, total };
|
||||
}
|
||||
|
||||
/** Get timestamps for sparkline */
|
||||
getTimestamps(since) {
|
||||
if (this.sqliteOnly) {
|
||||
return this.db.prepare('SELECT timestamp FROM packets WHERE timestamp > ? ORDER BY timestamp ASC').all(since).map(r => r.timestamp);
|
||||
}
|
||||
const results = [];
|
||||
for (const p of this.packets) {
|
||||
if (p.timestamp <= since) break;
|
||||
results.push(p.timestamp);
|
||||
}
|
||||
return results.reverse();
|
||||
}
|
||||
|
||||
/** Get a single packet by ID — checks observation IDs first (backward compat) */
|
||||
getById(id) {
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets WHERE id = ?').get(id) || null;
|
||||
return this.byId.get(id) || null;
|
||||
}
|
||||
|
||||
/** Get all siblings of a packet (same hash) — returns observations array */
|
||||
getSiblings(hash) {
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets WHERE hash = ? ORDER BY timestamp DESC').all(hash);
|
||||
const tx = this.byTransmission.get(hash);
|
||||
return tx ? tx.observations : [];
|
||||
}
|
||||
|
||||
/** Get all transmissions (backward compat — returns packets array) */
|
||||
all() {
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets ORDER BY timestamp DESC').all();
|
||||
return this.packets;
|
||||
}
|
||||
|
||||
/** Get all transmissions matching a filter function */
|
||||
filter(fn) {
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets ORDER BY timestamp DESC').all().filter(fn);
|
||||
return this.packets.filter(fn);
|
||||
}
|
||||
|
||||
/** Memory stats */
|
||||
getStats() {
|
||||
return {
|
||||
...this.stats,
|
||||
inMemory: this.sqliteOnly ? 0 : this.packets.length,
|
||||
sqliteOnly: this.sqliteOnly,
|
||||
maxPackets: this.maxPackets,
|
||||
estimatedMB: this.sqliteOnly ? 0 : Math.round(this.packets.length * this.estPacketBytes / 1024 / 1024),
|
||||
maxMB: Math.round(this.maxBytes / 1024 / 1024),
|
||||
indexes: {
|
||||
byHash: this.byHash.size,
|
||||
byObserver: this.byObserver.size,
|
||||
byNode: this.byNode.size,
|
||||
byTransmission: this.byTransmission.size,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** SQLite fallback: query with filters */
|
||||
_querySQLite({ limit, offset, type, route, region, observer, hash, since, until, node, order }) {
|
||||
const where = []; const params = [];
|
||||
if (type !== undefined) { where.push('payload_type = ?'); params.push(Number(type)); }
|
||||
if (route !== undefined) { where.push('route_type = ?'); params.push(Number(route)); }
|
||||
if (observer) { where.push('observer_id = ?'); params.push(observer); }
|
||||
if (hash) { where.push('hash = ?'); params.push(hash); }
|
||||
if (since) { where.push('timestamp > ?'); params.push(since); }
|
||||
if (until) { where.push('timestamp < ?'); params.push(until); }
|
||||
if (region) { where.push('observer_id IN (SELECT id FROM observers WHERE iata = ?)'); params.push(region); }
|
||||
if (node) { try { const nr = this.db.prepare('SELECT public_key FROM nodes WHERE public_key = ? OR name = ? LIMIT 1').get(node, node); const pk = nr ? nr.public_key : node; where.push('(decoded_json LIKE ? OR id IN (SELECT packet_id FROM paths WHERE node_hash = ?))'); params.push('%' + pk + '%', pk.substring(0, 8)); } catch(e) { where.push('decoded_json LIKE ?'); params.push('%' + node + '%'); } }
|
||||
const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||
const total = this.db.prepare(`SELECT COUNT(*) as c FROM packets ${w}`).get(...params).c;
|
||||
const packets = this.db.prepare(`SELECT * FROM packets ${w} ORDER BY timestamp ${order === 'ASC' ? 'ASC' : 'DESC'} LIMIT ? OFFSET ?`).all(...params, limit, offset);
|
||||
return { packets, total };
|
||||
}
|
||||
|
||||
/** SQLite fallback: grouped query */
|
||||
_queryGroupedSQLite({ limit, offset, type, route, region, observer, hash, since, until, node }) {
|
||||
const where = []; const params = [];
|
||||
if (type !== undefined) { where.push('payload_type = ?'); params.push(Number(type)); }
|
||||
if (route !== undefined) { where.push('route_type = ?'); params.push(Number(route)); }
|
||||
if (observer) { where.push('observer_id = ?'); params.push(observer); }
|
||||
if (hash) { where.push('hash = ?'); params.push(hash); }
|
||||
if (since) { where.push('timestamp > ?'); params.push(since); }
|
||||
if (until) { where.push('timestamp < ?'); params.push(until); }
|
||||
if (region) { where.push('observer_id IN (SELECT id FROM observers WHERE iata = ?)'); params.push(region); }
|
||||
if (node) { try { const nr = this.db.prepare('SELECT public_key FROM nodes WHERE public_key = ? OR name = ? LIMIT 1').get(node, node); const pk = nr ? nr.public_key : node; where.push('(decoded_json LIKE ? OR id IN (SELECT packet_id FROM paths WHERE node_hash = ?))'); params.push('%' + pk + '%', pk.substring(0, 8)); } catch(e) { where.push('decoded_json LIKE ?'); params.push('%' + node + '%'); } }
|
||||
const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||
|
||||
const sql = `SELECT hash, COUNT(*) as count, COUNT(DISTINCT observer_id) as observer_count,
|
||||
MAX(timestamp) as latest, MIN(observer_id) as observer_id, MIN(observer_name) as observer_name,
|
||||
MIN(path_json) as path_json, MIN(payload_type) as payload_type, MIN(raw_hex) as raw_hex,
|
||||
MIN(decoded_json) as decoded_json
|
||||
FROM packets ${w} GROUP BY hash ORDER BY latest DESC LIMIT ? OFFSET ?`;
|
||||
const packets = this.db.prepare(sql).all(...params, limit, offset);
|
||||
|
||||
const countSql = `SELECT COUNT(DISTINCT hash) as c FROM packets ${w}`;
|
||||
const total = this.db.prepare(countSql).get(...params).c;
|
||||
return { packets, total };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PacketStore;
|
||||
@@ -2,17 +2,10 @@
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
let _analyticsData = {};
|
||||
function esc(s) { return s ? String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') : ''; }
|
||||
|
||||
// --- SVG helpers ---
|
||||
function svgLine(points, color, w, h, pad, maxX, maxY) {
|
||||
return points.map((v, i) => {
|
||||
const x = pad + i * ((w - pad * 2) / Math.max(points.length - 1, 1));
|
||||
const y = h - pad - (v / Math.max(maxY, 1)) * (h - pad * 2);
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
function sparkSvg(data, color, w = 120, h = 32) {
|
||||
if (!data.length) return '';
|
||||
const max = Math.max(...data, 1);
|
||||
@@ -21,13 +14,13 @@
|
||||
const y = h - 2 - (v / max) * (h - 4);
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
return `<svg viewBox="0 0 ${w} ${h}" style="width:${w}px;height:${h}px"><polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5"/></svg>`;
|
||||
return `<svg viewBox="0 0 ${w} ${h}" style="width:${w}px;height:${h}px" role="img" aria-label="Sparkline showing trend of ${data.length} data points"><title>Sparkline showing trend of ${data.length} data points</title><polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5"/></svg>`;
|
||||
}
|
||||
|
||||
function barChart(data, labels, colors, w = 800, h = 220, pad = 40) {
|
||||
const max = Math.max(...data, 1);
|
||||
const barW = Math.min((w - pad * 2) / data.length - 2, 30);
|
||||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:${h}px">`;
|
||||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:${h}px" role="img" aria-label="Bar chart showing data distribution"><title>Bar chart showing data distribution</title>`;
|
||||
// Grid
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = pad + (h - pad * 2) * i / 4;
|
||||
@@ -47,7 +40,15 @@
|
||||
return svg;
|
||||
}
|
||||
|
||||
function histogram(values, bins, color, w = 800, h = 180) {
|
||||
function histogram(data, bins, color, w = 800, h = 180) {
|
||||
// Support pre-computed histogram from server { bins: [{x, w, count}], min, max }
|
||||
if (data && data.bins && Array.isArray(data.bins)) {
|
||||
const buckets = data.bins.map(b => b.count);
|
||||
const labels = data.bins.map(b => b.x.toFixed(1));
|
||||
return { svg: barChart(buckets, labels, color, w, h), buckets, labels };
|
||||
}
|
||||
// Legacy: raw values array
|
||||
const values = data;
|
||||
const min = Math.min(...values), max = Math.max(...values);
|
||||
const step = (max - min) / bins;
|
||||
const buckets = Array(bins).fill(0);
|
||||
@@ -72,6 +73,7 @@
|
||||
<button class="tab-btn" data-tab="hashsizes">Hash Stats</button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div id="analyticsContent" class="analytics-content">
|
||||
@@ -80,7 +82,9 @@
|
||||
</div>`;
|
||||
|
||||
// Tab handling
|
||||
document.getElementById('analyticsTabs').addEventListener('click', e => {
|
||||
const analyticsTabs = document.getElementById('analyticsTabs');
|
||||
initTabBar(analyticsTabs);
|
||||
analyticsTabs.addEventListener('click', e => {
|
||||
const btn = e.target.closest('.tab-btn');
|
||||
if (!btn) return;
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
@@ -88,39 +92,54 @@
|
||||
renderTab(btn.dataset.tab);
|
||||
});
|
||||
|
||||
// Delegated click/keyboard handler for clickable table rows
|
||||
const analyticsContent = document.getElementById('analyticsContent');
|
||||
if (analyticsContent) {
|
||||
const handler = (e) => {
|
||||
const row = e.target.closest('tr[data-action="navigate"]');
|
||||
if (!row) return;
|
||||
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
|
||||
if (e.type === 'keydown') e.preventDefault();
|
||||
location.hash = row.dataset.value;
|
||||
};
|
||||
analyticsContent.addEventListener('click', handler);
|
||||
analyticsContent.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
try {
|
||||
window._analyticsData = {};
|
||||
_analyticsData = {};
|
||||
const [hashData, rfData, topoData, chanData] = await Promise.all([
|
||||
api('/analytics/hash-sizes'),
|
||||
api('/analytics/rf'),
|
||||
api('/analytics/topology'),
|
||||
api('/analytics/channels'),
|
||||
api('/analytics/hash-sizes', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/rf', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/topology', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/channels', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
]);
|
||||
window._analyticsData = { hashData, rfData, topoData, chanData };
|
||||
_analyticsData = { hashData, rfData, topoData, chanData };
|
||||
renderTab('overview');
|
||||
} catch (e) {
|
||||
document.getElementById('analyticsContent').innerHTML =
|
||||
`<div class="text-muted" style="padding:40px">Failed to load: ${e.message}</div>`;
|
||||
`<div class="text-muted" role="alert" aria-live="polite" style="padding:40px">Failed to load: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTab(tab) {
|
||||
async function renderTab(tab) {
|
||||
const el = document.getElementById('analyticsContent');
|
||||
const d = window._analyticsData;
|
||||
const d = _analyticsData;
|
||||
switch (tab) {
|
||||
case 'overview': renderOverview(el, d); break;
|
||||
case 'rf': renderRF(el, d.rfData); break;
|
||||
case 'topology': renderTopology(el, d.topoData); break;
|
||||
case 'channels': renderChannels(el, d.chanData); break;
|
||||
case 'hashsizes': renderHashSizes(el, d.hashData); break;
|
||||
case 'collisions': renderCollisionTab(el, d.hashData); break;
|
||||
case 'subpaths': renderSubpaths(el); break;
|
||||
case 'collisions': await renderCollisionTab(el, d.hashData); break;
|
||||
case 'subpaths': await renderSubpaths(el); break;
|
||||
case 'nodes': await renderNodesTab(el); break;
|
||||
}
|
||||
// Auto-apply column resizing to all analytics tables
|
||||
requestAnimationFrame(() => {
|
||||
el.querySelectorAll('.analytics-table').forEach((tbl, i) => {
|
||||
tbl.id = tbl.id || `analytics-tbl-${tab}-${i}`;
|
||||
makeColumnsResizable('#' + tbl.id, `meshcore-analytics-${tab}-${i}-col-widths`);
|
||||
if (typeof makeColumnsResizable === 'function') makeColumnsResizable('#' + tbl.id, `meshcore-analytics-${tab}-${i}-col-widths`);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -131,10 +150,14 @@
|
||||
el.innerHTML = `
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${rf.totalPackets.toLocaleString()}</div>
|
||||
<div class="stat-label">Total Packets</div>
|
||||
<div class="stat-value">${(rf.totalTransmissions || rf.totalAllPackets || rf.totalPackets).toLocaleString()}</div>
|
||||
<div class="stat-label">Total Transmissions</div>
|
||||
<div class="stat-spark">${sparkSvg(rf.packetsPerHour.map(h=>h.count), 'var(--accent)')}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${rf.totalPackets.toLocaleString()}</div>
|
||||
<div class="stat-label">Observations with Signal</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${topo.uniqueNodes}</div>
|
||||
<div class="stat-label">Unique Nodes</div>
|
||||
@@ -274,7 +297,7 @@
|
||||
function renderScatter(data) {
|
||||
const w = 600, h = 300, pad = 40;
|
||||
const snrMin = -12, snrMax = 15, rssiMin = -130, rssiMax = -5;
|
||||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:300px">`;
|
||||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:300px" role="img" aria-label="SNR vs RSSI scatter plot showing signal quality distribution"><title>SNR vs RSSI scatter plot showing signal quality distribution</title>`;
|
||||
// Axes
|
||||
svg += `<line x1="${pad}" y1="${h-pad}" x2="${w-pad}" y2="${h-pad}" stroke="var(--text-muted)" stroke-width="0.5"/>`;
|
||||
svg += `<line x1="${pad}" y1="${pad}" x2="${pad}" y2="${h-pad}" stroke="var(--text-muted)" stroke-width="0.5"/>`;
|
||||
@@ -295,12 +318,23 @@
|
||||
{ label: 'Good', snr: [0, 6], rssi: [-100, -80], color: '#f59e0b15' },
|
||||
{ label: 'Weak', snr: [-12, 0], rssi: [-130, -100], color: '#ef444410' },
|
||||
];
|
||||
// Define patterns for color-blind accessibility
|
||||
svg += `<defs>`;
|
||||
svg += `<pattern id="pat-excellent" patternUnits="userSpaceOnUse" width="8" height="8"><line x1="0" y1="8" x2="8" y2="0" stroke="#22c55e" stroke-width="0.5" opacity="0.4"/></pattern>`;
|
||||
svg += `<pattern id="pat-good" patternUnits="userSpaceOnUse" width="6" height="6"><circle cx="3" cy="3" r="1" fill="#f59e0b" opacity="0.4"/></pattern>`;
|
||||
svg += `<pattern id="pat-weak" patternUnits="userSpaceOnUse" width="8" height="8"><line x1="0" y1="0" x2="8" y2="8" stroke="#ef4444" stroke-width="0.5" opacity="0.4"/><line x1="0" y1="8" x2="8" y2="0" stroke="#ef4444" stroke-width="0.5" opacity="0.4"/></pattern>`;
|
||||
svg += `</defs>`;
|
||||
const zonePatterns = { 'Excellent': 'pat-excellent', 'Good': 'pat-good', 'Weak': 'pat-weak' };
|
||||
const zoneDash = { 'Excellent': '4,2', 'Good': '6,3', 'Weak': '2,2' };
|
||||
const zoneBorder = { 'Excellent': '#22c55e', 'Good': '#f59e0b', 'Weak': '#ef4444' };
|
||||
zones.forEach(z => {
|
||||
const x1 = pad + (z.snr[0] - snrMin) / (snrMax - snrMin) * (w - pad * 2);
|
||||
const x2 = pad + (z.snr[1] - snrMin) / (snrMax - snrMin) * (w - pad * 2);
|
||||
const y1 = h - pad - (z.rssi[1] - rssiMin) / (rssiMax - rssiMin) * (h - pad * 2);
|
||||
const y2 = h - pad - (z.rssi[0] - rssiMin) / (rssiMax - rssiMin) * (h - pad * 2);
|
||||
svg += `<rect x="${x1}" y="${y1}" width="${x2-x1}" height="${y2-y1}" fill="${z.color}"/>`;
|
||||
svg += `<rect x="${x1}" y="${y1}" width="${x2-x1}" height="${y2-y1}" fill="url(#${zonePatterns[z.label]})"/>`;
|
||||
svg += `<rect x="${x1}" y="${y1}" width="${x2-x1}" height="${y2-y1}" fill="none" stroke="${zoneBorder[z.label]}" stroke-width="1" stroke-dasharray="${zoneDash[z.label]}" opacity="0.6"/>`;
|
||||
svg += `<text x="${x1+4}" y="${y1+12}" font-size="9" fill="var(--text-muted)" opacity="0.7">${z.label}</text>`;
|
||||
});
|
||||
// Dots (sample if too many)
|
||||
@@ -336,8 +370,7 @@
|
||||
if (!data.length) return '<div class="text-muted">No data</div>';
|
||||
const w = 400, h = 160, pad = 35;
|
||||
const maxPkts = Math.max(...data.map(d => d.count), 1);
|
||||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:160px">`;
|
||||
// SNR line
|
||||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:160px" role="img" aria-label="Signal quality over time showing SNR trend and packet volume"><title>Signal quality over time showing SNR trend and packet volume</title>`;
|
||||
const snrPts = data.map((d, i) => {
|
||||
const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1));
|
||||
const y = h - pad - ((d.avgSnr + 12) / 27) * (h - pad * 2);
|
||||
@@ -427,6 +460,7 @@
|
||||
// Observer selector event handling
|
||||
const selector = document.getElementById('obsSelector');
|
||||
if (selector) {
|
||||
initTabBar(selector);
|
||||
selector.addEventListener('click', e => {
|
||||
const btn = e.target.closest('.tab-btn');
|
||||
if (!btn) return;
|
||||
@@ -471,7 +505,7 @@
|
||||
if (!data.length) return '<div class="text-muted">No data</div>';
|
||||
const w = 380, h = 160, pad = 40;
|
||||
const maxHop = Math.max(...data.map(d => d.hops));
|
||||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:160px">`;
|
||||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:160px" role="img" aria-label="Hops vs SNR bubble chart showing signal degradation over distance"><title>Hops vs SNR bubble chart showing signal degradation over distance</title>`;
|
||||
data.forEach(d => {
|
||||
const x = pad + (d.hops / maxHop) * (w - pad * 2);
|
||||
const y = h - pad - ((d.avgSnr + 12) / 27) * (h - pad * 2);
|
||||
@@ -568,7 +602,7 @@
|
||||
<table class="analytics-table">
|
||||
<thead><tr><th>Channel</th><th>Hash</th><th>Messages</th><th>Unique Senders</th><th>Last Activity</th><th>Decrypted</th></tr></thead>
|
||||
<tbody>
|
||||
${ch.channels.map(c => `<tr class="clickable-row" onclick="location.hash='#/channels?ch=${c.hash}'">
|
||||
${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>${c.messages}</td>
|
||||
@@ -604,7 +638,7 @@
|
||||
const channels = [...new Set(data.map(d => d.channel))];
|
||||
const colors = ['#ef4444','#22c55e','#3b82f6','#f59e0b','#8b5cf6','#ec4899','#14b8a6','#64748b'];
|
||||
const w = 600, h = 180, pad = 35;
|
||||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:180px">`;
|
||||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:180px" role="img" aria-label="Channel message activity over time"><title>Channel message activity over time</title>`;
|
||||
channels.forEach((ch, ci) => {
|
||||
const pts = hours.map((hr, i) => {
|
||||
const entry = data.find(d => d.hour === hr && d.channel === ch);
|
||||
@@ -679,7 +713,7 @@
|
||||
<table class="analytics-table">
|
||||
<thead><tr><th>Node</th><th>Hash Size</th><th>Adverts</th><th>Last Seen</th></tr></thead>
|
||||
<tbody>
|
||||
${data.multiByteNodes.map(n => `<tr class="clickable-row" onclick="location.hash='#/nodes/${n.pubkey ? encodeURIComponent(n.pubkey) : ''}'">
|
||||
${data.multiByteNodes.map(n => `<tr class="clickable-row" data-action="navigate" data-value="#/nodes/${n.pubkey ? encodeURIComponent(n.pubkey) : ''}" tabindex="0" role="row">
|
||||
<td><strong>${esc(n.name)}</strong></td>
|
||||
<td><span class="badge badge-hash-${n.hashSize}">${n.hashSize}-byte</span></td>
|
||||
<td>${n.packets}</td>
|
||||
@@ -697,7 +731,7 @@
|
||||
<tbody>
|
||||
${data.topHops.map(h => {
|
||||
const link = h.pubkey ? `#/nodes/${encodeURIComponent(h.pubkey)}` : `#/packets?search=${h.hex}`;
|
||||
return `<tr class="clickable-row" onclick="location.hash='${link}'">
|
||||
return `<tr class="clickable-row" data-action="navigate" data-value="${link}" tabindex="0" role="row">
|
||||
<td class="mono">${h.hex}</td>
|
||||
<td>${h.name ? `<strong>${esc(h.name)}</strong>` : '<span class="text-muted">unknown</span>'}</td>
|
||||
<td><span class="badge badge-hash-${h.size}">${h.size}-byte</span></td>
|
||||
@@ -711,7 +745,7 @@
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCollisionTab(el, data) {
|
||||
async function renderCollisionTab(el, data) {
|
||||
el.innerHTML = `
|
||||
<div class="analytics-card">
|
||||
<h3>1-Byte Hash Usage Matrix</h3>
|
||||
@@ -724,8 +758,10 @@
|
||||
<div id="collisionList"><div class="text-muted" style="padding:8px">Loading…</div></div>
|
||||
</div>
|
||||
`;
|
||||
renderHashMatrix(data.topHops);
|
||||
renderCollisions(data.topHops);
|
||||
let allNodes = [];
|
||||
try { const nd = await api('/nodes?limit=2000', { ttl: CLIENT_TTL.nodeList }); allNodes = nd.nodes || []; } catch {}
|
||||
renderHashMatrix(data.topHops, allNodes);
|
||||
renderCollisions(data.topHops, allNodes);
|
||||
}
|
||||
|
||||
function renderHashTimeline(hourly) {
|
||||
@@ -733,7 +769,7 @@
|
||||
const w = 800, h = 180, pad = 35;
|
||||
const maxVal = Math.max(...hourly.map(h => Math.max(h[1] || 0, h[2] || 0, h[3] || 0)), 1);
|
||||
const colors = { 1: '#ef4444', 2: '#22c55e', 3: '#3b82f6' };
|
||||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:180px">`;
|
||||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:180px" role="img" aria-label="Hash size distribution over time showing 1-byte, 2-byte, and 3-byte hash trends"><title>Hash size distribution over time showing 1-byte, 2-byte, and 3-byte hash trends</title>`;
|
||||
for (const size of [1, 2, 3]) {
|
||||
const pts = hourly.map((d, i) => {
|
||||
const x = pad + i * ((w - pad * 2) / Math.max(hourly.length - 1, 1));
|
||||
@@ -752,16 +788,9 @@
|
||||
return svg;
|
||||
}
|
||||
|
||||
async function renderHashMatrix(topHops) {
|
||||
async function renderHashMatrix(topHops, allNodes) {
|
||||
const el = document.getElementById('hashMatrix');
|
||||
|
||||
// Fetch all nodes for lookup
|
||||
let allNodes = [];
|
||||
try {
|
||||
const nd = await api('/nodes?limit=2000');
|
||||
allNodes = nd.nodes || [];
|
||||
} catch {}
|
||||
|
||||
// Build prefix → node count map
|
||||
const prefixNodes = {};
|
||||
for (let i = 0; i < 256; i++) {
|
||||
@@ -773,7 +802,7 @@
|
||||
const cellSize = 36;
|
||||
const headerSize = 24;
|
||||
|
||||
let html = `<div style="display:flex;gap:16px;flex-wrap:wrap"><div style="overflow-x:auto"><table style="border-collapse:collapse;font-size:0.7em;font-family:monospace">`;
|
||||
let html = `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
|
||||
html += `<tr><td style="width:${headerSize}px"></td>`;
|
||||
for (const n of nibbles) {
|
||||
html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
|
||||
@@ -788,27 +817,29 @@
|
||||
const count = nodes.length;
|
||||
let bg, color;
|
||||
if (count === 0) {
|
||||
bg = '#166534'; color = '#86efac'; // green — available
|
||||
bg = 'var(--card-bg)'; color = 'var(--text-muted)'; // empty — subtle
|
||||
} else if (count === 1) {
|
||||
bg = '#854d0e'; color = '#fde047'; // yellow — taken, no collision
|
||||
bg = '#dcfce7'; color = '#166534'; // light green — taken, no collision
|
||||
} else {
|
||||
// 2+ nodes: interpolate orange→red
|
||||
// 2+ nodes: orange→red
|
||||
const t = Math.min((count - 2) / 4, 1);
|
||||
const g = Math.round(80 * (1 - t));
|
||||
bg = `rgb(200,${g},30)`; color = '#fff';
|
||||
const r = Math.round(220 + 35 * t);
|
||||
const g = Math.round(120 * (1 - t));
|
||||
bg = `rgb(${r},${g},30)`; color = '#fff';
|
||||
}
|
||||
const status = count === 0 ? 'available' : count === 1 ? `1 node: ${nodes[0].name || nodes[0].public_key.slice(0,12)}` : `${count} nodes — COLLISION`;
|
||||
html += `<td class="hash-cell${count ? ' hash-active' : ''}" data-hex="${hex}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;background:${bg};color:${color};border:1px solid var(--border);cursor:${count ? 'pointer' : 'default'};font-size:0.85em" title="0x${hex}: ${status}">${hex}</td>`;
|
||||
const cellText = count === 0 ? `<span style="font-size:11px">${hex}</span>` : count >= 2 ? `<strong>${count >= 3 ? '3+' : count}</strong>` : String(count);
|
||||
html += `<td class="hash-cell${count ? ' hash-active' : ''}" data-hex="${hex}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;background:${bg};color:${color};border:1px solid var(--border);cursor:${count ? 'pointer' : 'default'};font-size:13px;font-weight:${count >= 2 ? '700' : '400'}" title="0x${hex}: ${status}">${cellText}</td>`;
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</table></div>';
|
||||
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:400px;font-size:0.85em"></div></div>
|
||||
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center">
|
||||
<span><span style="display:inline-block;width:12px;height:12px;background:#166534;border:1px solid var(--border);vertical-align:middle"></span> Available</span>
|
||||
<span><span style="display:inline-block;width:12px;height:12px;background:#854d0e;border:1px solid var(--border);vertical-align:middle"></span> 1 node</span>
|
||||
<span><span style="display:inline-block;width:12px;height:12px;background:rgb(200,80,30);border:1px solid var(--border);vertical-align:middle"></span> 2 nodes</span>
|
||||
<span><span style="display:inline-block;width:12px;height:12px;background:rgb(200,0,30);border:1px solid var(--border);vertical-align:middle"></span> 3+ nodes (collision)</span>
|
||||
<span><span class="legend-swatch" style="background:var(--card-bg);border:1px solid var(--border)"></span> 0 — Available</span>
|
||||
<span><span class="legend-swatch" style="background:#dcfce7"></span> 1 — One node</span>
|
||||
<span><span class="legend-swatch" style="background:rgb(200,80,30)"></span> 2 — Two nodes (collision)</span>
|
||||
<span><span class="legend-swatch" style="background:rgb(200,0,30)"></span> 3+ — Three+ nodes (collision)</span>
|
||||
</div>`;
|
||||
el.innerHTML = html;
|
||||
|
||||
@@ -836,13 +867,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function renderCollisions(topHops) {
|
||||
async function renderCollisions(topHops, allNodes) {
|
||||
const el = document.getElementById('collisionList');
|
||||
const oneByteHops = topHops.filter(h => h.size === 1);
|
||||
if (!oneByteHops.length) { el.innerHTML = '<div class="text-muted">No 1-byte hops</div>'; return; }
|
||||
try {
|
||||
const nodesData = await api('/nodes?limit=2000');
|
||||
const nodes = nodesData.nodes || [];
|
||||
const nodes = allNodes;
|
||||
const collisions = [];
|
||||
for (const hop of oneByteHops) {
|
||||
const prefix = hop.hex.toLowerCase();
|
||||
@@ -872,8 +902,8 @@
|
||||
}
|
||||
if (!collisions.length) { el.innerHTML = '<div class="text-muted" style="padding:8px">No collisions detected</div>'; return; }
|
||||
|
||||
// Sort: distant first (most interesting), then regional, local, incomplete
|
||||
const classOrder = { distant: 0, regional: 1, local: 2, incomplete: 3, unknown: 4 };
|
||||
// Sort: local first (most likely to collide), then regional, distant, incomplete
|
||||
const classOrder = { local: 0, regional: 1, distant: 2, incomplete: 3, unknown: 4 };
|
||||
collisions.sort((a, b) => classOrder[a.classification] - classOrder[b.classification] || b.count - a.count);
|
||||
|
||||
el.innerHTML = `<table class="analytics-table">
|
||||
@@ -920,10 +950,10 @@
|
||||
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">Analyzing route patterns…</div>';
|
||||
try {
|
||||
const [d2, d3, d4, d5] = await Promise.all([
|
||||
api('/analytics/subpaths?minLen=2&maxLen=2&limit=50'),
|
||||
api('/analytics/subpaths?minLen=3&maxLen=3&limit=30'),
|
||||
api('/analytics/subpaths?minLen=4&maxLen=4&limit=20'),
|
||||
api('/analytics/subpaths?minLen=5&maxLen=8&limit=15')
|
||||
api('/analytics/subpaths?minLen=2&maxLen=2&limit=50', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/subpaths?minLen=3&maxLen=3&limit=30', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/subpaths?minLen=4&maxLen=4&limit=20', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/subpaths?minLen=5&maxLen=8&limit=15', { ttl: CLIENT_TTL.analyticsRF })
|
||||
]);
|
||||
|
||||
function renderTable(data, title) {
|
||||
@@ -958,7 +988,7 @@
|
||||
<h3>🛤️ Route Pattern Analysis</h3>
|
||||
<p>Click a route to see details. Most common subpaths — reveals backbone routes, bottlenecks, and preferred relay chains.</p>
|
||||
<label style="display:inline-flex;align-items:center;gap:6px;margin-bottom:12px;cursor:pointer;font-size:0.9em">
|
||||
<input type="checkbox" id="hideCollisions" ${localStorage.getItem('subpath-hide-collisions') === '1' ? 'checked' : ''}> Hide likely prefix collisions (self-loops)
|
||||
<input type="checkbox" id="hideCollisions" aria-label="Hide likely prefix collisions" ${localStorage.getItem('subpath-hide-collisions') === '1' ? 'checked' : ''}> Hide likely prefix collisions (self-loops)
|
||||
</label>
|
||||
<div class="subpath-jump-nav">
|
||||
<span>Jump to:</span>
|
||||
@@ -1014,7 +1044,7 @@
|
||||
panel.classList.remove('collapsed');
|
||||
panel.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
|
||||
try {
|
||||
const data = await api('/analytics/subpath-detail?hops=' + encodeURIComponent(hopsStr));
|
||||
const data = await api('/analytics/subpath-detail?hops=' + encodeURIComponent(hopsStr), { ttl: CLIENT_TTL.analyticsRF });
|
||||
renderSubpathDetail(panel, data);
|
||||
} catch (e) {
|
||||
panel.innerHTML = `<div class="text-muted">Error: ${e.message}</div>`;
|
||||
@@ -1099,7 +1129,7 @@
|
||||
// Render minimap
|
||||
if (hasMap && typeof L !== 'undefined') {
|
||||
const map = L.map('subpathMap', { zoomControl: false, attributionControl: false });
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 18 }).addTo(map);
|
||||
L.tileLayer(getTileUrl(), { maxZoom: 18 }).addTo(map);
|
||||
|
||||
const latlngs = [];
|
||||
nodesWithLoc.forEach((n, i) => {
|
||||
@@ -1119,7 +1149,154 @@
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() { delete window._analyticsData; }
|
||||
async function renderNodesTab(el) {
|
||||
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Loading node analytics…</div>';
|
||||
try {
|
||||
const [nodesResp, bulkHealth, netStatus] = await Promise.all([
|
||||
api('/nodes?limit=200&sortBy=lastSeen', { ttl: CLIENT_TTL.nodeList }),
|
||||
api('/nodes/bulk-health?limit=50', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/nodes/network-status', { ttl: CLIENT_TTL.analyticsRF })
|
||||
]);
|
||||
const nodes = nodesResp.nodes || nodesResp;
|
||||
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
|
||||
const myKeys = new Set(myNodes.map(n => n.pubkey));
|
||||
|
||||
// Map bulk health by pubkey
|
||||
const healthMap = {};
|
||||
bulkHealth.forEach(h => { healthMap[h.public_key] = h; });
|
||||
const enriched = nodes.filter(n => healthMap[n.public_key]).map(n => ({ ...n, health: { stats: healthMap[n.public_key].stats, observers: healthMap[n.public_key].observers } }));
|
||||
|
||||
// Compute rankings
|
||||
const byPackets = [...enriched].sort((a, b) => (b.health.stats.totalTransmissions || b.health.stats.totalPackets || 0) - (a.health.stats.totalTransmissions || a.health.stats.totalPackets || 0));
|
||||
const bySnr = [...enriched].filter(n => n.health.stats.avgSnr != null).sort((a, b) => b.health.stats.avgSnr - a.health.stats.avgSnr);
|
||||
const byObservers = [...enriched].sort((a, b) => (b.health.observers?.length || 0) - (a.health.observers?.length || 0));
|
||||
const byRecent = [...enriched].filter(n => n.health.stats.lastHeard).sort((a, b) => new Date(b.health.stats.lastHeard) - new Date(a.health.stats.lastHeard));
|
||||
|
||||
// Use server-computed status across ALL nodes
|
||||
const { active, degraded, silent, total: totalNodes, roleCounts } = netStatus;
|
||||
|
||||
function nodeLink(n) {
|
||||
return `<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">${esc(n.name || n.public_key.slice(0, 12))}</a>`;
|
||||
}
|
||||
function claimedBadge(n) {
|
||||
return myKeys.has(n.public_key) ? ' <span style="color:var(--accent);font-size:10px">★ MINE</span>' : '';
|
||||
}
|
||||
|
||||
// ROLE_COLORS from shared roles.js
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="analytics-section">
|
||||
<h3>🔍 Network Status</h3>
|
||||
<div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:20px">
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
|
||||
<div style="font-size:28px;font-weight:700;color:#22c55e">${active}</div>
|
||||
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">🟢 Active</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
|
||||
<div style="font-size:28px;font-weight:700;color:#eab308">${degraded}</div>
|
||||
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">🟡 Degraded</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
|
||||
<div style="font-size:28px;font-weight:700;color:#ef4444">${silent}</div>
|
||||
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">🔴 Silent</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
|
||||
<div style="font-size:28px;font-weight:700">${totalNodes}</div>
|
||||
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">Total Nodes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>📊 Role Breakdown</h3>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:24px">
|
||||
${Object.entries(roleCounts).sort((a,b) => b[1]-a[1]).map(([role, count]) => {
|
||||
const c = ROLE_COLORS[role] || '#6b7280';
|
||||
return `<span class="badge" style="background:${c}20;color:${c};padding:6px 12px;font-size:13px">${role}: ${count}</span>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
|
||||
${myKeys.size ? `<h3>⭐ My Claimed Nodes</h3>
|
||||
<table class="analytics-table" style="margin-bottom:24px">
|
||||
<thead><tr><th>Node</th><th>Role</th><th>Packets</th><th>Avg SNR</th><th>Observers</th><th>Last Heard</th></tr></thead>
|
||||
<tbody>
|
||||
${enriched.filter(n => myKeys.has(n.public_key)).map(n => {
|
||||
const s = n.health.stats;
|
||||
return `<tr>
|
||||
<td>${nodeLink(n)}</td>
|
||||
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
|
||||
<td>${s.totalTransmissions || s.totalPackets || 0}</td>
|
||||
<td>${s.avgSnr != null ? s.avgSnr.toFixed(1) + ' dB' : '—'}</td>
|
||||
<td>${n.health.observers?.length || 0}</td>
|
||||
<td>${s.lastHeard ? timeAgo(s.lastHeard) : '—'}</td>
|
||||
</tr>`;
|
||||
}).join('') || '<tr><td colspan="6" class="text-muted">No claimed nodes have health data</td></tr>'}
|
||||
</tbody>
|
||||
</table>` : ''}
|
||||
|
||||
<h3>🏆 Most Active Nodes</h3>
|
||||
<table class="analytics-table" style="margin-bottom:24px">
|
||||
<thead><tr><th>#</th><th>Node</th><th>Role</th><th>Total Packets</th><th>Packets Today</th><th>Analytics</th></tr></thead>
|
||||
<tbody>
|
||||
${byPackets.slice(0, 15).map((n, i) => `<tr>
|
||||
<td>${i + 1}</td>
|
||||
<td>${nodeLink(n)}${claimedBadge(n)}</td>
|
||||
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
|
||||
<td>${n.health.stats.totalTransmissions || n.health.stats.totalPackets || 0}</td>
|
||||
<td>${n.health.stats.packetsToday || 0}</td>
|
||||
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">📊</a></td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>📶 Best Signal Quality</h3>
|
||||
<table class="analytics-table" style="margin-bottom:24px">
|
||||
<thead><tr><th>#</th><th>Node</th><th>Role</th><th>Avg SNR</th><th>Observers</th><th>Analytics</th></tr></thead>
|
||||
<tbody>
|
||||
${bySnr.slice(0, 15).map((n, i) => `<tr>
|
||||
<td>${i + 1}</td>
|
||||
<td>${nodeLink(n)}${claimedBadge(n)}</td>
|
||||
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
|
||||
<td>${n.health.stats.avgSnr.toFixed(1)} dB</td>
|
||||
<td>${n.health.observers?.length || 0}</td>
|
||||
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">📊</a></td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>👀 Most Observed Nodes</h3>
|
||||
<table class="analytics-table" style="margin-bottom:24px">
|
||||
<thead><tr><th>#</th><th>Node</th><th>Role</th><th>Observers</th><th>Avg SNR</th><th>Analytics</th></tr></thead>
|
||||
<tbody>
|
||||
${byObservers.slice(0, 15).map((n, i) => `<tr>
|
||||
<td>${i + 1}</td>
|
||||
<td>${nodeLink(n)}${claimedBadge(n)}</td>
|
||||
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
|
||||
<td>${n.health.observers?.length || 0}</td>
|
||||
<td>${n.health.stats.avgSnr != null ? n.health.stats.avgSnr.toFixed(1) + ' dB' : '—'}</td>
|
||||
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">📊</a></td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>⏰ Recently Active</h3>
|
||||
<table class="analytics-table" style="margin-bottom:24px">
|
||||
<thead><tr><th>Node</th><th>Role</th><th>Last Heard</th><th>Packets Today</th><th>Analytics</th></tr></thead>
|
||||
<tbody>
|
||||
${byRecent.slice(0, 15).map(n => `<tr>
|
||||
<td>${nodeLink(n)}${claimedBadge(n)}</td>
|
||||
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
|
||||
<td>${timeAgo(n.health.stats.lastHeard)}</td>
|
||||
<td>${n.health.stats.packetsToday || 0}</td>
|
||||
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">📊</a></td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div style="padding:40px;text-align:center;color:#ff6b6b">Failed to load node analytics: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() { _analyticsData = {}; }
|
||||
|
||||
registerPage('analytics', { init, destroy });
|
||||
})();
|
||||
|
||||
@@ -11,12 +11,81 @@ function payloadTypeName(n) { return PAYLOAD_TYPES[n] || 'UNKNOWN'; }
|
||||
function payloadTypeColor(n) { return PAYLOAD_COLORS[n] || 'unknown'; }
|
||||
|
||||
// --- Utilities ---
|
||||
async function api(path) {
|
||||
const res = await fetch('/api' + path);
|
||||
if (!res.ok) throw new Error(`API ${res.status}: ${path}`);
|
||||
return res.json();
|
||||
const _apiPerf = { calls: 0, totalMs: 0, log: [], cacheHits: 0 };
|
||||
const _apiCache = new Map();
|
||||
const _inflight = new Map();
|
||||
// Client-side TTLs (ms) — loaded from server config, with defaults
|
||||
const CLIENT_TTL = {
|
||||
stats: 10000, nodeDetail: 240000, nodeHealth: 240000, nodeList: 90000,
|
||||
bulkHealth: 300000, networkStatus: 300000, observers: 120000,
|
||||
channels: 15000, channelMessages: 10000, analyticsRF: 300000,
|
||||
analyticsTopology: 300000, analyticsChannels: 300000, analyticsHashSizes: 300000,
|
||||
analyticsSubpaths: 300000, analyticsSubpathDetail: 300000,
|
||||
nodeAnalytics: 60000, nodeSearch: 10000
|
||||
};
|
||||
// Fetch server cache config and use as client TTLs (server values are in seconds)
|
||||
fetch('/api/config/cache').then(r => r.json()).then(cfg => {
|
||||
for (const [k, v] of Object.entries(cfg)) {
|
||||
if (k in CLIENT_TTL && typeof v === 'number') CLIENT_TTL[k] = v * 1000;
|
||||
}
|
||||
}).catch(() => {});
|
||||
async function api(path, { ttl = 0, bust = false } = {}) {
|
||||
const t0 = performance.now();
|
||||
if (!bust && ttl > 0) {
|
||||
const cached = _apiCache.get(path);
|
||||
if (cached && Date.now() < cached.expires) {
|
||||
_apiPerf.calls++;
|
||||
_apiPerf.cacheHits++;
|
||||
_apiPerf.log.push({ path, ms: 0, time: Date.now(), cached: true });
|
||||
if (_apiPerf.log.length > 200) _apiPerf.log.shift();
|
||||
return cached.data;
|
||||
}
|
||||
}
|
||||
// Deduplicate in-flight requests
|
||||
if (_inflight.has(path)) return _inflight.get(path);
|
||||
const promise = (async () => {
|
||||
const res = await fetch('/api' + path);
|
||||
if (!res.ok) throw new Error(`API ${res.status}: ${path}`);
|
||||
const data = await res.json();
|
||||
const ms = performance.now() - t0;
|
||||
_apiPerf.calls++;
|
||||
_apiPerf.totalMs += ms;
|
||||
_apiPerf.log.push({ path, ms: Math.round(ms), time: Date.now() });
|
||||
if (_apiPerf.log.length > 200) _apiPerf.log.shift();
|
||||
if (ms > 500) console.warn(`[SLOW API] ${path} took ${Math.round(ms)}ms`);
|
||||
if (ttl > 0) _apiCache.set(path, { data, expires: Date.now() + ttl });
|
||||
return data;
|
||||
})();
|
||||
_inflight.set(path, promise);
|
||||
promise.finally(() => _inflight.delete(path));
|
||||
return promise;
|
||||
}
|
||||
|
||||
function invalidateApiCache(prefix) {
|
||||
for (const key of _apiCache.keys()) {
|
||||
if (key.startsWith(prefix || '')) _apiCache.delete(key);
|
||||
}
|
||||
}
|
||||
// Expose for console debugging: apiPerf()
|
||||
window.apiPerf = function() {
|
||||
const byPath = {};
|
||||
_apiPerf.log.forEach(e => {
|
||||
if (!byPath[e.path]) byPath[e.path] = { count: 0, totalMs: 0, maxMs: 0 };
|
||||
byPath[e.path].count++;
|
||||
byPath[e.path].totalMs += e.ms;
|
||||
if (e.ms > byPath[e.path].maxMs) byPath[e.path].maxMs = e.ms;
|
||||
});
|
||||
const rows = Object.entries(byPath).map(([p, s]) => ({
|
||||
path: p, count: s.count, avgMs: Math.round(s.totalMs / s.count), maxMs: s.maxMs,
|
||||
totalMs: Math.round(s.totalMs)
|
||||
})).sort((a, b) => b.totalMs - a.totalMs);
|
||||
console.table(rows);
|
||||
const hitRate = _apiPerf.calls ? Math.round(_apiPerf.cacheHits / _apiPerf.calls * 100) : 0;
|
||||
const misses = _apiPerf.calls - _apiPerf.cacheHits;
|
||||
console.log(`Cache: ${_apiPerf.cacheHits} hits / ${misses} misses (${hitRate}% hit rate)`);
|
||||
return { calls: _apiPerf.calls, avgMs: Math.round(_apiPerf.totalMs / (misses || 1)), cacheHits: _apiPerf.cacheHits, cacheMisses: misses, cacheHitRate: hitRate, endpoints: rows };
|
||||
};
|
||||
|
||||
function timeAgo(iso) {
|
||||
if (!iso) return '—';
|
||||
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
@@ -140,6 +209,15 @@ function connectWS() {
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
// Debounce cache invalidation — don't nuke on every packet
|
||||
if (!api._invalidateTimer) {
|
||||
api._invalidateTimer = setTimeout(() => {
|
||||
api._invalidateTimer = null;
|
||||
invalidateApiCache('/stats');
|
||||
invalidateApiCache('/nodes');
|
||||
invalidateApiCache('/channels');
|
||||
}, 5000);
|
||||
}
|
||||
wsListeners.forEach(fn => fn(msg));
|
||||
} catch {}
|
||||
};
|
||||
@@ -148,6 +226,38 @@ function connectWS() {
|
||||
function onWS(fn) { wsListeners.push(fn); }
|
||||
function offWS(fn) { wsListeners = wsListeners.filter(f => f !== fn); }
|
||||
|
||||
/* Global escapeHtml — used by multiple pages */
|
||||
function escapeHtml(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
/* Global debounce */
|
||||
function debounce(fn, ms) {
|
||||
let t;
|
||||
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
||||
}
|
||||
|
||||
/* Debounced WS helper — batches rapid messages, calls fn with array of msgs */
|
||||
function debouncedOnWS(fn, ms) {
|
||||
if (typeof ms === 'undefined') ms = 250;
|
||||
let pending = [];
|
||||
let timer = null;
|
||||
function handler(msg) {
|
||||
pending.push(msg);
|
||||
if (!timer) {
|
||||
timer = setTimeout(function () {
|
||||
const batch = pending;
|
||||
pending = [];
|
||||
timer = null;
|
||||
fn(batch);
|
||||
}, ms);
|
||||
}
|
||||
}
|
||||
onWS(handler);
|
||||
return handler; // caller stores this to pass to offWS() in destroy
|
||||
}
|
||||
|
||||
// --- Router ---
|
||||
const pages = {};
|
||||
|
||||
@@ -168,6 +278,21 @@ function navigate() {
|
||||
routeParam = decodeURIComponent(route.substring(slashIdx + 1));
|
||||
}
|
||||
|
||||
// Special route: nodes/PUBKEY/analytics → node-analytics page
|
||||
if (basePage === 'nodes' && routeParam && routeParam.endsWith('/analytics')) {
|
||||
basePage = 'node-analytics';
|
||||
}
|
||||
|
||||
// Special route: packet/123 → standalone packet detail page
|
||||
if (basePage === 'packet' && routeParam) {
|
||||
basePage = 'packet-detail';
|
||||
}
|
||||
|
||||
// Special route: observers/ID → observer detail page
|
||||
if (basePage === 'observers' && routeParam) {
|
||||
basePage = 'observer-detail';
|
||||
}
|
||||
|
||||
// Update nav active state
|
||||
document.querySelectorAll('.nav-link[data-route]').forEach(el => {
|
||||
el.classList.toggle('active', el.dataset.route === basePage);
|
||||
@@ -180,7 +305,10 @@ function navigate() {
|
||||
|
||||
const app = document.getElementById('app');
|
||||
if (pages[basePage]?.init) {
|
||||
const t0 = performance.now();
|
||||
pages[basePage].init(app, routeParam);
|
||||
const ms = performance.now() - t0;
|
||||
if (ms > 100) console.warn(`[SLOW PAGE] ${basePage} init took ${Math.round(ms)}ms`);
|
||||
app.classList.remove('page-enter'); void app.offsetWidth; app.classList.add('page-enter');
|
||||
} else {
|
||||
app.innerHTML = `<div style="padding:40px;text-align:center;color:#6b7280"><h2>${route}</h2><p>Page not yet implemented.</p></div>`;
|
||||
@@ -253,9 +381,9 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
favDropdown.innerHTML = '<div class="fav-dd-loading">Loading...</div>';
|
||||
const items = await Promise.all(favs.map(async (pk) => {
|
||||
try {
|
||||
const h = await api('/nodes/' + pk + '/health');
|
||||
const h = await api('/nodes/' + pk + '/health', { ttl: CLIENT_TTL.nodeHealth });
|
||||
const age = h.stats.lastHeard ? Date.now() - new Date(h.stats.lastHeard).getTime() : null;
|
||||
const status = age === null ? '🔴' : age < 3600000 ? '🟢' : age < 86400000 ? '🟡' : '🔴';
|
||||
const status = age === null ? '🔴' : age < HEALTH_THRESHOLDS.nodeDegradedMs ? '🟢' : age < HEALTH_THRESHOLDS.nodeSilentMs ? '🟡' : '🔴';
|
||||
return '<a href="#/nodes/' + pk + '" class="fav-dd-item" data-key="' + pk + '">'
|
||||
+ '<span class="fav-dd-status">' + status + '</span>'
|
||||
+ '<span class="fav-dd-name">' + (h.node.name || truncate(pk, 12)) + '</span>'
|
||||
@@ -341,7 +469,7 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
const chList = Array.isArray(channels) ? channels : [];
|
||||
for (const c of chList) {
|
||||
if (c.name && c.name.toLowerCase().includes(q.toLowerCase())) {
|
||||
html += `<div class="search-result-item" onclick="location.hash='#/channels?ch=${c.channel_hash}';document.getElementById('searchOverlay').classList.add('hidden')">
|
||||
html += `<div class="search-result-item" onclick="location.hash='#/channels/${c.channel_hash}';document.getElementById('searchOverlay').classList.add('hidden')">
|
||||
<span class="search-result-type">Channel</span>${c.name}</div>`;
|
||||
}
|
||||
}
|
||||
@@ -357,7 +485,7 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
// --- Nav Stats ---
|
||||
async function updateNavStats() {
|
||||
try {
|
||||
const stats = await api('/stats');
|
||||
const stats = await api('/stats', { ttl: CLIENT_TTL.stats });
|
||||
const el = document.getElementById('navStats');
|
||||
if (el) {
|
||||
el.innerHTML = `<span class="stat-val">${stats.totalPackets}</span> pkts · <span class="stat-val">${stats.totalNodes}</span> nodes · <span class="stat-val">${stats.totalObservers}</span> obs`;
|
||||
@@ -368,12 +496,60 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
updateNavStats();
|
||||
setInterval(updateNavStats, 15000);
|
||||
onWS(() => updateNavStats());
|
||||
debouncedOnWS(function () { updateNavStats(); });
|
||||
|
||||
if (!location.hash || location.hash === '#/') location.hash = '#/home';
|
||||
else navigate();
|
||||
});
|
||||
|
||||
/**
|
||||
* Reusable ARIA tab-bar initialiser.
|
||||
* Adds role="tablist" to container, role="tab" + aria-selected to each button,
|
||||
* and arrow-key navigation between tabs.
|
||||
* @param {HTMLElement} container - the tab bar element
|
||||
* @param {Function} [onChange] - optional callback(activeBtn) on tab change
|
||||
*/
|
||||
function initTabBar(container, onChange) {
|
||||
if (!container || container.getAttribute('role') === 'tablist') return;
|
||||
container.setAttribute('role', 'tablist');
|
||||
const tabs = Array.from(container.querySelectorAll('button, [data-tab], [data-obs]'));
|
||||
tabs.forEach(btn => {
|
||||
btn.setAttribute('role', 'tab');
|
||||
const isActive = btn.classList.contains('active');
|
||||
btn.setAttribute('aria-selected', String(isActive));
|
||||
btn.setAttribute('tabindex', isActive ? '0' : '-1');
|
||||
// Link to panel if aria-controls target exists
|
||||
const panelId = btn.dataset.tab || btn.dataset.obs;
|
||||
if (panelId && document.getElementById(panelId)) {
|
||||
btn.setAttribute('aria-controls', panelId);
|
||||
}
|
||||
});
|
||||
container.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[role="tab"]');
|
||||
if (!btn || !container.contains(btn)) return;
|
||||
tabs.forEach(b => { b.setAttribute('aria-selected', 'false'); b.setAttribute('tabindex', '-1'); });
|
||||
btn.setAttribute('aria-selected', 'true');
|
||||
btn.setAttribute('tabindex', '0');
|
||||
if (onChange) onChange(btn);
|
||||
});
|
||||
container.addEventListener('keydown', (e) => {
|
||||
const btn = e.target.closest('[role="tab"]');
|
||||
if (!btn) return;
|
||||
let idx = tabs.indexOf(btn), next = -1;
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (idx + 1) % tabs.length;
|
||||
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (idx - 1 + tabs.length) % tabs.length;
|
||||
else if (e.key === 'Home') next = 0;
|
||||
else if (e.key === 'End') next = tabs.length - 1;
|
||||
if (next < 0) return;
|
||||
e.preventDefault();
|
||||
tabs.forEach(b => { b.setAttribute('aria-selected', 'false'); b.setAttribute('tabindex', '-1'); });
|
||||
tabs[next].setAttribute('aria-selected', 'true');
|
||||
tabs[next].setAttribute('tabindex', '0');
|
||||
tabs[next].focus();
|
||||
tabs[next].click();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Make table columns resizable with drag handles. Widths saved to localStorage.
|
||||
* Call after table is in DOM. Re-call safe (idempotent per table).
|
||||
@@ -398,6 +574,20 @@ function makeColumnsResizable(tableSelector, storageKey) {
|
||||
|
||||
if (saved) {
|
||||
try { widths = JSON.parse(saved); } catch { widths = null; }
|
||||
// Validate: must be array of correct length with values summing to ~100 (percentages)
|
||||
if (widths && Array.isArray(widths) && widths.length === ths.length) {
|
||||
const sum = widths.reduce((s, w) => s + w, 0);
|
||||
if (sum > 90 && sum < 110) {
|
||||
// Saved percentages — apply directly
|
||||
table.style.tableLayout = 'fixed';
|
||||
table.style.width = '100%';
|
||||
ths.forEach((th, i) => { th.style.width = widths[i] + '%'; });
|
||||
// Skip measurement, jump to adding handles
|
||||
addResizeHandles();
|
||||
return;
|
||||
}
|
||||
}
|
||||
widths = null; // Force remeasure
|
||||
}
|
||||
|
||||
if (!widths) {
|
||||
@@ -464,9 +654,13 @@ function makeColumnsResizable(tableSelector, storageKey) {
|
||||
topN.forEach(x => { finalWidths[x.i] += Math.round(surplus * (x.w / topTotal)); });
|
||||
}
|
||||
|
||||
table.style.width = containerW + 'px';
|
||||
ths.forEach((th, i) => { th.style.width = finalWidths[i] + 'px'; });
|
||||
table.style.width = '100%';
|
||||
const totalFinal = finalWidths.reduce((s, w) => s + w, 0);
|
||||
ths.forEach((th, i) => { th.style.width = (finalWidths[i] / totalFinal * 100) + '%'; });
|
||||
|
||||
addResizeHandles();
|
||||
|
||||
function addResizeHandles() {
|
||||
// Add resize handles
|
||||
ths.forEach((th, i) => {
|
||||
if (i === ths.length - 1) return;
|
||||
@@ -485,16 +679,28 @@ function makeColumnsResizable(tableSelector, storageKey) {
|
||||
|
||||
function onMove(e2) {
|
||||
const dx = e2.clientX - startX;
|
||||
const newW = Math.max(30, startW + dx);
|
||||
const newW = Math.max(50, startW + dx);
|
||||
const delta = newW - th.offsetWidth;
|
||||
if (delta === 0) return;
|
||||
// Steal/give space from columns to the right, proportionally
|
||||
const rightThs = ths.slice(i + 1);
|
||||
const rightWidths = rightThs.map(t => t.offsetWidth);
|
||||
const rightTotal = rightWidths.reduce((s, w) => s + w, 0);
|
||||
if (rightTotal - delta < rightThs.length * 50) return; // can't squeeze below 50px each
|
||||
th.style.width = newW + 'px';
|
||||
table.style.width = (startTableW + (newW - startW)) + 'px';
|
||||
const scale = (rightTotal - delta) / rightTotal;
|
||||
rightThs.forEach(t => { t.style.width = Math.max(50, t.offsetWidth * scale) + 'px'; });
|
||||
}
|
||||
function onUp() {
|
||||
handle.classList.remove('active');
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
const ws = ths.map(t => t.offsetWidth);
|
||||
// Save as percentages
|
||||
const tableW = table.offsetWidth;
|
||||
const ws = ths.map(t => (t.offsetWidth / tableW * 100));
|
||||
localStorage.setItem(storageKey, JSON.stringify(ws));
|
||||
// Re-apply as percentages
|
||||
ths.forEach((t, j) => { t.style.width = ws[j] + '%'; });
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
}
|
||||
@@ -503,4 +709,5 @@ function makeColumnsResizable(tableSelector, storageKey) {
|
||||
});
|
||||
th.appendChild(handle);
|
||||
});
|
||||
} // end addResizeHandles
|
||||
}
|
||||
|
||||
@@ -9,18 +9,23 @@
|
||||
let autoScroll = true;
|
||||
let nodeCache = {};
|
||||
let selectedNode = null;
|
||||
var _nodeCacheTTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
async function lookupNode(name) {
|
||||
if (nodeCache[name] !== undefined) return nodeCache[name];
|
||||
var cached = nodeCache[name];
|
||||
if (cached !== undefined) {
|
||||
if (cached && cached.fetchedAt && (Date.now() - cached.fetchedAt < _nodeCacheTTL)) return cached.data;
|
||||
if (cached && !cached.fetchedAt) return cached; // legacy null entries
|
||||
}
|
||||
try {
|
||||
const data = await api('/nodes/search?q=' + encodeURIComponent(name));
|
||||
const data = await api('/nodes/search?q=' + encodeURIComponent(name), { ttl: CLIENT_TTL.channelMessages });
|
||||
// Try exact match first, then case-insensitive, then contains
|
||||
const nodes = data.nodes || [];
|
||||
const match = nodes.find(n => n.name === name)
|
||||
|| nodes.find(n => n.name && n.name.toLowerCase() === name.toLowerCase())
|
||||
|| nodes.find(n => n.name && n.name.toLowerCase().includes(name.toLowerCase()))
|
||||
|| nodes[0] || null;
|
||||
nodeCache[name] = match;
|
||||
nodeCache[name] = { data: match, fetchedAt: Date.now() };
|
||||
return match;
|
||||
} catch { nodeCache[name] = null; return null; }
|
||||
}
|
||||
@@ -34,24 +39,52 @@
|
||||
const tip = document.createElement('div');
|
||||
tip.id = 'chNodeTooltip';
|
||||
tip.className = 'ch-node-tooltip';
|
||||
const role = node.is_repeater ? '📡 Repeater' : node.is_room ? '🏠 Room' : node.is_sensor ? '🌡 Sensor' : '📻 Companion';
|
||||
tip.setAttribute('role', 'tooltip');
|
||||
const roleKey = node.role || (node.is_repeater ? 'repeater' : node.is_room ? 'room' : node.is_sensor ? 'sensor' : 'companion');
|
||||
const role = (ROLE_EMOJI[roleKey] || '●') + ' ' + (ROLE_LABELS[roleKey] || roleKey);
|
||||
const lastSeen = node.last_seen ? timeAgo(node.last_seen) : 'unknown';
|
||||
tip.innerHTML = `<div class="ch-tooltip-name">${escapeHtml(node.name)}</div>
|
||||
<div class="ch-tooltip-role">${role}</div>
|
||||
<div class="ch-tooltip-meta">Last seen: ${lastSeen}</div>
|
||||
<div class="ch-tooltip-key mono">${(node.public_key || '').slice(0, 16)}…</div>`;
|
||||
document.body.appendChild(tip);
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
var trigger = e.target.closest('[data-node]') || e.target;
|
||||
trigger.setAttribute('aria-describedby', 'chNodeTooltip');
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
tip.style.left = Math.min(rect.left, window.innerWidth - 220) + 'px';
|
||||
tip.style.top = (rect.bottom + 4) + 'px';
|
||||
}
|
||||
|
||||
function hideNodeTooltip() {
|
||||
var trigger = document.querySelector('[aria-describedby="chNodeTooltip"]');
|
||||
if (trigger) trigger.removeAttribute('aria-describedby');
|
||||
const tip = document.getElementById('chNodeTooltip');
|
||||
if (tip) tip.remove();
|
||||
}
|
||||
|
||||
let _focusTrapCleanup = null;
|
||||
let _nodePanelTrigger = null;
|
||||
|
||||
function trapFocus(container) {
|
||||
function handler(e) {
|
||||
if (e.key === 'Escape') { closeNodeDetail(); return; }
|
||||
if (e.key !== 'Tab') return;
|
||||
const focusable = container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
if (!focusable.length) return;
|
||||
const first = focusable[0], last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
} else {
|
||||
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
}
|
||||
container.addEventListener('keydown', handler);
|
||||
return function () { container.removeEventListener('keydown', handler); };
|
||||
}
|
||||
|
||||
async function showNodeDetail(name) {
|
||||
_nodePanelTrigger = document.activeElement;
|
||||
if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; }
|
||||
const node = await lookupNode(name);
|
||||
selectedNode = name;
|
||||
|
||||
@@ -67,24 +100,27 @@
|
||||
if (!node) {
|
||||
panel.innerHTML = `<div class="ch-node-panel-header">
|
||||
<strong>${escapeHtml(name)}</strong>
|
||||
<button class="ch-node-close" onclick="window._chCloseNode()" aria-label="Close">✕</button>
|
||||
<button class="ch-node-close" data-action="ch-close-node" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="ch-node-panel-body">
|
||||
<div class="ch-node-field" style="color:var(--text-muted)">No node record found — this sender has only been seen in channel messages, not via adverts.</div>
|
||||
</div>`;
|
||||
_focusTrapCleanup = trapFocus(panel);
|
||||
panel.querySelector('.ch-node-close')?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const detail = await api('/nodes/' + encodeURIComponent(node.public_key));
|
||||
const detail = await api('/nodes/' + encodeURIComponent(node.public_key), { ttl: CLIENT_TTL.nodeDetail });
|
||||
const n = detail.node;
|
||||
const adverts = detail.recentAdverts || [];
|
||||
const role = n.is_repeater ? '📡 Repeater' : n.is_room ? '🏠 Room' : n.is_sensor ? '🌡 Sensor' : '📻 Companion';
|
||||
const roleKey = n.role || (n.is_repeater ? 'repeater' : n.is_room ? 'room' : n.is_sensor ? 'sensor' : 'companion');
|
||||
const role = (ROLE_EMOJI[roleKey] || '●') + ' ' + (ROLE_LABELS[roleKey] || roleKey);
|
||||
const lastSeen = n.last_seen ? timeAgo(n.last_seen) : 'unknown';
|
||||
|
||||
panel.innerHTML = `<div class="ch-node-panel-header">
|
||||
<strong>${escapeHtml(n.name || 'Unknown')}</strong>
|
||||
<button class="ch-node-close" onclick="window._chCloseNode()" aria-label="Close">✕</button>
|
||||
<button class="ch-node-close" data-action="ch-close-node" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="ch-node-panel-body">
|
||||
<div class="ch-node-field"><span class="ch-node-label">Role</span> ${role}</div>
|
||||
@@ -97,25 +133,33 @@
|
||||
</div>` : ''}
|
||||
<a href="#/nodes/${n.public_key}" class="ch-node-link">View full node detail →</a>
|
||||
</div>`;
|
||||
_focusTrapCleanup = trapFocus(panel);
|
||||
panel.querySelector('.ch-node-close')?.focus();
|
||||
} catch (e) {
|
||||
panel.innerHTML = `<div class="ch-node-panel-header"><strong>${escapeHtml(name)}</strong><button class="ch-node-close" onclick="window._chCloseNode()">✕</button></div><div class="ch-node-panel-body ch-empty">Failed to load</div>`;
|
||||
panel.innerHTML = `<div class="ch-node-panel-header"><strong>${escapeHtml(name)}</strong><button class="ch-node-close" data-action="ch-close-node">✕</button></div><div class="ch-node-panel-body ch-empty">Failed to load</div>`;
|
||||
_focusTrapCleanup = trapFocus(panel);
|
||||
panel.querySelector('.ch-node-close')?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeNodeDetail() {
|
||||
if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; }
|
||||
const panel = document.getElementById('chNodePanel');
|
||||
if (panel) panel.classList.remove('open');
|
||||
selectedNode = null;
|
||||
if (_nodePanelTrigger && typeof _nodePanelTrigger.focus === 'function') {
|
||||
_nodePanelTrigger.focus();
|
||||
_nodePanelTrigger = null;
|
||||
}
|
||||
}
|
||||
|
||||
window._chShowNode = showNodeDetail;
|
||||
window._chCloseNode = closeNodeDetail;
|
||||
window._chHoverNode = showNodeTooltip;
|
||||
window._chUnhoverNode = hideNodeTooltip;
|
||||
window._chBack = function() {
|
||||
function chBack() {
|
||||
closeNodeDetail();
|
||||
document.querySelector('.ch-layout')?.classList.remove('ch-show-main');
|
||||
};
|
||||
var layout = document.querySelector('.ch-layout');
|
||||
if (layout) layout.classList.remove('ch-show-main');
|
||||
var sidebar = document.querySelector('.ch-sidebar');
|
||||
if (sidebar) sidebar.style.pointerEvents = '';
|
||||
}
|
||||
|
||||
// WCAG AA compliant colors — ≥4.5:1 contrast on both white and dark backgrounds
|
||||
// Channel badge colors (white text on colored background)
|
||||
@@ -165,33 +209,78 @@
|
||||
if (!text) return '';
|
||||
return escapeHtml(text).replace(/@\[([^\]]+)\]/g, function(_, name) {
|
||||
const safeId = btoa(encodeURIComponent(name));
|
||||
return '<span class="ch-mention ch-sender-link" data-node="' + safeId + '">@' + name + '</span>';
|
||||
return '<span class="ch-mention ch-sender-link" tabindex="0" role="button" data-node="' + safeId + '">@' + name + '</span>';
|
||||
});
|
||||
}
|
||||
|
||||
function init(app) {
|
||||
function init(app, routeParam) {
|
||||
app.innerHTML = `<div class="ch-layout">
|
||||
<div class="ch-sidebar" role="navigation" aria-label="Channel list">
|
||||
<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 class="ch-channel-list" id="chList">
|
||||
<div class="ch-channel-list" id="chList" role="listbox" aria-label="Channels">
|
||||
<div class="ch-loading">Loading channels…</div>
|
||||
</div>
|
||||
<div class="ch-sidebar-resize" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="ch-main" role="region" aria-label="Channel messages">
|
||||
<div class="ch-main-header" id="chHeader">
|
||||
<button class="ch-back-btn" id="chBackBtn" aria-label="Back to channels" onclick="window._chBack()">←</button>
|
||||
<button class="ch-back-btn" id="chBackBtn" aria-label="Back to channels" data-action="ch-back">←</button>
|
||||
<span class="ch-header-text">Select a channel</span>
|
||||
</div>
|
||||
<div class="ch-messages" id="chMessages">
|
||||
<div class="ch-empty">Choose a channel from the sidebar to view messages</div>
|
||||
</div>
|
||||
<button class="ch-scroll-btn hidden" id="chScrollBtn" aria-live="polite">↓ New messages</button>
|
||||
<span id="chAriaLive" class="sr-only" aria-live="polite"></span>
|
||||
<button class="ch-scroll-btn hidden" id="chScrollBtn">↓ New messages</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
loadChannels();
|
||||
loadChannels().then(() => {
|
||||
if (routeParam) selectChannel(routeParam);
|
||||
});
|
||||
|
||||
// #89: Sidebar resize handle
|
||||
(function () {
|
||||
var sidebar = app.querySelector('.ch-sidebar');
|
||||
var handle = app.querySelector('.ch-sidebar-resize');
|
||||
var saved = localStorage.getItem('channels-sidebar-width');
|
||||
if (saved) { var w = parseInt(saved, 10); if (w >= 180 && w <= 600) { sidebar.style.width = w + 'px'; sidebar.style.minWidth = w + 'px'; } }
|
||||
var dragging = false, startX, startW;
|
||||
handle.addEventListener('mousedown', function (e) { dragging = true; startX = e.clientX; startW = sidebar.getBoundingClientRect().width; e.preventDefault(); });
|
||||
document.addEventListener('mousemove', function (e) { if (!dragging) return; var w = Math.max(180, Math.min(600, startW + e.clientX - startX)); sidebar.style.width = w + 'px'; sidebar.style.minWidth = w + 'px'; });
|
||||
document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; localStorage.setItem('channels-sidebar-width', parseInt(sidebar.style.width, 10)); });
|
||||
})();
|
||||
|
||||
// #90: Theme change observer — re-render messages on theme toggle
|
||||
var _themeObserver = new MutationObserver(function (muts) {
|
||||
for (var i = 0; i < muts.length; i++) {
|
||||
if (muts[i].attributeName === 'data-theme') { if (selectedHash) renderMessages(); break; }
|
||||
}
|
||||
});
|
||||
_themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
|
||||
// #87: Fix pointer-events during mobile slide transition
|
||||
var chMain = app.querySelector('.ch-main');
|
||||
var chSidebar = app.querySelector('.ch-sidebar');
|
||||
chMain.addEventListener('transitionend', function () {
|
||||
var layout = app.querySelector('.ch-layout');
|
||||
if (layout && layout.classList.contains('ch-show-main')) {
|
||||
chSidebar.style.pointerEvents = 'none';
|
||||
} else {
|
||||
chSidebar.style.pointerEvents = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Event delegation for data-action buttons
|
||||
app.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
var action = btn.dataset.action;
|
||||
if (action === 'ch-close-node') closeNodeDetail();
|
||||
else if (action === 'ch-back') chBack();
|
||||
});
|
||||
|
||||
// Event delegation for channel selection (touch-friendly)
|
||||
document.getElementById('chList').addEventListener('click', (e) => {
|
||||
@@ -218,6 +307,18 @@
|
||||
closeNodeDetail();
|
||||
}
|
||||
}
|
||||
// Keyboard support for data-node elements (Bug #82)
|
||||
msgEl.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
const el = e.target.closest('[data-node]');
|
||||
if (el) {
|
||||
e.preventDefault();
|
||||
const name = decodeURIComponent(atob(el.dataset.node));
|
||||
showNodeDetail(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
msgEl.addEventListener('click', handleNodeTap);
|
||||
// touchend fires more reliably on mobile for non-button elements
|
||||
let touchMoved = false;
|
||||
@@ -249,18 +350,33 @@
|
||||
hoverTimeout = setTimeout(hideNodeTooltip, 100);
|
||||
}
|
||||
});
|
||||
// #86: Show tooltip on focus for keyboard users
|
||||
msgEl.addEventListener('focusin', (e) => {
|
||||
const el = e.target.closest('[data-node]');
|
||||
if (el) {
|
||||
clearTimeout(hoverTimeout);
|
||||
const name = decodeURIComponent(atob(el.dataset.node));
|
||||
showNodeTooltip(e, name);
|
||||
}
|
||||
});
|
||||
msgEl.addEventListener('focusout', (e) => {
|
||||
const el = e.target.closest('[data-node]');
|
||||
if (el) {
|
||||
hoverTimeout = setTimeout(hideNodeTooltip, 100);
|
||||
}
|
||||
});
|
||||
|
||||
wsHandler = (msg) => {
|
||||
const isMessage = msg.type === 'message';
|
||||
const isChannelPacket = msg.type === 'packet' && msg.data?.decoded?.header?.payloadTypeName === 'GRP_TXT';
|
||||
if (isMessage || isChannelPacket) {
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
var dominated = msgs.some(function (m) {
|
||||
return m.type === 'message' || (m.type === 'packet' && m.data?.decoded?.header?.payloadTypeName === 'GRP_TXT');
|
||||
});
|
||||
if (dominated) {
|
||||
loadChannels(true);
|
||||
if (selectedHash) {
|
||||
refreshMessages();
|
||||
}
|
||||
}
|
||||
};
|
||||
onWS(wsHandler);
|
||||
});
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
@@ -277,7 +393,7 @@
|
||||
|
||||
async function loadChannels(silent) {
|
||||
try {
|
||||
const data = await api('/channels');
|
||||
const data = await api('/channels', { ttl: CLIENT_TTL.channels });
|
||||
channels = (data.channels || []).sort((a, b) => (b.lastActivity || '').localeCompare(a.lastActivity || ''));
|
||||
renderChannelList();
|
||||
} catch (e) {
|
||||
@@ -311,7 +427,7 @@
|
||||
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" aria-label="${escapeHtml(name)}">
|
||||
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)}">
|
||||
<div class="ch-badge" style="background:${color}" aria-hidden="true">${escapeHtml(abbr)}</div>
|
||||
<div class="ch-item-body">
|
||||
<div class="ch-item-top">
|
||||
@@ -326,6 +442,7 @@
|
||||
|
||||
async function selectChannel(hash) {
|
||||
selectedHash = hash;
|
||||
history.replaceState(null, '', `#/channels/${hash}`);
|
||||
renderChannelList();
|
||||
const ch = channels.find(c => c.hash === hash);
|
||||
const name = ch?.name || `Channel ${hash}`;
|
||||
@@ -339,7 +456,7 @@
|
||||
msgEl.innerHTML = '<div class="ch-loading">Loading messages…</div>';
|
||||
|
||||
try {
|
||||
const data = await api(`/channels/${hash}/messages?limit=200`);
|
||||
const data = await api(`/channels/${hash}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
|
||||
messages = data.messages || [];
|
||||
renderMessages();
|
||||
scrollToBottom();
|
||||
@@ -354,17 +471,19 @@
|
||||
if (!msgEl) return;
|
||||
const wasAtBottom = msgEl.scrollHeight - msgEl.scrollTop - msgEl.clientHeight < 60;
|
||||
try {
|
||||
const data = await api(`/channels/${selectedHash}/messages?limit=200`);
|
||||
const data = await api(`/channels/${selectedHash}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
|
||||
const newMsgs = data.messages || [];
|
||||
// Compare last message timestamp instead of count — count stays same at limit
|
||||
const lastOld = messages.length ? messages[messages.length - 1]?.timestamp : null;
|
||||
const lastNew = newMsgs.length ? newMsgs[newMsgs.length - 1]?.timestamp : null;
|
||||
if (newMsgs.length === messages.length && lastOld === lastNew) return;
|
||||
// #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 || '') : ''; };
|
||||
if (newMsgs.length === messages.length && _getLastId(newMsgs) === _getLastId(messages)) return;
|
||||
var prevLen = messages.length;
|
||||
messages = newMsgs;
|
||||
renderMessages();
|
||||
if (wasAtBottom) scrollToBottom();
|
||||
else {
|
||||
document.getElementById('chScrollBtn')?.classList.remove('hidden');
|
||||
var liveEl = document.getElementById('chAriaLive');
|
||||
if (liveEl) liveEl.textContent = Math.max(1, newMsgs.length - prevLen) + ' new messages';
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -398,9 +517,9 @@
|
||||
|
||||
const safeId = btoa(encodeURIComponent(sender));
|
||||
return `<div class="ch-msg">
|
||||
<div class="ch-avatar ch-tappable" style="background:${senderColor}" data-node="${safeId}">${senderLetter}</div>
|
||||
<div class="ch-avatar ch-tappable" style="background:${senderColor}" tabindex="0" role="button" data-node="${safeId}">${senderLetter}</div>
|
||||
<div class="ch-msg-content">
|
||||
<div class="ch-msg-sender ch-sender-link ch-tappable" style="color:${senderColor}" data-node="${safeId}">${escapeHtml(sender)}</div>
|
||||
<div class="ch-msg-sender ch-sender-link ch-tappable" style="color:${senderColor}" tabindex="0" role="button" data-node="${safeId}">${escapeHtml(sender)}</div>
|
||||
<div class="ch-msg-bubble">${displayText}</div>
|
||||
<div class="ch-msg-meta">${meta.join(' · ')}${msg.packetId ? ` · <a href="#/packets/id/${msg.packetId}" class="ch-analyze-link">View packet →</a>` : ''}</div>
|
||||
</div>
|
||||
@@ -413,6 +532,5 @@
|
||||
if (msgEl) { msgEl.scrollTop = msgEl.scrollHeight; autoScroll = true; document.getElementById('chScrollBtn')?.classList.add('hidden'); }
|
||||
}
|
||||
|
||||
window._chSelect = selectChannel;
|
||||
registerPage('channels', { init, destroy });
|
||||
})();
|
||||
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#1a1a2e"/>
|
||||
<circle cx="16" cy="8" r="3" fill="#00d4ff"/>
|
||||
<circle cx="7" cy="22" r="3" fill="#00d4ff"/>
|
||||
<circle cx="25" cy="22" r="3" fill="#00d4ff"/>
|
||||
<circle cx="16" cy="18" r="2" fill="#00ff88"/>
|
||||
<line x1="16" y1="8" x2="7" y2="22" stroke="#00d4ff" stroke-width="1" opacity="0.6"/>
|
||||
<line x1="16" y1="8" x2="25" y2="22" stroke="#00d4ff" stroke-width="1" opacity="0.6"/>
|
||||
<line x1="7" y1="22" x2="25" y2="22" stroke="#00d4ff" stroke-width="1" opacity="0.6"/>
|
||||
<line x1="16" y1="8" x2="16" y2="18" stroke="#00ff88" stroke-width="1" opacity="0.5"/>
|
||||
<line x1="7" y1="22" x2="16" y2="18" stroke="#00ff88" stroke-width="1" opacity="0.5"/>
|
||||
<line x1="25" y1="22" x2="16" y2="18" stroke="#00ff88" stroke-width="1" opacity="0.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 851 B |
@@ -4,6 +4,7 @@
|
||||
(function () {
|
||||
let searchTimeout = null;
|
||||
let miniMap = null;
|
||||
let searchAbort = null; // AbortController for document-level listeners
|
||||
|
||||
const PREF_KEY = 'meshcore-user-level';
|
||||
const MY_NODES_KEY = 'meshcore-my-nodes'; // [{pubkey, name, addedAt}]
|
||||
@@ -67,8 +68,8 @@
|
||||
<h1>${hasNodes ? 'My Mesh' : 'MeshCore Analyzer'}</h1>
|
||||
<p>${hasNodes ? 'Your nodes at a glance. Add more by searching below.' : 'Find your nodes to start monitoring them.'}</p>
|
||||
<div class="home-search-wrap">
|
||||
<input type="text" id="homeSearch" placeholder="Search by node name or public key…" autocomplete="off" aria-label="Search nodes">
|
||||
<div class="home-suggest" id="homeSuggest"></div>
|
||||
<input type="text" id="homeSearch" placeholder="Search by node name or public key…" autocomplete="off" aria-label="Search nodes" role="combobox" aria-expanded="false" aria-owns="homeSuggest" aria-autocomplete="list" aria-activedescendant="">
|
||||
<div class="home-suggest" id="homeSuggest" role="listbox"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -122,7 +123,15 @@
|
||||
|
||||
// Checklist accordion
|
||||
container.querySelectorAll('.checklist-q').forEach(q => {
|
||||
q.addEventListener('click', () => q.parentElement.classList.toggle('open'));
|
||||
const toggle = () => {
|
||||
const item = q.parentElement;
|
||||
item.classList.toggle('open');
|
||||
q.setAttribute('aria-expanded', item.classList.contains('open'));
|
||||
};
|
||||
q.addEventListener('click', toggle);
|
||||
q.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -134,17 +143,17 @@
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(searchTimeout);
|
||||
const q = input.value.trim();
|
||||
if (!q) { suggest.classList.remove('open'); return; }
|
||||
if (!q) { suggest.classList.remove('open'); input.setAttribute('aria-expanded', 'false'); input.setAttribute('aria-activedescendant', ''); return; }
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const data = await api('/nodes/search?q=' + encodeURIComponent(q));
|
||||
const data = await api('/nodes/search?q=' + encodeURIComponent(q), { ttl: CLIENT_TTL.nodeSearch });
|
||||
const nodes = data.nodes || [];
|
||||
if (!nodes.length) {
|
||||
suggest.innerHTML = '<div class="suggest-empty">No nodes found</div>';
|
||||
} else {
|
||||
suggest.innerHTML = nodes.slice(0, 10).map(n => {
|
||||
suggest.innerHTML = nodes.slice(0, 10).map((n, idx) => {
|
||||
const claimed = isMyNode(n.public_key);
|
||||
return `<div class="suggest-item" data-key="${n.public_key}" data-name="${escapeAttr(n.name || '')}">
|
||||
return `<div class="suggest-item" role="option" id="suggest-${idx}" data-key="${n.public_key}" data-name="${escapeAttr(n.name || '')}">
|
||||
<div class="suggest-main">
|
||||
<span class="suggest-name">${escapeHtml(n.name || 'Unknown')}</span>
|
||||
<small class="suggest-key">${truncate(n.public_key, 16)}</small>
|
||||
@@ -159,6 +168,8 @@
|
||||
}).join('');
|
||||
}
|
||||
suggest.classList.add('open');
|
||||
input.setAttribute('aria-expanded', 'true');
|
||||
input.setAttribute('aria-activedescendant', '');
|
||||
|
||||
// Claim buttons
|
||||
suggest.querySelectorAll('.suggest-claim').forEach(btn => {
|
||||
@@ -178,7 +189,7 @@
|
||||
loadMyNodes();
|
||||
});
|
||||
});
|
||||
} catch { suggest.classList.remove('open'); }
|
||||
} catch { suggest.classList.remove('open'); input.setAttribute('aria-expanded', 'false'); }
|
||||
}, 200);
|
||||
});
|
||||
|
||||
@@ -186,21 +197,29 @@
|
||||
const item = e.target.closest('.suggest-item');
|
||||
if (!item || !item.dataset.key || e.target.closest('.suggest-claim')) return;
|
||||
suggest.classList.remove('open');
|
||||
input.setAttribute('aria-expanded', 'false');
|
||||
input.value = '';
|
||||
loadHealth(item.dataset.key);
|
||||
});
|
||||
|
||||
document.addEventListener('click', handleOutsideClick);
|
||||
// Use AbortController so re-calling setupSearch won't stack listeners
|
||||
if (searchAbort) searchAbort.abort();
|
||||
searchAbort = new AbortController();
|
||||
document.addEventListener('click', handleOutsideClick, { signal: searchAbort.signal });
|
||||
}
|
||||
|
||||
function handleOutsideClick(e) {
|
||||
const suggest = document.getElementById('homeSuggest');
|
||||
if (suggest && !e.target.closest('.home-search-wrap')) suggest.classList.remove('open');
|
||||
const input = document.getElementById('homeSearch');
|
||||
if (suggest && !e.target.closest('.home-search-wrap')) {
|
||||
suggest.classList.remove('open');
|
||||
if (input) { input.setAttribute('aria-expanded', 'false'); input.setAttribute('aria-activedescendant', ''); }
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
clearTimeout(searchTimeout);
|
||||
document.removeEventListener('click', handleOutsideClick);
|
||||
if (searchAbort) { searchAbort.abort(); searchAbort = null; }
|
||||
if (miniMap) { miniMap.remove(); miniMap = null; }
|
||||
}
|
||||
|
||||
@@ -228,13 +247,13 @@
|
||||
|
||||
const cards = await Promise.all(myNodes.map(async (mn) => {
|
||||
try {
|
||||
const h = await api('/nodes/' + encodeURIComponent(mn.pubkey) + '/health');
|
||||
const h = await api('/nodes/' + encodeURIComponent(mn.pubkey) + '/health', { ttl: CLIENT_TTL.nodeHealth });
|
||||
const node = h.node || {};
|
||||
const stats = h.stats || {};
|
||||
const obs = h.observers || [];
|
||||
|
||||
const age = stats.lastHeard ? Date.now() - new Date(stats.lastHeard).getTime() : null;
|
||||
const status = age === null ? 'silent' : age < 3600000 ? 'healthy' : age < 86400000 ? 'degraded' : 'silent';
|
||||
const status = age === null ? 'silent' : age < HEALTH_THRESHOLDS.nodeDegradedMs ? 'healthy' : age < HEALTH_THRESHOLDS.nodeSilentMs ? 'degraded' : 'silent';
|
||||
const statusDot = status === 'healthy' ? '🟢' : status === 'degraded' ? '🟡' : '🔴';
|
||||
const statusText = status === 'healthy' ? 'Active' : status === 'degraded' ? 'Degraded' : 'Silent';
|
||||
const name = node.name || mn.name || truncate(mn.pubkey, 12);
|
||||
@@ -247,12 +266,12 @@
|
||||
// Build sparkline from recent packets (packet timestamps → hourly buckets)
|
||||
const sparkHtml = buildSparkline(h.recentPackets || []);
|
||||
|
||||
return `<div class="my-node-card ${status}" data-key="${mn.pubkey}">
|
||||
return `<div class="my-node-card ${status}" data-key="${mn.pubkey}" tabindex="0" role="button">
|
||||
<div class="mnc-header">
|
||||
<div class="mnc-status">${statusDot}</div>
|
||||
<div class="mnc-name">${escapeHtml(name)}</div>
|
||||
<div class="mnc-role">${node.role || '?'}</div>
|
||||
<button class="mnc-remove" data-key="${mn.pubkey}" title="Remove from My Mesh">✕</button>
|
||||
<button class="mnc-remove" data-key="${mn.pubkey}" title="Remove from My Mesh" aria-label="Remove ${escapeAttr(name)} from My Mesh">✕</button>
|
||||
</div>
|
||||
<div class="mnc-status-text">${statusText}${stats.lastHeard ? ' · ' + timeAgo(stats.lastHeard) : ''}</div>
|
||||
<div class="mnc-metrics">
|
||||
@@ -281,11 +300,11 @@
|
||||
</div>
|
||||
</div>`;
|
||||
} catch {
|
||||
return `<div class="my-node-card silent" data-key="${mn.pubkey}">
|
||||
return `<div class="my-node-card silent" data-key="${mn.pubkey}" tabindex="0" role="button">
|
||||
<div class="mnc-header">
|
||||
<div class="mnc-status">❓</div>
|
||||
<div class="mnc-name">${escapeHtml(mn.name || truncate(mn.pubkey, 12))}</div>
|
||||
<button class="mnc-remove" data-key="${mn.pubkey}" title="Remove">✕</button>
|
||||
<button class="mnc-remove" data-key="${mn.pubkey}" title="Remove" aria-label="Remove ${escapeAttr(mn.name || truncate(mn.pubkey, 12))} from My Mesh">✕</button>
|
||||
</div>
|
||||
<div class="mnc-status-text">Could not load data</div>
|
||||
</div>`;
|
||||
@@ -317,9 +336,13 @@
|
||||
|
||||
// Card click → health
|
||||
grid.querySelectorAll('.my-node-card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
const handler = (e) => {
|
||||
if (e.target.closest('.mnc-remove') || e.target.closest('.mnc-btn')) return;
|
||||
loadHealth(card.dataset.key);
|
||||
};
|
||||
card.addEventListener('click', handler);
|
||||
card.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handler(e); }
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -338,19 +361,19 @@
|
||||
const bars = buckets.map(v => {
|
||||
const h = Math.max(2, Math.round((v / max) * 24));
|
||||
const opacity = v > 0 ? 0.4 + (v / max) * 0.6 : 0.1;
|
||||
return `<div class="spark-bar" style="height:${h}px;opacity:${opacity}"></div>`;
|
||||
return `<div class="home-spark-bar" style="height:${h}px;opacity:${opacity}"></div>`;
|
||||
}).join('');
|
||||
return `<div class="spark-label">24h activity</div><div class="spark-bars">${bars}</div>`;
|
||||
return `<div class="home-spark-label">24h activity</div><div class="home-spark-bars">${bars}</div>`;
|
||||
}
|
||||
|
||||
// ==================== STATS ====================
|
||||
async function loadStats() {
|
||||
try {
|
||||
const s = await api('/stats');
|
||||
const s = await api('/stats', { ttl: CLIENT_TTL.nodeSearch });
|
||||
const el = document.getElementById('homeStats');
|
||||
if (!el) return;
|
||||
el.innerHTML = `
|
||||
<div class="home-stat"><div class="val">${s.totalPackets ?? '—'}</div><div class="lbl">Packets</div></div>
|
||||
<div class="home-stat"><div class="val">${s.totalTransmissions ?? s.totalPackets ?? '—'}</div><div class="lbl">Transmissions</div></div>
|
||||
<div class="home-stat"><div class="val">${s.totalNodes ?? '—'}</div><div class="lbl">Nodes</div></div>
|
||||
<div class="home-stat"><div class="val">${s.totalObservers ?? '—'}</div><div class="lbl">Observers</div></div>
|
||||
<div class="home-stat"><div class="val">${s.packetsLast24h ?? '—'}</div><div class="lbl">Last 24h</div></div>
|
||||
@@ -368,7 +391,7 @@
|
||||
if (journey) journey.classList.remove('visible');
|
||||
|
||||
try {
|
||||
const h = await api('/nodes/' + encodeURIComponent(pubkey) + '/health');
|
||||
const h = await api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeHealth });
|
||||
const node = h.node || {};
|
||||
const stats = h.stats || {};
|
||||
const packets = h.recentPackets || [];
|
||||
@@ -380,8 +403,8 @@
|
||||
if (stats.lastHeard) {
|
||||
const ageMs = Date.now() - new Date(stats.lastHeard).getTime();
|
||||
const ago = timeAgo(stats.lastHeard);
|
||||
if (ageMs < 3600000) { status = 'healthy'; color = 'green'; statusMsg = `Last heard ${ago}`; }
|
||||
else if (ageMs < 86400000) { status = 'degraded'; color = 'yellow'; statusMsg = `Last heard ${ago}`; }
|
||||
if (ageMs < HEALTH_THRESHOLDS.nodeDegradedMs) { status = 'healthy'; color = 'green'; statusMsg = `Last heard ${ago}`; }
|
||||
else if (ageMs < HEALTH_THRESHOLDS.nodeSilentMs) { status = 'degraded'; color = 'yellow'; statusMsg = `Last heard ${ago}`; }
|
||||
else { statusMsg = `Last heard ${ago}`; }
|
||||
}
|
||||
|
||||
@@ -409,13 +432,13 @@
|
||||
${packets.length ? packets.slice(0, 10).map(p => {
|
||||
const decoded = p.decoded_json ? JSON.parse(p.decoded_json) : {};
|
||||
const obsId = p.observer_name || p.observer_id || '?';
|
||||
return `<div class="timeline-item" data-pkt='${JSON.stringify({
|
||||
return `<div class="timeline-item" tabindex="0" role="button" data-pkt='${JSON.stringify({
|
||||
from: node.name || truncate(pubkey, 12),
|
||||
observers: [obsId],
|
||||
type: p.payload_type,
|
||||
time: p.timestamp || p.created_at
|
||||
}).replace(/'/g, ''')}'>
|
||||
<span class="badge" style="background:var(--type-${payloadTypeColor(p.payload_type)})">${payloadTypeName(p.payload_type)}</span>
|
||||
<span class="badge" style="background:var(--type-${payloadTypeColor(p.payload_type)})">${escapeHtml(payloadTypeName(p.payload_type))}</span>
|
||||
<span>via ${escapeHtml(obsId)}</span>
|
||||
<span class="time">${timeAgo(p.timestamp || p.created_at)}</span>
|
||||
<span class="snr">${p.snr != null ? p.snr.toFixed(1) + ' dB' : ''}</span>
|
||||
@@ -450,14 +473,16 @@
|
||||
// Scroll to health card
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
|
||||
// Timeline click → journey
|
||||
// Timeline click/keyboard → journey
|
||||
card.querySelectorAll('.timeline-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
try { showJourney(JSON.parse(item.dataset.pkt)); } catch {}
|
||||
const activate = () => { try { showJourney(JSON.parse(item.dataset.pkt)); } catch {} };
|
||||
item.addEventListener('click', activate);
|
||||
item.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(); }
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
card.innerHTML = '<p style="color:var(--status-red);padding:12px">Failed to load node health.</p>';
|
||||
card.innerHTML = '<p style="color:var(--status-red, #ef4444);padding:12px">Failed to load node health.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,7 +498,7 @@
|
||||
const nodeHtml = `<div class="journey-node"><div class="node-name">${escapeHtml(n.name)}</div><div class="node-meta">${n.meta}</div></div>`;
|
||||
return i < nodes.length - 1 ? nodeHtml + '<div class="journey-arrow"></div>' : nodeHtml;
|
||||
}).join('');
|
||||
el.innerHTML = `<div class="journey-title">Packet Journey — ${payloadTypeName(data.type)}</div><div class="journey-flow">${flow}</div>`;
|
||||
el.innerHTML = `<div class="journey-title">Packet Journey — ${escapeHtml(payloadTypeName(data.type))}</div><div class="journey-flow">${flow}</div>`;
|
||||
el.classList.add('visible');
|
||||
}
|
||||
|
||||
@@ -497,7 +522,7 @@
|
||||
{ q: '📍 Repeaters near you?',
|
||||
a: '<p><a href="#/map" style="color:var(--accent)">Check the network map</a> to see active repeaters.</p>' }
|
||||
];
|
||||
return items.map(i => `<div class="checklist-item"><div class="checklist-q">${i.q}</div><div class="checklist-a">${i.a}</div></div>`).join('');
|
||||
return items.map(i => `<div class="checklist-item"><div class="checklist-q" role="button" tabindex="0" aria-expanded="false">${i.q}</div><div class="checklist-a">${i.a}</div></div>`).join('');
|
||||
}
|
||||
|
||||
registerPage('home', { init, destroy });
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" href="favicon.svg" type="image/svg+xml">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>MeshCore Analyzer</title>
|
||||
|
||||
<!-- Open Graph / Discord embed -->
|
||||
@@ -20,12 +22,17 @@
|
||||
<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">
|
||||
<link rel="stylesheet" href="style.css?v=1774042199">
|
||||
<link rel="stylesheet" href="home.css">
|
||||
<link rel="stylesheet" href="live.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<link rel="stylesheet" href="live.css?v=1774034490">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="anonymous">
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
||||
<script src="https://unpkg.com/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#app">Skip to content</a>
|
||||
@@ -46,6 +53,7 @@
|
||||
<a href="#/traces" class="nav-link" data-route="traces">Traces</a>
|
||||
<a href="#/observers" class="nav-link" data-route="observers">Observers</a>
|
||||
<a href="#/analytics" class="nav-link" data-route="analytics">Analytics</a>
|
||||
<a href="#/perf" class="nav-link" data-route="perf">⚡ Perf</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
@@ -71,15 +79,19 @@
|
||||
<main id="app" role="main"></main>
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="app.js"></script>
|
||||
<script src="home.js"></script>
|
||||
<script src="packets.js"></script>
|
||||
<script src="map.js" onerror=""></script>
|
||||
<script src="channels.js" onerror=""></script>
|
||||
<script src="nodes.js" onerror=""></script>
|
||||
<script src="traces.js" onerror=""></script>
|
||||
<script src="analytics.js" onerror=""></script>
|
||||
<script src="live.js" onerror=""></script>
|
||||
<script src="observers.js" onerror=""></script>
|
||||
<script src="roles.js?v=1774028201"></script>
|
||||
<script src="app.js?v=1774034748"></script>
|
||||
<script src="home.js?v=1774042199"></script>
|
||||
<script src="packets.js?v=1774044174"></script>
|
||||
<script src="map.js?v=1774028201" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774028201" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774042199" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1773972187" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774042199" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774046040" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774018095" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1774028201" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1774042199" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1773985649" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
background: #0a0a0f;
|
||||
background: var(--surface-0);
|
||||
}
|
||||
/* Override #app height constraint on live page */
|
||||
#app:has(.live-page) {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@@ -26,11 +28,11 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
background: rgba(6, 6, 18, 0.82);
|
||||
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 8px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.15);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
@@ -38,7 +40,7 @@
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 2px;
|
||||
color: #e5e7eb;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -66,12 +68,12 @@
|
||||
}
|
||||
|
||||
.live-stat-pill {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -85,8 +87,8 @@
|
||||
.live-stat-pill.rate-pill span { color: #22c55e; }
|
||||
|
||||
.live-sound-btn {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
@@ -95,7 +97,27 @@
|
||||
}
|
||||
|
||||
.live-sound-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
background: color-mix(in srgb, var(--text) 14%, transparent);
|
||||
}
|
||||
|
||||
/* ---- Node Detail Panel ---- */
|
||||
.live-node-detail {
|
||||
top: 60px;
|
||||
right: 12px;
|
||||
width: 320px;
|
||||
max-height: calc(100vh - 140px);
|
||||
overflow-y: auto;
|
||||
background: color-mix(in srgb, var(--surface-1) 95%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
.live-node-detail.hidden {
|
||||
transform: translateX(340px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- Feed ---- */
|
||||
@@ -104,11 +126,11 @@
|
||||
left: 12px;
|
||||
width: 360px;
|
||||
max-height: 340px;
|
||||
overflow: hidden;
|
||||
background: rgba(6, 6, 18, 0.82);
|
||||
overflow-y: auto;
|
||||
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.12);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
@@ -117,7 +139,7 @@
|
||||
}
|
||||
|
||||
.live-feed-item {
|
||||
color: #d1d5db;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
padding: 5px 8px;
|
||||
@@ -143,14 +165,14 @@
|
||||
.feed-type { font-weight: 700; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; flex-shrink: 0; }
|
||||
.feed-hops {
|
||||
font-size: 10px;
|
||||
color: #6b7280;
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: var(--text-muted);
|
||||
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.feed-text {
|
||||
color: #9ca3af;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -159,22 +181,22 @@
|
||||
}
|
||||
.feed-time {
|
||||
font-size: 10px;
|
||||
color: #4b5563;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ---- Legend ---- */
|
||||
.live-legend {
|
||||
bottom: 12px;
|
||||
bottom: 58px;
|
||||
right: 12px;
|
||||
background: rgba(6, 6, 18, 0.82);
|
||||
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
|
||||
color: #9ca3af;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -186,8 +208,18 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
color: #4b5563;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.legend-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
@@ -202,9 +234,9 @@
|
||||
|
||||
/* ---- Tooltip ---- */
|
||||
.live-tooltip {
|
||||
background: rgba(6, 6, 18, 0.92) !important;
|
||||
color: #e5e7eb !important;
|
||||
border: 1px solid rgba(59, 130, 246, 0.2) !important;
|
||||
background: color-mix(in srgb, var(--surface-1) 95%, transparent) !important;
|
||||
color: var(--text) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: 6px !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 600 !important;
|
||||
@@ -214,7 +246,7 @@
|
||||
}
|
||||
|
||||
.live-tooltip::before {
|
||||
border-top-color: rgba(6, 6, 18, 0.92) !important;
|
||||
border-top-color: var(--surface-1) !important;
|
||||
}
|
||||
|
||||
/* ---- Heatmap toggle ---- */
|
||||
@@ -222,7 +254,7 @@
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
color: var(--text-muted);
|
||||
align-items: center;
|
||||
margin-left: 8px;
|
||||
}
|
||||
@@ -231,10 +263,10 @@
|
||||
|
||||
/* ---- Leaflet overrides for dark theme ---- */
|
||||
.live-page .leaflet-control-zoom a {
|
||||
background: rgba(6, 6, 18, 0.82) !important;
|
||||
background: color-mix(in srgb, var(--surface-1) 92%, transparent) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
color: #e5e7eb !important;
|
||||
border-color: rgba(255, 255, 255, 0.08) !important;
|
||||
color: var(--text) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
.live-page .leaflet-control-zoom a:hover {
|
||||
background: rgba(59, 130, 246, 0.2) !important;
|
||||
@@ -242,15 +274,45 @@
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
@media (max-width: 640px) {
|
||||
.live-feed { width: calc(100vw - 24px); max-height: 180px; }
|
||||
.live-legend { display: none; }
|
||||
.live-header { flex-wrap: wrap; gap: 8px; }
|
||||
.live-stats-row { flex-wrap: wrap; }
|
||||
.live-header { flex-wrap: wrap; gap: 6px; }
|
||||
.live-feed { display: none !important; }
|
||||
.feed-show-btn { display: none !important; }
|
||||
.live-legend { display: none !important; }
|
||||
.legend-toggle-btn { display: none !important; }
|
||||
.live-header {
|
||||
flex-wrap: wrap; gap: 6px; padding: 6px 10px;
|
||||
top: 56px; left: 8px; right: 8px; max-width: calc(100vw - 16px);
|
||||
}
|
||||
.live-stats-row { flex-wrap: wrap; gap: 4px; }
|
||||
.live-stat-pill { font-size: 11px; padding: 2px 7px; }
|
||||
.live-toggles { font-size: 10px; gap: 6px; margin-left: 0; }
|
||||
.live-title { font-size: 12px; letter-spacing: 1px; }
|
||||
.feed-detail-card {
|
||||
position: fixed !important;
|
||||
right: 0 !important;
|
||||
left: 0 !important;
|
||||
bottom: 58px !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
width: 100% !important;
|
||||
max-width: 100vw !important;
|
||||
max-height: 50vh !important;
|
||||
overflow-y: auto !important;
|
||||
border-radius: 10px 10px 0 0 !important;
|
||||
animation: slideUp 0.2s ease-out !important;
|
||||
}
|
||||
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||
/* Touch targets */
|
||||
.feed-hide-btn { width: 36px; height: 36px; font-size: 16px; }
|
||||
.feed-show-btn { padding: 10px 12px; min-width: 44px; min-height: 44px; }
|
||||
.legend-toggle-btn { min-width: 44px; min-height: 44px; }
|
||||
/* Feed resize handle: disable on mobile (can't drag easily) */
|
||||
.feed-resize-handle { display: none; }
|
||||
/* Leaflet zoom controls */
|
||||
.live-page .leaflet-top.leaflet-right { top: 56px; }
|
||||
}
|
||||
|
||||
/* Feed item hover */
|
||||
.live-feed-item:hover { background: rgba(255,255,255,0.06); }
|
||||
.live-feed-item:hover { background: color-mix(in srgb, var(--text) 8%, transparent); }
|
||||
|
||||
/* Feed detail card */
|
||||
.feed-detail-card {
|
||||
@@ -259,16 +321,16 @@
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 260px;
|
||||
background: rgba(10,10,30,0.92);
|
||||
background: color-mix(in srgb, var(--surface-1) 95%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(59,130,246,0.3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
z-index: 600;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
animation: fadeSlideIn 0.15s ease-out;
|
||||
font-size: .8rem;
|
||||
color: #e0e0e0;
|
||||
color: var(--text);
|
||||
}
|
||||
@keyframes fadeSlideIn { from { opacity:0; transform: translateY(-50%) translateX(8px); } to { opacity:1; transform: translateY(-50%) translateX(0); } }
|
||||
|
||||
@@ -279,22 +341,22 @@
|
||||
padding-left: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.fdc-header strong { font-size: .85rem; color: #fff; }
|
||||
.fdc-sender { color: #94a3b8; font-size: .75rem; }
|
||||
.fdc-header strong { font-size: .85rem; color: var(--text); }
|
||||
.fdc-sender { color: var(--text-muted); font-size: .75rem; }
|
||||
.fdc-close {
|
||||
margin-left: auto;
|
||||
background: none; border: none; color: #6b7280; cursor: pointer;
|
||||
background: none; border: none; color: var(--text-muted); cursor: pointer;
|
||||
font-size: .85rem; padding: 2px 4px; border-radius: 4px;
|
||||
}
|
||||
.fdc-close:hover { color: #fff; background: rgba(255,255,255,0.1); }
|
||||
.fdc-close:hover { color: var(--text); background: color-mix(in srgb, var(--text) 12%, transparent); }
|
||||
|
||||
.fdc-text {
|
||||
background: rgba(255,255,255,0.05);
|
||||
background: color-mix(in srgb, var(--text) 6%, transparent);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
color: #d1d5db;
|
||||
color: var(--text-muted);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -304,7 +366,7 @@
|
||||
gap: 6px 12px;
|
||||
margin-bottom: 8px;
|
||||
font-size: .7rem;
|
||||
color: #94a3b8;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.fdc-link {
|
||||
@@ -352,24 +414,23 @@
|
||||
.live-feed.hidden { opacity: 0; transform: translateX(-100%); pointer-events: none; visibility: hidden; }
|
||||
|
||||
.feed-hide-btn {
|
||||
position: absolute; top: 4px; right: 4px;
|
||||
background: rgba(255,255,255,0.08); border: none; color: #6b7280;
|
||||
width: 20px; height: 20px; border-radius: 4px; cursor: pointer;
|
||||
font-size: 10px; line-height: 1; display: flex; align-items: center; justify-content: center;
|
||||
opacity: 0; transition: opacity 0.2s;
|
||||
position: absolute; top: 6px; right: 6px;
|
||||
background: color-mix(in srgb, var(--text) 15%, transparent); border: 1px solid var(--border); color: var(--text-muted);
|
||||
width: 24px; height: 24px; border-radius: 6px; cursor: pointer;
|
||||
font-size: 13px; line-height: 1; display: flex; align-items: center; justify-content: center;
|
||||
opacity: 0.7; transition: opacity 0.2s, background 0.2s;
|
||||
z-index: 5;
|
||||
}
|
||||
.live-feed:hover .feed-hide-btn { opacity: 1; }
|
||||
.feed-hide-btn:hover { color: #fff; background: rgba(239,68,68,0.4); }
|
||||
.feed-hide-btn:hover { opacity: 1; color: #fff; background: rgba(239,68,68,0.6); }
|
||||
|
||||
.feed-show-btn {
|
||||
position: absolute; bottom: 12px; left: 12px; z-index: 500;
|
||||
background: rgba(6,6,18,0.85); backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255,255,255,0.1); border-radius: 8px;
|
||||
color: #9ca3af; font-size: 18px; padding: 8px 10px;
|
||||
background: color-mix(in srgb, var(--surface-1) 92%, transparent); backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
color: var(--text-muted); font-size: 18px; padding: 8px 10px;
|
||||
cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.feed-show-btn:hover { color: #fff; border-color: rgba(59,130,246,0.4); }
|
||||
.feed-show-btn:hover { color: var(--text); border-color: rgba(59,130,246,0.4); }
|
||||
.feed-show-btn.hidden { display: none; }
|
||||
|
||||
/* Push Leaflet zoom controls below nav bar */
|
||||
@@ -384,25 +445,25 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(6, 6, 18, 0.9);
|
||||
background: color-mix(in srgb, var(--surface-1) 95%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 8px 12px;
|
||||
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.vcr-bar > .vcr-controls,
|
||||
.vcr-bar > .vcr-timeline-wrap {
|
||||
/* These stack vertically in a wrapper — but we need them side by side with LCD */
|
||||
|
||||
@supports (padding-bottom: env(safe-area-inset-bottom)) {
|
||||
.vcr-bar { padding-bottom: calc(8px + env(safe-area-inset-bottom, 34px)); }
|
||||
.live-feed { bottom: calc(78px + env(safe-area-inset-bottom, 34px)); }
|
||||
.feed-show-btn { bottom: calc(88px + env(safe-area-inset-bottom, 34px)) !important; }
|
||||
.live-legend { bottom: calc(78px + env(safe-area-inset-bottom, 34px)); }
|
||||
}
|
||||
.vcr-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
.vcr-bar > .vcr-controls {
|
||||
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vcr-controls {
|
||||
@@ -412,16 +473,16 @@
|
||||
}
|
||||
|
||||
.vcr-btn {
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
color: #e2e8f0;
|
||||
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.8rem;
|
||||
padding: 6px 14px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.vcr-btn:hover { background: rgba(255,255,255,0.15); }
|
||||
.vcr-btn:hover { background: color-mix(in srgb, var(--text) 18%, transparent); }
|
||||
|
||||
.vcr-live-btn {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
@@ -458,16 +519,13 @@
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.vcr-clock {
|
||||
display: none; /* replaced by LCD panel */
|
||||
}
|
||||
.vcr-lcd {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
background: #1a1a0a;
|
||||
border: 1px solid #333;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
min-width: 110px;
|
||||
@@ -487,9 +545,6 @@
|
||||
text-shadow: 0 0 6px rgba(74, 222, 128, 0.6);
|
||||
font-weight: 700;
|
||||
}
|
||||
.vcr-lcd-time {
|
||||
display: none; /* replaced by canvas */
|
||||
}
|
||||
.vcr-lcd-canvas {
|
||||
width: 130px;
|
||||
height: 28px;
|
||||
@@ -515,12 +570,6 @@
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.vcr-timeline-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vcr-scope-btns {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
@@ -528,11 +577,11 @@
|
||||
}
|
||||
.vcr-scope-btn {
|
||||
background: none;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: #94a3b8;
|
||||
font-size: 0.65rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
@@ -545,14 +594,15 @@
|
||||
.vcr-timeline-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
height: 24px;
|
||||
height: 28px;
|
||||
}
|
||||
.vcr-timeline {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
border-radius: 3px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
background: color-mix(in srgb, var(--text) 6%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
|
||||
touch-action: none;
|
||||
}
|
||||
.vcr-timeline:active, .vcr-timeline.dragging {
|
||||
@@ -575,7 +625,7 @@
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
font-size: 0.78rem;
|
||||
color: #e2e8f0;
|
||||
color: var(--text);
|
||||
}
|
||||
.vcr-prompt.hidden { display: none; }
|
||||
.vcr-prompt-btn {
|
||||
@@ -592,15 +642,31 @@
|
||||
.vcr-prompt-btn:hover { background: rgba(59,130,246,0.3); }
|
||||
|
||||
/* Adjust feed position to not overlap VCR bar */
|
||||
.live-feed { bottom: 72px; }
|
||||
.feed-show-btn { bottom: 82px !important; }
|
||||
.live-feed { bottom: 58px; }
|
||||
.feed-show-btn { bottom: 68px !important; }
|
||||
|
||||
/* Mobile VCR */
|
||||
@media (max-width: 600px) {
|
||||
.vcr-bar { padding: 4px 8px; }
|
||||
.vcr-controls { gap: 4px; }
|
||||
.vcr-btn { padding: 3px 6px; font-size: 0.7rem; }
|
||||
.vcr-scope-btn { font-size: 0.6rem; padding: 1px 4px; }
|
||||
@media (max-width: 640px) {
|
||||
/* Mobile VCR: two-row stacked layout */
|
||||
.vcr-bar {
|
||||
padding: 4px 8px;
|
||||
padding-bottom: calc(4px + env(safe-area-inset-bottom, 20px));
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
overflow: visible;
|
||||
}
|
||||
/* Row 1: controls + scope + LCD, all in one line */
|
||||
.vcr-controls { order: 1; flex-shrink: 0; gap: 4px; }
|
||||
.vcr-scope-btns { order: 2; flex-shrink: 0; gap: 1px; }
|
||||
.vcr-lcd { order: 3; display: flex; margin-left: auto; min-width: 90px; padding: 2px 6px; }
|
||||
.vcr-lcd-canvas { width: 100px; height: 22px; }
|
||||
.vcr-mode { display: none; }
|
||||
/* Row 2: timeline takes full width */
|
||||
.vcr-timeline-container { order: 4; width: 100%; flex: none; height: 20px; }
|
||||
/* Smaller buttons */
|
||||
.vcr-btn { padding: 4px 8px; font-size: 0.75rem; min-height: 32px; min-width: 32px; }
|
||||
.vcr-scope-btn { font-size: 0.6rem; padding: 2px 6px; min-height: 28px; }
|
||||
.vcr-prompt { order: 5; width: 100%; font-size: 0.7rem; }
|
||||
}
|
||||
|
||||
/* Timeline time tooltip */
|
||||
@@ -608,8 +674,8 @@
|
||||
position: absolute;
|
||||
top: -24px;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0,0,0,0.85);
|
||||
color: #e2e8f0;
|
||||
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
|
||||
color: var(--text);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
@@ -619,3 +685,75 @@
|
||||
z-index: 10;
|
||||
}
|
||||
.vcr-time-tooltip.hidden { display: none; }
|
||||
|
||||
/* Screen-reader only text */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Legend toggle button for mobile (#60) */
|
||||
.legend-toggle-btn {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 82px;
|
||||
right: 12px;
|
||||
z-index: 500;
|
||||
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.legend-toggle-btn:hover { color: var(--text); border-color: rgba(59,130,246,0.4); }
|
||||
|
||||
/* Feed resize handle (#27) */
|
||||
.feed-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -4px;
|
||||
width: 8px;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
z-index: 10;
|
||||
}
|
||||
.feed-resize-handle::after {
|
||||
content: '⋮';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0px;
|
||||
width: 10px;
|
||||
height: 32px;
|
||||
transform: translateY(-50%);
|
||||
background: color-mix(in srgb, var(--text) 25%, transparent);
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 14px; color: var(--text-muted); line-height: 32px; text-align: center;
|
||||
}
|
||||
.feed-resize-handle:hover::after { background: rgba(59,130,246,0.5); color: #fff; }
|
||||
|
||||
/* Nav pin button (#62) */
|
||||
.nav-pin-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
margin-left: auto;
|
||||
}
|
||||
.nav-pin-btn:hover { opacity: 0.8; }
|
||||
.nav-pin-btn.pinned { opacity: 1; filter: drop-shadow(0 0 4px rgba(59,130,246,0.5)); }
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
let showGhostHops = localStorage.getItem('live-ghost-hops') !== 'false';
|
||||
let _onResize = null;
|
||||
let _navCleanup = null;
|
||||
let _timelineRefreshInterval = null;
|
||||
let _lcdClockInterval = null;
|
||||
let _rateCounterInterval = null;
|
||||
|
||||
// === VCR State Machine ===
|
||||
const VCR = {
|
||||
@@ -27,10 +30,7 @@
|
||||
timelineFetchedScope: 0, // last fetched scope to avoid redundant fetches
|
||||
};
|
||||
|
||||
const ROLE_COLORS = {
|
||||
repeater: '#3b82f6', companion: '#06b6d4', room: '#a855f7',
|
||||
sensor: '#f59e0b', unknown: '#6b7280'
|
||||
};
|
||||
// ROLE_COLORS loaded from shared roles.js (includes 'unknown')
|
||||
|
||||
const TYPE_COLORS = {
|
||||
ADVERT: '#22c55e', GRP_TXT: '#3b82f6', TXT_MSG: '#f59e0b', ACK: '#6b7280',
|
||||
@@ -61,10 +61,28 @@
|
||||
let resizeTimer = null;
|
||||
_onResize = function() {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => { if (map) map.invalidateSize({ animate: false }); }, 150);
|
||||
resizeTimer = setTimeout(() => {
|
||||
// Set live-page height from JS — most reliable across all mobile browsers
|
||||
const page = document.querySelector('.live-page');
|
||||
const appEl = document.getElementById('app');
|
||||
const h = window.innerHeight;
|
||||
if (page) page.style.height = h + 'px';
|
||||
if (appEl) appEl.style.height = h + 'px';
|
||||
if (map) {
|
||||
map.invalidateSize({ animate: false, pan: false });
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
// Run immediately to set correct initial height
|
||||
_onResize();
|
||||
window.addEventListener('resize', _onResize);
|
||||
window.addEventListener('orientationchange', () => setTimeout(_onResize, 200));
|
||||
window.addEventListener('orientationchange', () => {
|
||||
// Orientation change is async — viewport dimensions settle late
|
||||
[50, 200, 500, 1000, 2000].forEach(ms => setTimeout(_onResize, ms));
|
||||
});
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', _onResize);
|
||||
}
|
||||
}
|
||||
|
||||
// === VCR Controls ===
|
||||
@@ -117,11 +135,11 @@
|
||||
clearNodeMarkers();
|
||||
loadNodes(targetTs);
|
||||
|
||||
// Fetch ALL packets from scrub point to now (no limit, no until)
|
||||
fetch(`/api/packets?limit=10000&grouped=false&since=${encodeURIComponent(fetchFrom)}`)
|
||||
// Fetch packets from scrub point forward (ASC order, no limit clipping from the wrong end)
|
||||
fetch(`/api/packets?limit=10000&grouped=false&since=${encodeURIComponent(fetchFrom)}&order=asc`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const pkts = (data.packets || []).reverse(); // chronological order
|
||||
const pkts = data.packets || []; // already ASC from server
|
||||
const replayEntries = pkts.map(p => ({
|
||||
ts: new Date(p.timestamp || p.created_at).getTime(),
|
||||
pkt: dbPacketToLive(p)
|
||||
@@ -143,6 +161,8 @@
|
||||
function showVCRPrompt(count) {
|
||||
const prompt = document.getElementById('vcrPrompt');
|
||||
if (!prompt) return;
|
||||
prompt.setAttribute('role', 'alertdialog');
|
||||
prompt.setAttribute('aria-label', 'Missed packets prompt');
|
||||
prompt.innerHTML = `
|
||||
<span>You missed <strong>${count}</strong> packets.</span>
|
||||
<button id="vcrPromptReplay" class="vcr-prompt-btn">▶ Replay</button>
|
||||
@@ -157,6 +177,8 @@
|
||||
prompt.classList.add('hidden');
|
||||
vcrResumeLive();
|
||||
});
|
||||
// Focus first button for keyboard users (#59)
|
||||
document.getElementById('vcrPromptReplay').focus();
|
||||
}
|
||||
|
||||
function vcrReplayMissed() {
|
||||
@@ -173,7 +195,7 @@
|
||||
// Fetch packets from DB for the time window
|
||||
const now = Date.now();
|
||||
const from = new Date(now - ms).toISOString();
|
||||
fetch(`/api/packets?limit=200&grouped=false&since=${encodeURIComponent(from)}`)
|
||||
fetch(`/api/packets?limit=2000&grouped=false&since=${encodeURIComponent(from)}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const pkts = (data.packets || []).reverse(); // oldest first
|
||||
@@ -198,7 +220,11 @@
|
||||
function tick() {
|
||||
if (VCR.mode !== 'REPLAY') return;
|
||||
if (VCR.playhead >= VCR.buffer.length) {
|
||||
vcrResumeLive();
|
||||
// Try to fetch the next page before going live
|
||||
fetchNextReplayPage().then(hasMore => {
|
||||
if (hasMore) tick();
|
||||
else vcrResumeLive();
|
||||
});
|
||||
return;
|
||||
}
|
||||
const entry = VCR.buffer[VCR.playhead];
|
||||
@@ -221,6 +247,27 @@
|
||||
tick();
|
||||
}
|
||||
|
||||
function fetchNextReplayPage() {
|
||||
// Get timestamp of last packet in buffer to fetch the next page
|
||||
const last = VCR.buffer[VCR.buffer.length - 1];
|
||||
if (!last) return Promise.resolve(false);
|
||||
const since = new Date(last.ts + 1).toISOString(); // +1ms to avoid dupe
|
||||
return fetch(`/api/packets?limit=10000&grouped=false&since=${encodeURIComponent(since)}&order=asc`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const pkts = data.packets || [];
|
||||
if (pkts.length === 0) return false;
|
||||
const newEntries = pkts.map(p => ({
|
||||
ts: new Date(p.timestamp || p.created_at).getTime(),
|
||||
pkt: dbPacketToLive(p)
|
||||
}));
|
||||
// Append to buffer, playhead stays where it is (at the end, about to read new entries)
|
||||
VCR.buffer = VCR.buffer.concat(newEntries);
|
||||
return true;
|
||||
})
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
function stopReplay() {
|
||||
if (VCR.replayTimer) { clearTimeout(VCR.replayTimer); VCR.replayTimer = null; }
|
||||
}
|
||||
@@ -266,6 +313,7 @@
|
||||
function drawLcdText(text, color) {
|
||||
const canvas = document.getElementById('vcrLcdCanvas');
|
||||
if (!canvas) return;
|
||||
canvas.setAttribute('aria-label', 'VCR time: ' + text);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cw = canvas.offsetWidth, ch = canvas.offsetHeight;
|
||||
@@ -279,15 +327,15 @@
|
||||
let x = (cw - totalW) / 2;
|
||||
const y = 2;
|
||||
|
||||
// Draw ghost segments (dim background)
|
||||
const dimColor = color.replace(/[\d.]+\)$/, '0.07)').replace(/^#/, '');
|
||||
// Draw ghost segments (dim background) — hardcoded to match LCD green
|
||||
const ghostColor = 'rgba(74,222,128,0.07)';
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch2 = text[i];
|
||||
if (ch2 === ':') {
|
||||
drawSegDigit(ctx, x, y, digitW * 0.5, digitH, 0x80, `rgba(74,222,128,0.07)`);
|
||||
drawSegDigit(ctx, x, y, digitW * 0.5, digitH, 0x80, ghostColor);
|
||||
x += digitW * 0.5;
|
||||
} else {
|
||||
drawSegDigit(ctx, x, y, digitW, digitH, 0x7F, `rgba(74,222,128,0.07)`);
|
||||
drawSegDigit(ctx, x, y, digitW, digitH, 0x7F, ghostColor);
|
||||
x += digitW + 1;
|
||||
}
|
||||
}
|
||||
@@ -341,13 +389,13 @@
|
||||
if (VCR.mode === 'LIVE') {
|
||||
modeEl.innerHTML = '<span class="vcr-live-dot"></span> LIVE';
|
||||
modeEl.className = 'vcr-mode vcr-mode-live';
|
||||
if (pauseBtn) pauseBtn.textContent = '⏸';
|
||||
if (pauseBtn) { pauseBtn.textContent = '⏸'; pauseBtn.setAttribute('aria-label', 'Pause'); }
|
||||
if (missedEl) missedEl.classList.add('hidden');
|
||||
updateVCRClock(Date.now());
|
||||
} else if (VCR.mode === 'PAUSED') {
|
||||
modeEl.textContent = '⏸ PAUSED';
|
||||
modeEl.className = 'vcr-mode vcr-mode-paused';
|
||||
if (pauseBtn) pauseBtn.textContent = '▶';
|
||||
if (pauseBtn) { pauseBtn.textContent = '▶'; pauseBtn.setAttribute('aria-label', 'Play'); }
|
||||
if (missedEl && VCR.missedCount > 0) {
|
||||
missedEl.textContent = `+${VCR.missedCount}`;
|
||||
missedEl.classList.remove('hidden');
|
||||
@@ -355,10 +403,10 @@
|
||||
} else if (VCR.mode === 'REPLAY') {
|
||||
modeEl.textContent = `⏪ REPLAY`;
|
||||
modeEl.className = 'vcr-mode vcr-mode-replay';
|
||||
if (pauseBtn) pauseBtn.textContent = '⏸';
|
||||
if (pauseBtn) { pauseBtn.textContent = '⏸'; pauseBtn.setAttribute('aria-label', 'Pause'); }
|
||||
if (missedEl) missedEl.classList.add('hidden');
|
||||
}
|
||||
if (speedBtn) speedBtn.textContent = VCR.speed + 'x';
|
||||
if (speedBtn) { speedBtn.textContent = VCR.speed + 'x'; speedBtn.setAttribute('aria-label', 'Speed ' + VCR.speed + 'x'); }
|
||||
updateVCRLcd();
|
||||
}
|
||||
|
||||
@@ -379,8 +427,14 @@
|
||||
pkt._ts = Date.now();
|
||||
const entry = { ts: pkt._ts, pkt };
|
||||
VCR.buffer.push(entry);
|
||||
// Keep buffer capped at ~2000
|
||||
if (VCR.buffer.length > 2000) VCR.buffer.splice(0, 500);
|
||||
// Keep buffer capped at ~2000 — adjust playhead to avoid stale indices (#63)
|
||||
if (VCR.buffer.length > 2000) {
|
||||
const trimCount = 500;
|
||||
VCR.buffer.splice(0, trimCount);
|
||||
if (VCR.playhead >= 0) {
|
||||
VCR.playhead = Math.max(0, VCR.playhead - trimCount);
|
||||
}
|
||||
}
|
||||
|
||||
if (VCR.mode === 'LIVE') {
|
||||
animatePacket(pkt);
|
||||
@@ -534,55 +588,57 @@
|
||||
</div>
|
||||
<button class="live-sound-btn" id="liveSoundBtn" title="Toggle sound">🔇</button>
|
||||
<div class="live-toggles">
|
||||
<label><input type="checkbox" id="liveHeatToggle" checked> Heat</label>
|
||||
<label><input type="checkbox" id="liveGhostToggle" checked> Ghosts</label>
|
||||
<label><input type="checkbox" id="liveHeatToggle" checked aria-describedby="heatDesc"> Heat</label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="live-overlay live-feed" id="liveFeed">
|
||||
<button class="feed-hide-btn" id="feedHideBtn" title="Hide feed">✕</button>
|
||||
</div>
|
||||
<button class="feed-show-btn hidden" id="feedShowBtn" title="Show feed">📋</button>
|
||||
<div class="live-overlay live-legend">
|
||||
<div class="legend-title">PACKET TYPES</div>
|
||||
<div><span class="live-dot" style="background:#22c55e"></span> Advert</div>
|
||||
<div><span class="live-dot" style="background:#3b82f6"></span> Message</div>
|
||||
<div><span class="live-dot" style="background:#f59e0b"></span> Direct</div>
|
||||
<div><span class="live-dot" style="background:#a855f7"></span> Request</div>
|
||||
<div><span class="live-dot" style="background:#ec4899"></span> Trace</div>
|
||||
<div class="legend-title" style="margin-top:8px">NODE ROLES</div>
|
||||
<div><span class="live-dot" style="background:#3b82f6"></span> Repeater</div>
|
||||
<div><span class="live-dot" style="background:#06b6d4"></span> Companion</div>
|
||||
<div><span class="live-dot" style="background:#a855f7"></span> Room</div>
|
||||
<div><span class="live-dot" style="background:#f59e0b"></span> Sensor</div>
|
||||
<div class="live-overlay live-node-detail hidden" id="liveNodeDetail">
|
||||
<button class="feed-hide-btn" id="nodeDetailClose" title="Close">✕</button>
|
||||
<div id="nodeDetailContent"></div>
|
||||
</div>
|
||||
<button class="legend-toggle-btn hidden" id="legendToggleBtn" aria-label="Show legend" title="Show legend">🎨</button>
|
||||
<div class="live-overlay live-legend" id="liveLegend" role="region" aria-label="Map legend">
|
||||
<h3 class="legend-title">PACKET TYPES</h3>
|
||||
<ul class="legend-list">
|
||||
<li><span class="live-dot" style="background:#22c55e" aria-hidden="true"></span> Advert — Node advertisement</li>
|
||||
<li><span class="live-dot" style="background:#3b82f6" aria-hidden="true"></span> Message — Group text</li>
|
||||
<li><span class="live-dot" style="background:#f59e0b" aria-hidden="true"></span> Direct — Direct message</li>
|
||||
<li><span class="live-dot" style="background:#a855f7" aria-hidden="true"></span> Request — Data request</li>
|
||||
<li><span class="live-dot" style="background:#ec4899" aria-hidden="true"></span> Trace — Route trace</li>
|
||||
</ul>
|
||||
<h3 class="legend-title" style="margin-top:8px">NODE ROLES</h3>
|
||||
<ul class="legend-list" id="roleLegendList"></ul>
|
||||
</div>
|
||||
|
||||
<!-- VCR Bar -->
|
||||
<div class="vcr-bar" id="vcrBar">
|
||||
<div class="vcr-left">
|
||||
<div class="vcr-controls">
|
||||
<button id="vcrRewindBtn" class="vcr-btn" title="Rewind">⏪</button>
|
||||
<button id="vcrPauseBtn" class="vcr-btn" title="Pause/Play">⏸</button>
|
||||
<button id="vcrLiveBtn" class="vcr-btn vcr-live-btn" title="Jump to live">LIVE</button>
|
||||
<button id="vcrSpeedBtn" class="vcr-btn" title="Playback speed">1x</button>
|
||||
<button id="vcrRewindBtn" class="vcr-btn" title="Rewind" aria-label="Rewind">⏪</button>
|
||||
<button id="vcrPauseBtn" class="vcr-btn" title="Pause/Play" aria-label="Pause">⏸</button>
|
||||
<button id="vcrLiveBtn" class="vcr-btn vcr-live-btn" title="Jump to live" aria-label="Snap to Live">LIVE</button>
|
||||
<button id="vcrSpeedBtn" class="vcr-btn" title="Playback speed" aria-label="Speed 1x">1x</button>
|
||||
<div id="vcrMode" class="vcr-mode vcr-mode-live"><span class="vcr-live-dot"></span> LIVE</div>
|
||||
</div>
|
||||
<div class="vcr-timeline-wrap">
|
||||
<div class="vcr-scope-btns">
|
||||
<button class="vcr-scope-btn active" data-scope="3600000">1h</button>
|
||||
<button class="vcr-scope-btn" data-scope="21600000">6h</button>
|
||||
<button class="vcr-scope-btn" data-scope="43200000">12h</button>
|
||||
<button class="vcr-scope-btn" data-scope="86400000">24h</button>
|
||||
</div>
|
||||
<div class="vcr-timeline-container">
|
||||
<canvas id="vcrTimeline" class="vcr-timeline"></canvas>
|
||||
<div id="vcrPlayhead" class="vcr-playhead"></div>
|
||||
<div id="vcrTimeTooltip" class="vcr-time-tooltip hidden"></div>
|
||||
</div>
|
||||
<div class="vcr-scope-btns" role="radiogroup" aria-label="Timeline scope">
|
||||
<button class="vcr-scope-btn active" data-scope="3600000" role="radio" aria-checked="true" aria-label="Scope 1 hour">1h</button>
|
||||
<button class="vcr-scope-btn" data-scope="21600000" role="radio" aria-checked="false" aria-label="Scope 6 hours">6h</button>
|
||||
<button class="vcr-scope-btn" data-scope="43200000" role="radio" aria-checked="false" aria-label="Scope 12 hours">12h</button>
|
||||
<button class="vcr-scope-btn" data-scope="86400000" role="radio" aria-checked="false" aria-label="Scope 24 hours">24h</button>
|
||||
</div>
|
||||
<div class="vcr-timeline-container">
|
||||
<canvas id="vcrTimeline" class="vcr-timeline"></canvas>
|
||||
<div id="vcrPlayhead" class="vcr-playhead"></div>
|
||||
<div id="vcrTimeTooltip" class="vcr-time-tooltip hidden"></div>
|
||||
</div>
|
||||
<div class="vcr-lcd">
|
||||
<div class="vcr-lcd-row vcr-lcd-mode" id="vcrLcdMode">LIVE</div>
|
||||
<canvas id="vcrLcdCanvas" class="vcr-lcd-canvas" width="200" height="32"></canvas>
|
||||
<canvas id="vcrLcdCanvas" class="vcr-lcd-canvas" width="200" height="32" role="img" aria-label="VCR time display"></canvas>
|
||||
<div class="vcr-lcd-row vcr-lcd-pkts" id="vcrLcdPkts"></div>
|
||||
</div>
|
||||
<div id="vcrPrompt" class="vcr-prompt hidden"></div>
|
||||
@@ -594,7 +650,17 @@
|
||||
zoomAnimation: true, markerZoomAnimation: true
|
||||
}).setView([37.45, -122.0], 9);
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 }).addTo(map);
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
let tileLayer = L.tileLayer(isDark ? TILE_DARK : TILE_LIGHT, { maxZoom: 19 }).addTo(map);
|
||||
|
||||
// Swap tiles when theme changes
|
||||
const _themeObs = new MutationObserver(function () {
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
tileLayer.setUrl(dark ? TILE_DARK : TILE_LIGHT);
|
||||
});
|
||||
_themeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
L.control.zoom({ position: 'topright' }).addTo(map);
|
||||
|
||||
nodesLayer = L.layerGroup().addTo(map);
|
||||
@@ -644,6 +710,13 @@
|
||||
|
||||
// Feed show/hide
|
||||
const feedEl = document.getElementById('liveFeed');
|
||||
// Keyboard support for feed items (event delegation)
|
||||
feedEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
const item = e.target.closest('.live-feed-item');
|
||||
if (item) { e.preventDefault(); item.click(); }
|
||||
}
|
||||
});
|
||||
const feedHideBtn = document.getElementById('feedHideBtn');
|
||||
const feedShowBtn = document.getElementById('feedShowBtn');
|
||||
if (localStorage.getItem('live-feed-hidden') === 'true') {
|
||||
@@ -659,6 +732,56 @@
|
||||
localStorage.setItem('live-feed-hidden', 'false');
|
||||
});
|
||||
|
||||
// Legend toggle for mobile (#60)
|
||||
const legendEl = document.getElementById('liveLegend');
|
||||
const legendToggleBtn = document.getElementById('legendToggleBtn');
|
||||
if (legendToggleBtn && legendEl) {
|
||||
legendToggleBtn.addEventListener('click', () => {
|
||||
const isVisible = legendEl.classList.toggle('legend-mobile-visible');
|
||||
legendToggleBtn.setAttribute('aria-label', isVisible ? 'Hide legend' : 'Show legend');
|
||||
legendToggleBtn.textContent = isVisible ? '✕' : '🎨';
|
||||
});
|
||||
}
|
||||
|
||||
// Populate role legend from shared roles.js
|
||||
const roleLegendList = document.getElementById('roleLegendList');
|
||||
if (roleLegendList) {
|
||||
for (const role of (window.ROLE_SORT || ['repeater', 'companion', 'room', 'sensor', 'observer'])) {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<span class="live-dot" style="background:${ROLE_COLORS[role] || '#6b7280'}" aria-hidden="true"></span> ${(ROLE_LABELS[role] || role).replace(/s$/, '')}`;
|
||||
roleLegendList.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
// Node detail panel
|
||||
const nodeDetailPanel = document.getElementById('liveNodeDetail');
|
||||
const nodeDetailContent = document.getElementById('nodeDetailContent');
|
||||
document.getElementById('nodeDetailClose').addEventListener('click', () => {
|
||||
nodeDetailPanel.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Feed panel resize handle (#27)
|
||||
const savedFeedWidth = localStorage.getItem('live-feed-width');
|
||||
if (savedFeedWidth) feedEl.style.width = savedFeedWidth + 'px';
|
||||
const resizeHandle = document.createElement('div');
|
||||
resizeHandle.className = 'feed-resize-handle';
|
||||
resizeHandle.setAttribute('aria-label', 'Resize feed panel');
|
||||
feedEl.appendChild(resizeHandle);
|
||||
let feedResizing = false;
|
||||
resizeHandle.addEventListener('mousedown', (e) => {
|
||||
feedResizing = true; e.preventDefault();
|
||||
});
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!feedResizing) return;
|
||||
const newWidth = Math.max(200, Math.min(800, e.clientX - feedEl.getBoundingClientRect().left));
|
||||
feedEl.style.width = newWidth + 'px';
|
||||
});
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (!feedResizing) return;
|
||||
feedResizing = false;
|
||||
localStorage.setItem('live-feed-width', parseInt(feedEl.style.width));
|
||||
});
|
||||
|
||||
// Save/restore map view
|
||||
const savedView = localStorage.getItem('live-map-view');
|
||||
if (savedView) {
|
||||
@@ -685,8 +808,9 @@
|
||||
// Scope buttons
|
||||
document.querySelectorAll('.vcr-scope-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.vcr-scope-btn').forEach(b => b.classList.remove('active'));
|
||||
document.querySelectorAll('.vcr-scope-btn').forEach(b => { b.classList.remove('active'); b.setAttribute('aria-checked', 'false'); });
|
||||
btn.classList.add('active');
|
||||
btn.setAttribute('aria-checked', 'true');
|
||||
VCR.timelineScope = parseInt(btn.dataset.scope);
|
||||
fetchTimelineTimestamps().then(() => updateTimeline());
|
||||
});
|
||||
@@ -709,6 +833,20 @@
|
||||
});
|
||||
timelineEl.addEventListener('mouseleave', () => { timeTooltip.classList.add('hidden'); });
|
||||
|
||||
// Touch tooltip for timeline (#19)
|
||||
timelineEl.addEventListener('touchmove', (e) => {
|
||||
if (!VCR.dragging) return;
|
||||
const touch = e.touches[0];
|
||||
const rect = timelineEl.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
|
||||
const ts = Date.now() - VCR.timelineScope + pct * VCR.timelineScope;
|
||||
const d = new Date(ts);
|
||||
timeTooltip.textContent = d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
||||
timeTooltip.style.left = (touch.clientX - rect.left) + 'px';
|
||||
timeTooltip.classList.remove('hidden');
|
||||
});
|
||||
timelineEl.addEventListener('touchend', () => { timeTooltip.classList.add('hidden'); });
|
||||
|
||||
// Drag scrubbing on timeline
|
||||
VCR.dragging = false;
|
||||
VCR.dragPct = 0;
|
||||
@@ -767,25 +905,48 @@
|
||||
|
||||
// Fetch historical timestamps for timeline, then start refresh
|
||||
fetchTimelineTimestamps().then(() => updateTimeline());
|
||||
setInterval(() => {
|
||||
// Re-fetch if scope changed or periodically to pick up new data
|
||||
_timelineRefreshInterval = setInterval(() => {
|
||||
VCR.timelineFetchedScope = 0; // force refetch
|
||||
fetchTimelineTimestamps().then(() => updateTimeline());
|
||||
}, 30000);
|
||||
|
||||
// Live clock tick — update LCD every second when in LIVE mode
|
||||
setInterval(() => {
|
||||
_lcdClockInterval = setInterval(() => {
|
||||
if (VCR.mode === 'LIVE') updateVCRClock(Date.now());
|
||||
}, 1000);
|
||||
|
||||
// Auto-hide nav
|
||||
// Auto-hide nav with pin toggle (#62)
|
||||
const topNav = document.querySelector('.top-nav');
|
||||
if (topNav) { topNav.style.position = 'fixed'; topNav.style.width = '100%'; topNav.style.zIndex = '1100'; }
|
||||
_navCleanup = { timeout: null, fn: null };
|
||||
_navCleanup = { timeout: null, fn: null, pinned: false };
|
||||
// Add pin button to nav (guard against duplicate)
|
||||
if (topNav && !document.getElementById('navPinBtn')) {
|
||||
const pinBtn = document.createElement('button');
|
||||
pinBtn.id = 'navPinBtn';
|
||||
pinBtn.className = 'nav-pin-btn';
|
||||
pinBtn.setAttribute('aria-label', 'Pin navigation open');
|
||||
pinBtn.setAttribute('title', 'Pin navigation open');
|
||||
pinBtn.textContent = '📌';
|
||||
pinBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_navCleanup.pinned = !_navCleanup.pinned;
|
||||
pinBtn.classList.toggle('pinned', _navCleanup.pinned);
|
||||
pinBtn.setAttribute('aria-pressed', _navCleanup.pinned);
|
||||
if (_navCleanup.pinned) {
|
||||
clearTimeout(_navCleanup.timeout);
|
||||
topNav.classList.remove('nav-autohide');
|
||||
} else {
|
||||
_navCleanup.timeout = setTimeout(() => { topNav.classList.add('nav-autohide'); }, 4000);
|
||||
}
|
||||
});
|
||||
topNav.appendChild(pinBtn);
|
||||
}
|
||||
function showNav() {
|
||||
if (topNav) topNav.classList.remove('nav-autohide');
|
||||
clearTimeout(_navCleanup.timeout);
|
||||
_navCleanup.timeout = setTimeout(() => { if (topNav) topNav.classList.add('nav-autohide'); }, 4000);
|
||||
if (!_navCleanup.pinned) {
|
||||
_navCleanup.timeout = setTimeout(() => { if (topNav) topNav.classList.add('nav-autohide'); }, 4000);
|
||||
}
|
||||
}
|
||||
_navCleanup.fn = showNav;
|
||||
const livePage = document.querySelector('.live-page');
|
||||
@@ -808,7 +969,7 @@
|
||||
|
||||
let pktTimestamps = [];
|
||||
function startRateCounter() {
|
||||
setInterval(() => {
|
||||
_rateCounterInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
pktTimestamps = pktTimestamps.filter(t => now - t < 60000);
|
||||
const el = document.getElementById('livePktRate');
|
||||
@@ -816,6 +977,80 @@
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async function showNodeDetail(pubkey) {
|
||||
const panel = document.getElementById('liveNodeDetail');
|
||||
const content = document.getElementById('nodeDetailContent');
|
||||
panel.classList.remove('hidden');
|
||||
content.innerHTML = '<div style="padding:20px;color:var(--text-muted)">Loading…</div>';
|
||||
try {
|
||||
const [data, healthData] = await Promise.all([
|
||||
api('/nodes/' + encodeURIComponent(pubkey), { ttl: 30 }),
|
||||
api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: 30 }).catch(() => null)
|
||||
]);
|
||||
const n = data.node;
|
||||
const h = healthData || {};
|
||||
const stats = h.stats || {};
|
||||
const observers = h.observers || [];
|
||||
const recent = h.recentPackets || [];
|
||||
const roleColor = ROLE_COLORS[n.role] || '#6b7280';
|
||||
const roleLabel = (ROLE_LABELS[n.role] || n.role || 'unknown').replace(/s$/, '');
|
||||
const hasLoc = n.lat != null && n.lon != null;
|
||||
const lastSeen = n.last_seen ? timeAgo(n.last_seen) : '—';
|
||||
const thresholds = window.getHealthThresholds ? getHealthThresholds(n.role) : { degradedMs: 3600000, silentMs: 86400000 };
|
||||
const ageMs = n.last_seen ? Date.now() - new Date(n.last_seen).getTime() : Infinity;
|
||||
const statusDot = ageMs < thresholds.degradedMs ? 'health-green' : ageMs < thresholds.silentMs ? 'health-yellow' : 'health-red';
|
||||
const statusLabel = ageMs < thresholds.degradedMs ? 'Online' : ageMs < thresholds.silentMs ? 'Degraded' : 'Offline';
|
||||
|
||||
let html = `
|
||||
<div style="padding:16px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<span class="${statusDot}" style="font-size:18px">●</span>
|
||||
<h3 style="margin:0;font-size:16px;font-weight:700;">${escapeHtml(n.name || 'Unknown')}</h3>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<span style="display:inline-block;padding:2px 10px;border-radius:12px;font-size:11px;font-weight:600;background:${roleColor};color:#fff;">${roleLabel.toUpperCase()}</span>
|
||||
<span style="color:var(--text-muted);font-size:12px;margin-left:8px;">${statusLabel}</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px;">
|
||||
<code style="font-size:10px;word-break:break-all;">${escapeHtml(n.public_key)}</code>
|
||||
</div>
|
||||
<table style="font-size:12px;width:100%;border-collapse:collapse;">
|
||||
<tr><td style="color:var(--text-muted);padding:4px 8px 4px 0;">Last Seen</td><td>${lastSeen}</td></tr>
|
||||
<tr><td style="color:var(--text-muted);padding:4px 8px 4px 0;">Adverts</td><td>${n.advert_count || 0}</td></tr>
|
||||
${hasLoc ? `<tr><td style="color:var(--text-muted);padding:4px 8px 4px 0;">Location</td><td>${n.lat.toFixed(5)}, ${n.lon.toFixed(5)}</td></tr>` : ''}
|
||||
${stats.avgSnr != null ? `<tr><td style="color:var(--text-muted);padding:4px 8px 4px 0;">Avg SNR</td><td>${stats.avgSnr.toFixed(1)} dB</td></tr>` : ''}
|
||||
${stats.avgHops != null ? `<tr><td style="color:var(--text-muted);padding:4px 8px 4px 0;">Avg Hops</td><td>${stats.avgHops.toFixed(1)}</td></tr>` : ''}
|
||||
${stats.totalTransmissions || stats.totalPackets ? `<tr><td style="color:var(--text-muted);padding:4px 8px 4px 0;">Total Packets</td><td>${stats.totalTransmissions || stats.totalPackets}</td></tr>` : ''}
|
||||
</table>`;
|
||||
|
||||
if (observers.length) {
|
||||
html += `<h4 style="font-size:12px;margin:12px 0 6px;color:var(--text-muted);">Heard By</h4>
|
||||
<div style="font-size:11px;">` +
|
||||
observers.map(o => `<div style="padding:2px 0;"><a href="#/observers/${encodeURIComponent(o.observer_id)}" style="color:var(--accent);text-decoration:none;">${escapeHtml(o.observer_name || o.observer_id.slice(0, 12))}</a> — ${o.packetCount || o.count || 0} pkts</div>`).join('') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
if (recent.length) {
|
||||
html += `<h4 style="font-size:12px;margin:12px 0 6px;color:var(--text-muted);">Recent Packets</h4>
|
||||
<div style="font-size:11px;max-height:200px;overflow-y:auto;">` +
|
||||
recent.slice(0, 10).map(p => `<div style="padding:2px 0;display:flex;justify-content:space-between;">
|
||||
<a href="#/packets/${encodeURIComponent(p.hash || '')}" style="color:var(--accent);text-decoration:none;">${escapeHtml(p.payload_type || '?')}${p.observation_count > 1 ? ' <span class="badge badge-obs" style="font-size:9px">👁 ' + p.observation_count + '</span>' : ''}</a>
|
||||
<span style="color:var(--text-muted)">${p.timestamp ? timeAgo(p.timestamp) : '—'}</span>
|
||||
</div>`).join('') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
html += `<div style="margin-top:12px;display:flex;gap:8px;">
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}" style="font-size:12px;color:var(--accent);">Full Detail →</a>
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" style="font-size:12px;color:var(--accent);">📊 Analytics</a>
|
||||
</div></div>`;
|
||||
|
||||
content.innerHTML = html;
|
||||
} catch (e) {
|
||||
content.innerHTML = `<div style="padding:20px;color:var(--text-muted);">Error: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNodes(beforeTs) {
|
||||
try {
|
||||
const url = beforeTs
|
||||
@@ -830,7 +1065,7 @@
|
||||
addNodeMarker(n);
|
||||
}
|
||||
});
|
||||
document.getElementById('liveNodeCount').textContent = Object.keys(nodeMarkers).length;
|
||||
const _el2 = document.getElementById('liveNodeCount'); if (_el2) _el2.textContent = Object.keys(nodeMarkers).length;
|
||||
} catch (e) { console.error('Failed to load nodes:', e); }
|
||||
}
|
||||
|
||||
@@ -864,6 +1099,8 @@
|
||||
permanent: false, direction: 'top', offset: [0, -10], className: 'live-tooltip'
|
||||
});
|
||||
|
||||
marker.on('click', () => showNodeDetail(n.public_key));
|
||||
|
||||
marker._glowMarker = glow;
|
||||
marker._baseColor = color;
|
||||
marker._baseSize = size;
|
||||
@@ -909,14 +1146,14 @@
|
||||
if (msg.type === 'packet') bufferPacket(msg.data);
|
||||
} catch {}
|
||||
};
|
||||
ws.onclose = () => setTimeout(connectWS, 3000);
|
||||
ws.onclose = () => setTimeout(connectWS, WS_RECONNECT_MS);
|
||||
ws.onerror = () => {};
|
||||
}
|
||||
|
||||
function animatePacket(pkt) {
|
||||
packetCount++;
|
||||
pktTimestamps.push(Date.now());
|
||||
document.getElementById('livePktCount').textContent = packetCount;
|
||||
const _el = document.getElementById('livePktCount'); if (_el) _el.textContent = packetCount;
|
||||
|
||||
const decoded = pkt.decoded || {};
|
||||
const header = decoded.header || {};
|
||||
@@ -936,7 +1173,7 @@
|
||||
const n = { public_key: key, name: payload.name || key.slice(0,8), role: payload.role || 'unknown', lat: payload.lat, lon: payload.lon };
|
||||
nodeData[key] = n;
|
||||
addNodeMarker(n);
|
||||
document.getElementById('liveNodeCount').textContent = Object.keys(nodeMarkers).length;
|
||||
const _el2 = document.getElementById('liveNodeCount'); if (_el2) _el2.textContent = Object.keys(nodeMarkers).length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1004,7 +1241,7 @@
|
||||
|
||||
// Sanity check: drop hops that are impossibly far from both neighbors (>200km ≈ 1.8°)
|
||||
// These are almost certainly 1-byte prefix collisions with distant nodes
|
||||
const MAX_HOP_DIST = 1.8;
|
||||
// MAX_HOP_DIST from shared roles.js
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
if (!raw[i].known || !raw[i].pos) continue;
|
||||
const prev = i > 0 && raw[i-1].known && raw[i-1].pos ? raw[i-1].pos : null;
|
||||
@@ -1237,14 +1474,17 @@
|
||||
const text = payload.text || payload.name || '';
|
||||
const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
|
||||
const hopStr = hops.length ? `<span class="feed-hops">${hops.length}⇢</span>` : '';
|
||||
const obsBadge = pkt.observation_count > 1 ? `<span class="badge badge-obs" style="font-size:10px;margin-left:4px">👁 ${pkt.observation_count}</span>` : '';
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'live-feed-item live-feed-enter';
|
||||
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}
|
||||
${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>
|
||||
`;
|
||||
@@ -1297,17 +1537,25 @@
|
||||
if (feedEl) feedEl.parentElement.appendChild(card);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
stopReplay();
|
||||
if (_timelineRefreshInterval) { clearInterval(_timelineRefreshInterval); _timelineRefreshInterval = null; }
|
||||
if (_lcdClockInterval) { clearInterval(_lcdClockInterval); _lcdClockInterval = null; }
|
||||
if (_rateCounterInterval) { clearInterval(_rateCounterInterval); _rateCounterInterval = null; }
|
||||
if (ws) { ws.onclose = null; ws.close(); ws = null; }
|
||||
if (map) { map.remove(); map = null; }
|
||||
if (_onResize) { window.removeEventListener('resize', _onResize); window.removeEventListener('orientationchange', _onResize); }
|
||||
if (_onResize) {
|
||||
window.removeEventListener('resize', _onResize);
|
||||
window.removeEventListener('orientationchange', _onResize);
|
||||
if (window.visualViewport) window.visualViewport.removeEventListener('resize', _onResize);
|
||||
}
|
||||
// Restore #app height to CSS default
|
||||
const appEl = document.getElementById('app');
|
||||
if (appEl) appEl.style.height = '';
|
||||
const topNav = document.querySelector('.top-nav');
|
||||
if (topNav) { topNav.classList.remove('nav-autohide'); topNav.style.position = ''; topNav.style.width = ''; topNav.style.zIndex = ''; }
|
||||
const existingPin = document.getElementById('navPinBtn');
|
||||
if (existingPin) existingPin.remove();
|
||||
if (_navCleanup) {
|
||||
clearTimeout(_navCleanup.timeout);
|
||||
const livePage = document.querySelector('.live-page');
|
||||
|
||||
@@ -8,25 +8,63 @@
|
||||
let clusterGroup = null;
|
||||
let nodes = [];
|
||||
let observers = [];
|
||||
let filters = { repeater: true, companion: true, room: true, sensor: true, lastHeard: '30d', mqttOnly: false, neighbors: false, clusters: false };
|
||||
let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clusters: false };
|
||||
let wsHandler = null;
|
||||
let heatLayer = null;
|
||||
let userHasMoved = false;
|
||||
let controlsCollapsed = false;
|
||||
|
||||
// Role → marker style (WCAG AA compliant: all ≥4.5:1 on both light/dark backgrounds)
|
||||
const ROLE_STYLE = {
|
||||
repeater: { color: '#1d4ed8', fill: true, radius: 8, weight: 2 },
|
||||
companion: { color: '#0369a1', fill: false, radius: 7, weight: 2 },
|
||||
room: { color: '#6d28d9', fill: true, radius: 7, weight: 2 },
|
||||
sensor: { color: '#92400e', fill: true, radius: 4, weight: 1 },
|
||||
};
|
||||
// Safe escape — falls back to identity if app.js hasn't loaded yet
|
||||
const safeEsc = (typeof esc === 'function') ? esc : function (s) { return s; };
|
||||
|
||||
const ROLE_LABELS = { repeater: 'Repeaters', companion: 'Companions', room: 'Room Servers', sensor: 'Sensors' };
|
||||
const ROLE_COLORS = { repeater: '#1d4ed8', companion: '#0369a1', room: '#6d28d9', sensor: '#92400e' };
|
||||
// Roles loaded from shared roles.js (ROLE_STYLE, ROLE_LABELS, ROLE_COLORS globals)
|
||||
|
||||
function makeMarkerIcon(role) {
|
||||
const s = ROLE_STYLE[role] || ROLE_STYLE.companion;
|
||||
const size = s.radius * 2 + 4;
|
||||
const c = size / 2;
|
||||
let path;
|
||||
switch (s.shape) {
|
||||
case 'diamond':
|
||||
path = `<polygon points="${c},2 ${size-2},${c} ${c},${size-2} 2,${c}" fill="${s.color}" stroke="#fff" stroke-width="2"/>`;
|
||||
break;
|
||||
case 'square':
|
||||
path = `<rect x="3" y="3" width="${size-6}" height="${size-6}" fill="${s.color}" stroke="#fff" stroke-width="2"/>`;
|
||||
break;
|
||||
case 'triangle':
|
||||
path = `<polygon points="${c},2 ${size-2},${size-2} 2,${size-2}" fill="${s.color}" stroke="#fff" stroke-width="2"/>`;
|
||||
break;
|
||||
case 'star': {
|
||||
// 5-pointed star
|
||||
const cx = c, cy = c, outer = c - 1, inner = outer * 0.4;
|
||||
let pts = '';
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const aOuter = (i * 72 - 90) * Math.PI / 180;
|
||||
const aInner = ((i * 72) + 36 - 90) * Math.PI / 180;
|
||||
pts += `${cx + outer * Math.cos(aOuter)},${cy + outer * Math.sin(aOuter)} `;
|
||||
pts += `${cx + inner * Math.cos(aInner)},${cy + inner * Math.sin(aInner)} `;
|
||||
}
|
||||
path = `<polygon points="${pts.trim()}" fill="${s.color}" stroke="#fff" stroke-width="1.5"/>`;
|
||||
break;
|
||||
}
|
||||
default: // circle
|
||||
path = `<circle cx="${c}" cy="${c}" r="${c-2}" fill="${s.color}" stroke="#fff" stroke-width="2"/>`;
|
||||
}
|
||||
const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">${path}</svg>`;
|
||||
return L.divIcon({
|
||||
html: svg,
|
||||
className: 'meshcore-marker',
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [c, c],
|
||||
popupAnchor: [0, -c],
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
<button class="map-controls-toggle" id="mapControlsToggle" aria-label="Toggle map controls" aria-expanded="true">⚙️</button>
|
||||
<div class="map-controls" id="mapControls" role="region" aria-label="Map controls">
|
||||
<h3>🗺️ Map Controls</h3>
|
||||
<fieldset class="mc-section">
|
||||
@@ -35,13 +73,12 @@
|
||||
</fieldset>
|
||||
<fieldset class="mc-section">
|
||||
<legend class="mc-label">Display</legend>
|
||||
<label><input type="checkbox" id="mcClusters"> Show clusters</label>
|
||||
<label><input type="checkbox" id="mcHeatmap"> Heat map</label>
|
||||
<label for="mcClusters"><input type="checkbox" id="mcClusters"> Show clusters</label>
|
||||
<label for="mcHeatmap"><input type="checkbox" id="mcHeatmap"> Heat map</label>
|
||||
</fieldset>
|
||||
<fieldset class="mc-section">
|
||||
<legend class="mc-label">Filters</legend>
|
||||
<label><input type="checkbox" id="mcMqtt"> MQTT Connected Only</label>
|
||||
<label><input type="checkbox" id="mcNeighbors"> Show direct neighbors</label>
|
||||
<label for="mcNeighbors"><input type="checkbox" id="mcNeighbors"> Show direct neighbors</label>
|
||||
</fieldset>
|
||||
<fieldset class="mc-section">
|
||||
<legend class="mc-label">Last Heard</legend>
|
||||
@@ -71,15 +108,25 @@
|
||||
try { const v = JSON.parse(savedView); initCenter = [v.lat, v.lng]; initZoom = v.zoom; } catch {}
|
||||
}
|
||||
map = L.map('leaflet-map', { zoomControl: true }).setView(initCenter, initZoom);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap',
|
||||
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const tileLayer = L.tileLayer(isDark ? TILE_DARK : TILE_LIGHT, {
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
const _mapThemeObs = new MutationObserver(function () {
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
tileLayer.setUrl(dark ? TILE_DARK : TILE_LIGHT);
|
||||
});
|
||||
_mapThemeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
|
||||
// Save position on move
|
||||
map.on('moveend', () => {
|
||||
const c = map.getCenter();
|
||||
localStorage.setItem('map-view', JSON.stringify({ lat: c.lat, lng: c.lng, zoom: map.getZoom() }));
|
||||
userHasMoved = true;
|
||||
});
|
||||
|
||||
markerLayer = L.layerGroup().addTo(map);
|
||||
@@ -88,20 +135,33 @@
|
||||
// Fix map size on SPA load
|
||||
setTimeout(() => map.invalidateSize(), 100);
|
||||
|
||||
// Controls toggle
|
||||
const toggleBtn = document.getElementById('mapControlsToggle');
|
||||
const controlsPanel = document.getElementById('mapControls');
|
||||
// Default collapsed on mobile
|
||||
if (window.innerWidth <= 640) {
|
||||
controlsCollapsed = true;
|
||||
controlsPanel.classList.add('collapsed');
|
||||
toggleBtn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
controlsCollapsed = !controlsCollapsed;
|
||||
controlsPanel.classList.toggle('collapsed', controlsCollapsed);
|
||||
toggleBtn.setAttribute('aria-expanded', String(!controlsCollapsed));
|
||||
});
|
||||
|
||||
// Bind controls
|
||||
document.getElementById('mcClusters').addEventListener('change', e => { filters.clusters = e.target.checked; renderMarkers(); });
|
||||
document.getElementById('mcHeatmap').addEventListener('change', e => { toggleHeatmap(e.target.checked); });
|
||||
document.getElementById('mcMqtt').addEventListener('change', e => { filters.mqttOnly = e.target.checked; renderMarkers(); });
|
||||
document.getElementById('mcNeighbors').addEventListener('change', e => { filters.neighbors = e.target.checked; renderMarkers(); });
|
||||
document.getElementById('mcLastHeard').addEventListener('change', e => { filters.lastHeard = e.target.value; loadNodes(); });
|
||||
|
||||
// WS for live advert updates
|
||||
wsHandler = msg => {
|
||||
if (msg.type === 'packet' && msg.data?.decoded?.header?.payloadTypeName === 'ADVERT') {
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
if (msgs.some(function (m) { return m.type === 'packet' && m.data?.decoded?.header?.payloadTypeName === 'ADVERT'; })) {
|
||||
loadNodes();
|
||||
}
|
||||
};
|
||||
onWS(wsHandler);
|
||||
});
|
||||
|
||||
loadNodes().then(() => {
|
||||
// Check for route from packet detail (via sessionStorage)
|
||||
@@ -117,24 +177,42 @@
|
||||
}
|
||||
|
||||
function drawPacketRoute(hopKeys) {
|
||||
// Match hop keys to nodes - supports both full pubkeys and short prefixes
|
||||
// Bidirectional prefix match handles DB nodes with truncated or full keys
|
||||
function findNode(hop) {
|
||||
// Resolve hop short hashes to node positions with geographic disambiguation
|
||||
const raw = hopKeys.map(hop => {
|
||||
const hopLower = hop.toLowerCase();
|
||||
return nodes.find(n => {
|
||||
const candidates = nodes.filter(n => {
|
||||
const pk = n.public_key.toLowerCase();
|
||||
return (pk === hopLower || pk.startsWith(hopLower) || hopLower.startsWith(pk)) &&
|
||||
n.lat != null && n.lon != null && !(n.lat === 0 && n.lon === 0);
|
||||
});
|
||||
}
|
||||
|
||||
const positions = hopKeys.map(hop => {
|
||||
const node = findNode(hop);
|
||||
if (node) {
|
||||
return { lat: node.lat, lon: node.lon, name: node.name || hop.slice(0,8), pubkey: node.public_key, role: node.role, resolved: true };
|
||||
if (candidates.length === 1) {
|
||||
const c = candidates[0];
|
||||
return { lat: c.lat, lon: c.lon, name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: true };
|
||||
} else if (candidates.length > 1) {
|
||||
return { name: hop.slice(0,8), resolved: false, candidates };
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
});
|
||||
|
||||
// Disambiguate: pick candidate closest to center of already-resolved hops
|
||||
const knownPos = raw.filter(h => h && h.resolved);
|
||||
if (knownPos.length > 0) {
|
||||
const cLat = knownPos.reduce((s, p) => s + p.lat, 0) / knownPos.length;
|
||||
const cLon = knownPos.reduce((s, p) => s + p.lon, 0) / knownPos.length;
|
||||
const dist = (lat, lon) => Math.sqrt((lat - cLat) ** 2 + (lon - cLon) ** 2);
|
||||
for (const hop of raw) {
|
||||
if (hop && !hop.resolved && hop.candidates) {
|
||||
hop.candidates.sort((a, b) => dist(a.lat, a.lon) - dist(b.lat, b.lon));
|
||||
const best = hop.candidates[0];
|
||||
hop.lat = best.lat; hop.lon = best.lon;
|
||||
hop.name = best.name || hop.name;
|
||||
hop.pubkey = best.public_key; hop.role = best.role;
|
||||
hop.resolved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const positions = raw.filter(h => h && h.resolved);
|
||||
if (positions.length < 1) return;
|
||||
|
||||
// Even a single node is worth showing (zoom to it)
|
||||
@@ -159,9 +237,9 @@
|
||||
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}: ${esc(p.name)}</div>
|
||||
<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>
|
||||
<div style="font-family:monospace;font-size:10px;color:#6b7280;margin-bottom:6px;word-break:break-all">${esc(p.pubkey || '')}</div>
|
||||
<div style="font-family:monospace;font-size:10px;color:#6b7280;margin-bottom:6px;word-break:break-all">${safeEsc(p.pubkey || '')}</div>
|
||||
<div style="font-size:11px;color:#9ca3af">${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}</div>
|
||||
${p.pubkey ? `<div style="margin-top:6px"><a href="#/nodes/${p.pubkey}" style="color:var(--accent);font-size:11px">View Node →</a></div>` : ''}
|
||||
</div>`;
|
||||
@@ -178,17 +256,22 @@
|
||||
|
||||
async function loadNodes() {
|
||||
try {
|
||||
const data = await api(`/nodes?limit=10000&lastHeard=${filters.lastHeard}`);
|
||||
nodes = data.nodes || [];
|
||||
buildRoleChecks(data.counts || {});
|
||||
// Load regions from config + observed IATAs
|
||||
try { REGION_NAMES = await api('/config/regions', { ttl: 3600 }); } catch {}
|
||||
|
||||
// Load observers for jump buttons
|
||||
const obsData = await api('/observers');
|
||||
const data = await api(`/nodes?limit=10000&lastHeard=${filters.lastHeard}`, { ttl: CLIENT_TTL.nodeList });
|
||||
nodes = data.nodes || [];
|
||||
|
||||
// Load observers for jump buttons + map markers
|
||||
const obsData = await api('/observers', { ttl: CLIENT_TTL.observers });
|
||||
observers = obsData.observers || [];
|
||||
|
||||
buildRoleChecks(data.counts || {});
|
||||
buildJumpButtons();
|
||||
|
||||
renderMarkers();
|
||||
if (!savedView) fitBounds();
|
||||
// Don't fitBounds on initial load — respect the Bay Area default or saved view
|
||||
// Only fitBounds on subsequent data refreshes if user hasn't manually panned
|
||||
} catch (e) {
|
||||
console.error('Map load error:', e);
|
||||
}
|
||||
@@ -198,10 +281,16 @@
|
||||
const el = document.getElementById('mcRoleChecks');
|
||||
if (!el) return;
|
||||
el.innerHTML = '';
|
||||
for (const role of ['repeater', 'companion', 'room', 'sensor']) {
|
||||
const count = counts[role + 's'] || 0;
|
||||
const obsCount = observers.filter(o => o.lat && o.lon).length;
|
||||
const roles = ['repeater', 'companion', 'room', 'sensor', 'observer'];
|
||||
const shapeMap = { repeater: '◆', companion: '●', room: '■', sensor: '▲', observer: '★' };
|
||||
for (const role of roles) {
|
||||
const count = role === 'observer' ? obsCount : (counts[role + 's'] || 0);
|
||||
const cbId = 'mcRole_' + role;
|
||||
const lbl = document.createElement('label');
|
||||
lbl.innerHTML = `<input type="checkbox" data-role="${role}" ${filters[role] ? 'checked' : ''}> <span style="color:${ROLE_COLORS[role]};font-weight:600;" aria-hidden="true">●</span> ${ROLE_LABELS[role]} <span style="color:var(--text-muted)">(${count})</span>`;
|
||||
lbl.setAttribute('for', cbId);
|
||||
const shape = shapeMap[role] || '●';
|
||||
lbl.innerHTML = `<input type="checkbox" id="${cbId}" data-role="${role}" ${filters[role] ? 'checked' : ''}> <span style="color:${ROLE_COLORS[role]};font-weight:600;" aria-hidden="true">${shape}</span> ${ROLE_LABELS[role]} <span style="color:var(--text-muted)">(${count})</span>`;
|
||||
lbl.querySelector('input').addEventListener('change', e => {
|
||||
filters[e.target.dataset.role] = e.target.checked;
|
||||
renderMarkers();
|
||||
@@ -210,7 +299,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
const REGION_NAMES = { SJC: 'San Jose', SFO: 'San Francisco', OAK: 'Oakland', MTV: 'Mountain View', SCZ: 'Santa Cruz', MRY: 'Monterey', PAO: 'Palo Alto' };
|
||||
let REGION_NAMES = {};
|
||||
|
||||
function buildJumpButtons() {
|
||||
const el = document.getElementById('mcJumps');
|
||||
@@ -236,11 +325,23 @@
|
||||
}
|
||||
|
||||
function jumpToRegion(iata) {
|
||||
// Find nodes observed in this region — use all nodes with location and fit bounds
|
||||
// For now, just find the centroid of nodes that have location
|
||||
const nodesWithLoc = nodes.filter(n => n.lat && n.lon);
|
||||
if (nodesWithLoc.length === 0) return;
|
||||
const bounds = L.latLngBounds(nodesWithLoc.map(n => [n.lat, n.lon]));
|
||||
// Find observers in this region, then find nodes seen by those observers
|
||||
const regionObserverIds = new Set(observers.filter(o => o.iata === iata).map(o => o.id || o.observer_id));
|
||||
// Filter nodes that have location; prefer nodes associated with region observers
|
||||
let regionNodes = nodes.filter(n => n.lat && n.lon && n.observer_id && regionObserverIds.has(n.observer_id));
|
||||
// Fallback: if observers don't link to nodes, use observers' own locations
|
||||
if (regionNodes.length === 0) {
|
||||
const obsWithLoc = observers.filter(o => o.iata === iata && o.lat && o.lon);
|
||||
if (obsWithLoc.length > 0) {
|
||||
const bounds = L.latLngBounds(obsWithLoc.map(o => [o.lat, o.lon]));
|
||||
map.fitBounds(bounds.pad(0.5), { padding: [40, 40], maxZoom: 12 });
|
||||
return;
|
||||
}
|
||||
// Final fallback: fit all nodes
|
||||
regionNodes = nodes.filter(n => n.lat && n.lon);
|
||||
}
|
||||
if (regionNodes.length === 0) return;
|
||||
const bounds = L.latLngBounds(regionNodes.map(n => [n.lat, n.lon]));
|
||||
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
|
||||
}
|
||||
|
||||
@@ -254,19 +355,53 @@
|
||||
});
|
||||
|
||||
for (const node of filtered) {
|
||||
const style = ROLE_STYLE[node.role] || ROLE_STYLE.companion;
|
||||
const marker = L.circleMarker([node.lat, node.lon], {
|
||||
radius: style.radius,
|
||||
color: style.color,
|
||||
fillColor: style.color,
|
||||
fillOpacity: style.fill ? 0.8 : 0,
|
||||
weight: style.weight,
|
||||
const icon = makeMarkerIcon(node.role || 'companion');
|
||||
const marker = L.marker([node.lat, node.lon], {
|
||||
icon,
|
||||
alt: `${node.name || 'Unknown'} (${node.role || 'node'})`,
|
||||
});
|
||||
|
||||
marker.bindPopup(buildPopup(node), { maxWidth: 280 });
|
||||
markerLayer.addLayer(marker);
|
||||
}
|
||||
|
||||
// Add observer markers
|
||||
if (filters.observer) {
|
||||
for (const obs of observers) {
|
||||
if (!obs.lat || !obs.lon) continue;
|
||||
const icon = makeMarkerIcon('observer');
|
||||
const marker = L.marker([obs.lat, obs.lon], {
|
||||
icon,
|
||||
alt: `${obs.name || obs.id} (observer)`,
|
||||
});
|
||||
marker.bindPopup(buildObserverPopup(obs), { maxWidth: 280 });
|
||||
markerLayer.addLayer(marker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildObserverPopup(obs) {
|
||||
const name = safeEsc(obs.name || obs.id || 'Unknown');
|
||||
const iata = obs.iata ? `<span class="badge-region">${safeEsc(obs.iata)}</span>` : '';
|
||||
const lastSeen = obs.last_seen ? timeAgo(obs.last_seen) : '—';
|
||||
const packets = (obs.packet_count || 0).toLocaleString();
|
||||
const loc = `${obs.lat.toFixed(5)}, ${obs.lon.toFixed(5)}`;
|
||||
const roleBadge = `<span style="display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600;background:${ROLE_COLORS.observer};color:#fff;">OBSERVER</span>`;
|
||||
|
||||
return `
|
||||
<div class="map-popup" style="font-family:var(--font);min-width:180px;">
|
||||
<h3 style="font-weight:700;font-size:14px;margin:0 0 4px;">${name}</h3>
|
||||
${roleBadge} ${iata}
|
||||
<dl style="margin-top:8px;font-size:12px;">
|
||||
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Location</dt>
|
||||
<dd style="margin-left:88px;padding:2px 0;">${loc}</dd>
|
||||
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Last Seen</dt>
|
||||
<dd style="margin-left:88px;padding:2px 0;">${lastSeen}</dd>
|
||||
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Packets</dt>
|
||||
<dd style="margin-left:88px;padding:2px 0;">${packets}</dd>
|
||||
</dl>
|
||||
<a href="#/observers/${encodeURIComponent(obs.id || obs.observer_id)}" style="display:block;margin-top:8px;font-size:12px;color:var(--accent);">View Detail →</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function buildPopup(node) {
|
||||
@@ -276,16 +411,20 @@
|
||||
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>`;
|
||||
|
||||
return `
|
||||
<div style="font-family:var(--font);min-width:180px;">
|
||||
<div style="font-weight:700;font-size:14px;margin-bottom:4px;">${node.name || 'Unknown'}</div>
|
||||
<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}
|
||||
<table style="margin-top:8px;font-size:12px;border-collapse:collapse;width:100%;">
|
||||
<tr><td style="color:var(--text-muted);padding:2px 8px 2px 0;">Key</td><td style="font-family:var(--mono);font-size:11px;">${key}</td></tr>
|
||||
<tr><td style="color:var(--text-muted);padding:2px 8px 2px 0;">Location</td><td>${loc}</td></tr>
|
||||
<tr><td style="color:var(--text-muted);padding:2px 8px 2px 0;">Last Advert</td><td>${lastAdvert}</td></tr>
|
||||
<tr><td style="color:var(--text-muted);padding:2px 8px 2px 0;">Adverts</td><td>${node.advert_count || 0}</td></tr>
|
||||
</table>
|
||||
<div style="margin-top:8px;"><a href="#/nodes/${node.public_key}" style="color:var(--accent);font-size:12px;">View Node →</a></div>
|
||||
<dl style="margin-top:8px;font-size:12px;">
|
||||
<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>
|
||||
<dd style="margin-left:88px;padding:2px 0;">${loc}</dd>
|
||||
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Last Advert</dt>
|
||||
<dd style="margin-left:88px;padding:2px 0;">${lastAdvert}</dd>
|
||||
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Adverts</dt>
|
||||
<dd style="margin-left:88px;padding:2px 0;">${node.advert_count || 0}</dd>
|
||||
</dl>
|
||||
<div style="margin-top:8px;clear:both;"><a href="#/nodes/${node.public_key}" style="color:var(--accent);font-size:12px;">View Node →</a></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
/* === MeshCore Analyzer — node-analytics.js === */
|
||||
'use strict';
|
||||
(function () {
|
||||
const PAYLOAD_LABELS = { 0: 'Request', 1: 'Response', 2: 'Direct Msg', 3: 'ACK', 4: 'Advert', 5: 'Channel Msg', 7: 'Anon Req', 8: 'Path', 9: 'Trace', 11: 'Control' };
|
||||
const CHART_COLORS = ['#4a9eff', '#ff6b6b', '#51cf66', '#fcc419', '#cc5de8', '#20c997', '#ff922b', '#845ef7', '#f06595', '#339af0'];
|
||||
const GRADE_COLORS = { A: '#51cf66', 'A-': '#51cf66', 'B+': '#339af0', B: '#339af0', C: '#fcc419', D: '#ff6b6b' };
|
||||
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
let charts = [];
|
||||
let currentDays = 7;
|
||||
let currentPubkey = null;
|
||||
|
||||
function destroyCharts() {
|
||||
charts.forEach(c => { try { c.destroy(); } catch {} });
|
||||
charts = [];
|
||||
}
|
||||
|
||||
function chartDefaults() {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
Chart.defaults.color = style.getPropertyValue('--text-muted').trim() || '#6b7280';
|
||||
Chart.defaults.borderColor = style.getPropertyValue('--border').trim() || '#e2e5ea';
|
||||
}
|
||||
|
||||
function formatSilence(ms) {
|
||||
if (!ms) return '—';
|
||||
const h = Math.floor(ms / 3600000);
|
||||
const m = Math.floor((ms % 3600000) / 60000);
|
||||
if (h > 24) return Math.floor(h / 24) + 'd ' + (h % 24) + 'h';
|
||||
if (h > 0) return h + 'h ' + m + 'm';
|
||||
return m + 'm';
|
||||
}
|
||||
|
||||
async function loadAnalytics(container, pubkey, days) {
|
||||
currentPubkey = pubkey;
|
||||
currentDays = days;
|
||||
destroyCharts();
|
||||
chartDefaults();
|
||||
|
||||
container.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Loading analytics…</div>';
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await api('/nodes/' + encodeURIComponent(pubkey) + '/analytics?days=' + days, { ttl: CLIENT_TTL.nodeAnalytics });
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div style="padding:40px;text-align:center;color:#ff6b6b">Failed to load analytics: ' + escapeHtml(e.message) + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const n = data.node;
|
||||
const s = data.computedStats;
|
||||
const nodeName = escapeHtml(n.name || n.public_key.slice(0, 12));
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="max-width:1000px;margin:0 auto;padding:12px 16px;height:100%;overflow-y:auto">
|
||||
<div style="margin-bottom:12px">
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}" style="color:var(--accent);text-decoration:none;font-size:12px">← Back to ${nodeName}</a>
|
||||
<h2 style="margin:4px 0 2px;font-size:18px">📊 ${nodeName} — Analytics</h2>
|
||||
<div style="color:var(--text-muted);font-size:11px">${n.role || 'Unknown role'} · ${s.totalTransmissions || s.totalPackets} packets in ${days}d window</div>
|
||||
</div>
|
||||
|
||||
<div class="analytics-time-range" id="timeRangeBtns">
|
||||
<button data-days="1" ${days===1?'class="active"':''}>24h</button>
|
||||
<button data-days="7" ${days===7?'class="active"':''}>7d</button>
|
||||
<button data-days="30" ${days===30?'class="active"':''}>30d</button>
|
||||
<button data-days="365" ${days===365?'class="active"':''}>All</button>
|
||||
</div>
|
||||
|
||||
<div class="analytics-stats">
|
||||
<div class="analytics-stat-card">
|
||||
<div class="analytics-stat-label">Availability</div>
|
||||
<div class="analytics-stat-value">${s.availabilityPct}%</div>
|
||||
<div class="analytics-stat-desc">% of time windows with at least one packet</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card">
|
||||
<div class="analytics-stat-label">Signal Grade</div>
|
||||
<div class="analytics-stat-value" style="color:${GRADE_COLORS[s.signalGrade]||'var(--text)'}">${s.signalGrade}</div>
|
||||
<div class="analytics-stat-desc">A–F based on average SNR across all observers</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card">
|
||||
<div class="analytics-stat-label">Packets / Day</div>
|
||||
<div class="analytics-stat-value">${s.avgPacketsPerDay}</div>
|
||||
<div class="analytics-stat-desc">Average daily packet volume in this window</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card">
|
||||
<div class="analytics-stat-label">Observers</div>
|
||||
<div class="analytics-stat-value">${s.uniqueObservers}</div>
|
||||
<div class="analytics-stat-desc">Distinct stations that heard this node</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card">
|
||||
<div class="analytics-stat-label">Relay %</div>
|
||||
<div class="analytics-stat-value">${s.relayPct}%</div>
|
||||
<div class="analytics-stat-desc">Packets forwarded through repeaters vs direct</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card">
|
||||
<div class="analytics-stat-label">Longest Silence</div>
|
||||
<div class="analytics-stat-value" style="font-size:18px">${formatSilence(s.longestSilenceMs)}</div>
|
||||
<div class="analytics-stat-desc">Longest gap between consecutive packets</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="analytics-charts">
|
||||
<div class="analytics-chart-card full">
|
||||
<h4>Activity Timeline</h4>
|
||||
<div class="analytics-chart-desc">Packet count per time bucket — shows when this node is most active</div>
|
||||
<canvas id="activityChart"></canvas>
|
||||
</div>
|
||||
<div class="analytics-chart-card">
|
||||
<h4>SNR Trend</h4>
|
||||
<div class="analytics-chart-desc">Signal-to-noise ratio over time — higher is better reception</div>
|
||||
<canvas id="snrChart"></canvas>
|
||||
</div>
|
||||
<div class="analytics-chart-card">
|
||||
<h4>Packet Types</h4>
|
||||
<div class="analytics-chart-desc">Breakdown of advert, position, text, and other packet types</div>
|
||||
<canvas id="packetTypeChart"></canvas>
|
||||
</div>
|
||||
<div class="analytics-chart-card">
|
||||
<h4>Observer Coverage</h4>
|
||||
<div class="analytics-chart-desc">Which stations hear this node and how often</div>
|
||||
<canvas id="observerChart"></canvas>
|
||||
</div>
|
||||
<div class="analytics-chart-card">
|
||||
<h4>Hop Distribution</h4>
|
||||
<div class="analytics-chart-desc">How many repeater hops packets take — 0 means direct</div>
|
||||
<canvas id="hopChart"></canvas>
|
||||
</div>
|
||||
<div class="analytics-chart-card full">
|
||||
<h4>Uptime Heatmap</h4>
|
||||
<div class="analytics-chart-desc">Hour-by-hour activity grid — darker = more packets in that slot</div>
|
||||
<div id="heatmapGrid" class="analytics-heatmap"></div>
|
||||
</div>
|
||||
${data.peerInteractions.length ? `<div class="analytics-chart-card full">
|
||||
<h4>Peer Interactions</h4>
|
||||
<div class="analytics-chart-desc">Nodes this device has exchanged messages with</div>
|
||||
<table class="analytics-peer-table">
|
||||
<thead><tr><th>Peer</th><th>Messages</th><th>Last Contact</th></tr></thead>
|
||||
<tbody>${data.peerInteractions.map(p => `<tr>
|
||||
<td><a href="#/nodes/${encodeURIComponent(p.peer_key)}" style="color:var(--accent)">${escapeHtml(p.peer_name)}</a></td>
|
||||
<td>${p.messageCount}</td>
|
||||
<td>${timeAgo(p.lastContact)}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Time range buttons
|
||||
container.querySelectorAll('#timeRangeBtns button').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const d = Number(btn.dataset.days);
|
||||
loadAnalytics(container, pubkey, d);
|
||||
});
|
||||
});
|
||||
|
||||
// Build charts
|
||||
buildActivityChart(data);
|
||||
buildSnrChart(data);
|
||||
buildPacketTypeChart(data);
|
||||
buildObserverChart(data);
|
||||
buildHopChart(data);
|
||||
buildHeatmap(data);
|
||||
}
|
||||
|
||||
function buildActivityChart(data) {
|
||||
const ctx = document.getElementById('activityChart');
|
||||
if (!ctx) return;
|
||||
const tl = data.activityTimeline;
|
||||
const c = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: tl.map(b => {
|
||||
const d = new Date(b.bucket);
|
||||
return currentDays <= 3 ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}),
|
||||
datasets: [{ label: 'Packets', data: tl.map(b => b.count), backgroundColor: 'rgba(74,158,255,0.5)', borderColor: '#4a9eff', borderWidth: 1 }]
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksAutoSkip: true, maxRotation: 45 } }, y: { beginAtZero: true } } }
|
||||
});
|
||||
charts.push(c);
|
||||
}
|
||||
|
||||
function buildSnrChart(data) {
|
||||
const ctx = document.getElementById('snrChart');
|
||||
if (!ctx) return;
|
||||
// Group by observer
|
||||
const byObs = {};
|
||||
data.snrTrend.forEach(p => {
|
||||
const key = p.observer_id || 'unknown';
|
||||
if (!byObs[key]) byObs[key] = { name: p.observer_name || key, points: [] };
|
||||
byObs[key].points.push({ x: new Date(p.timestamp), y: p.snr });
|
||||
});
|
||||
const datasets = Object.values(byObs).map((obs, i) => ({
|
||||
label: obs.name, data: obs.points.map(p => p.y), borderColor: CHART_COLORS[i % CHART_COLORS.length],
|
||||
backgroundColor: 'transparent', pointRadius: 1, borderWidth: 1.5, tension: 0.3
|
||||
}));
|
||||
// Use labels from the observer with most points
|
||||
const longestObs = Object.values(byObs).sort((a, b) => b.points.length - a.points.length)[0];
|
||||
const labels = longestObs ? longestObs.points.map(p => {
|
||||
const d = p.x;
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}) : [];
|
||||
const c = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: { x: { display: false }, y: { title: { display: true, text: 'SNR (dB)' } } },
|
||||
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 10 } } } }
|
||||
}
|
||||
});
|
||||
charts.push(c);
|
||||
}
|
||||
|
||||
function buildPacketTypeChart(data) {
|
||||
const ctx = document.getElementById('packetTypeChart');
|
||||
if (!ctx) return;
|
||||
const items = data.packetTypeBreakdown;
|
||||
const c = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: items.map(i => PAYLOAD_LABELS[i.payload_type] || 'Type ' + i.payload_type),
|
||||
datasets: [{ data: items.map(i => i.count), backgroundColor: items.map((_, i) => CHART_COLORS[i % CHART_COLORS.length]) }]
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 10 } } } } }
|
||||
});
|
||||
charts.push(c);
|
||||
}
|
||||
|
||||
function buildObserverChart(data) {
|
||||
const ctx = document.getElementById('observerChart');
|
||||
if (!ctx) return;
|
||||
const obs = data.observerCoverage;
|
||||
const c = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: obs.map(o => (o.observer_name || o.observer_id || '?').slice(0, 20)),
|
||||
datasets: [{ label: 'Packets', data: obs.map(o => o.packetCount), backgroundColor: obs.map(o => {
|
||||
const snr = o.avgSnr || 0;
|
||||
const alpha = Math.min(1, Math.max(0.3, snr / 20));
|
||||
return `rgba(74,158,255,${alpha})`;
|
||||
}) }]
|
||||
},
|
||||
options: { indexAxis: 'y', responsive: true, plugins: { legend: { display: false } }, scales: { x: { beginAtZero: true } } }
|
||||
});
|
||||
charts.push(c);
|
||||
}
|
||||
|
||||
function buildHopChart(data) {
|
||||
const ctx = document.getElementById('hopChart');
|
||||
if (!ctx) return;
|
||||
const hops = data.hopDistribution;
|
||||
const c = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: hops.map(h => h.hops + ' hop' + (h.hops !== '1' ? 's' : '')),
|
||||
datasets: [{ label: 'Packets', data: hops.map(h => h.count), backgroundColor: 'rgba(81,207,102,0.6)', borderColor: '#51cf66', borderWidth: 1 }]
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } }
|
||||
});
|
||||
charts.push(c);
|
||||
}
|
||||
|
||||
function buildHeatmap(data) {
|
||||
const grid = document.getElementById('heatmapGrid');
|
||||
if (!grid) return;
|
||||
// Build lookup
|
||||
const lookup = {};
|
||||
let maxCount = 1;
|
||||
data.uptimeHeatmap.forEach(h => {
|
||||
const key = h.dayOfWeek + '-' + h.hour;
|
||||
lookup[key] = h.count;
|
||||
if (h.count > maxCount) maxCount = h.count;
|
||||
});
|
||||
|
||||
// Header row
|
||||
grid.innerHTML = '<div class="analytics-heatmap-label"></div>';
|
||||
for (let h = 0; h < 24; h++) {
|
||||
grid.innerHTML += `<div class="analytics-heatmap-label" style="justify-content:center;font-size:9px">${h}</div>`;
|
||||
}
|
||||
// Day rows
|
||||
for (let d = 0; d < 7; d++) {
|
||||
grid.innerHTML += `<div class="analytics-heatmap-label">${DAY_NAMES[d]}</div>`;
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const count = lookup[d + '-' + h] || 0;
|
||||
const intensity = count / maxCount;
|
||||
const bg = count === 0 ? 'var(--card-bg)' : `rgba(74,158,255,${0.15 + intensity * 0.85})`;
|
||||
grid.innerHTML += `<div class="analytics-heatmap-cell" style="background:${bg}" title="${DAY_NAMES[d]} ${h}:00 — ${count} packets"></div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function init(container, routeParam) {
|
||||
// routeParam is "PUBKEY/analytics"
|
||||
if (!routeParam || !routeParam.endsWith('/analytics')) {
|
||||
container.innerHTML = '<div style="padding:40px;text-align:center">Invalid analytics URL</div>';
|
||||
return;
|
||||
}
|
||||
const pubkey = routeParam.slice(0, -'/analytics'.length);
|
||||
loadAnalytics(container, pubkey, 7);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
destroyCharts();
|
||||
currentPubkey = null;
|
||||
}
|
||||
|
||||
registerPage('node-analytics', { init, destroy });
|
||||
})();
|
||||
@@ -5,10 +5,16 @@
|
||||
let nodes = [];
|
||||
const PAYLOAD_TYPES = {0:'Request',1:'Response',2:'Direct Msg',3:'ACK',4:'Advert',5:'Channel Msg',7:'Anon Req',8:'Path',9:'Trace'};
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (!s) return '';
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
function syncClaimedToFavorites() {
|
||||
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
|
||||
const favs = getFavorites();
|
||||
let changed = false;
|
||||
myNodes.forEach(mn => {
|
||||
if (!favs.includes(mn.pubkey)) { favs.push(mn.pubkey); changed = true; }
|
||||
});
|
||||
if (changed) localStorage.setItem('meshcore-favorites', JSON.stringify(favs));
|
||||
}
|
||||
|
||||
let counts = {};
|
||||
let selectedKey = null;
|
||||
let activeTab = 'all';
|
||||
@@ -16,8 +22,9 @@
|
||||
let sortBy = 'lastSeen';
|
||||
let lastHeard = '';
|
||||
let wsHandler = null;
|
||||
let detailMap = null;
|
||||
|
||||
const ROLE_COLORS = { repeater: '#3b82f6', room: '#6b7280', companion: '#22c55e', sensor: '#f59e0b' };
|
||||
// ROLE_COLORS loaded from shared roles.js
|
||||
const TABS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'repeater', label: 'Repeaters' },
|
||||
@@ -35,20 +42,28 @@
|
||||
// Full-screen single node view
|
||||
app.innerHTML = `<div class="node-fullscreen">
|
||||
<div class="node-full-header">
|
||||
<button class="ch-back-btn node-back-btn" onclick="location.hash='#/nodes'" aria-label="Back to nodes">←</button>
|
||||
<button class="detail-back-btn node-back-btn" id="nodeBackBtn" aria-label="Back to nodes">←</button>
|
||||
<span class="node-full-title">Loading…</span>
|
||||
</div>
|
||||
<div class="node-full-body" id="nodeFullBody">
|
||||
<div class="text-center text-muted" style="padding:40px">Loading…</div>
|
||||
</div>
|
||||
</div>`;
|
||||
document.getElementById('nodeBackBtn').addEventListener('click', () => { location.hash = '#/nodes'; });
|
||||
loadFullNode(directNode);
|
||||
// Escape to go back to nodes list
|
||||
document.addEventListener('keydown', function nodesEsc(e) {
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', nodesEsc);
|
||||
location.hash = '#/nodes';
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
app.innerHTML = `<div class="nodes-page">
|
||||
<div class="nodes-topbar">
|
||||
<input type="text" class="nodes-search" id="nodeSearch" placeholder="Search nodes by name…">
|
||||
<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 class="split-layout">
|
||||
@@ -63,16 +78,15 @@
|
||||
}, 250));
|
||||
|
||||
loadNodes();
|
||||
wsHandler = msg => { if (msg.type === 'packet') loadNodes(); };
|
||||
onWS(wsHandler);
|
||||
wsHandler = debouncedOnWS(function (msgs) { if (msgs.some(function (m) { return m.type === 'packet'; })) loadNodes(); });
|
||||
}
|
||||
|
||||
async function loadFullNode(pubkey) {
|
||||
const body = document.getElementById('nodeFullBody');
|
||||
try {
|
||||
const [nodeData, healthData] = await Promise.all([
|
||||
api('/nodes/' + encodeURIComponent(pubkey)),
|
||||
api('/nodes/' + encodeURIComponent(pubkey) + '/health').catch(() => null)
|
||||
api('/nodes/' + encodeURIComponent(pubkey), { ttl: CLIENT_TTL.nodeDetail }),
|
||||
api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeDetail }).catch(() => null)
|
||||
]);
|
||||
const n = nodeData.node;
|
||||
const adverts = nodeData.recentAdverts || [];
|
||||
@@ -89,71 +103,82 @@
|
||||
const recent = h.recentPackets || [];
|
||||
const lastHeard = stats.lastHeard;
|
||||
const statusAge = lastHeard ? (Date.now() - new Date(lastHeard).getTime()) : Infinity;
|
||||
const statusLabel = statusAge < 3600000 ? '🟢 Active' : statusAge < 86400000 ? '🟡 Degraded' : '🔴 Silent';
|
||||
// Thresholds based on MeshCore advert intervals:
|
||||
// Repeaters/rooms: flood advert every 12-24h, so degraded after 24h, silent after 72h
|
||||
// Companions/sensors: user-initiated adverts, shorter thresholds
|
||||
const role = (n.role || '').toLowerCase();
|
||||
const { degradedMs, silentMs } = getHealthThresholds(role);
|
||||
const statusLabel = statusAge < degradedMs ? '🟢 Active' : statusAge < silentMs ? '🟡 Degraded' : '🔴 Silent';
|
||||
|
||||
body.innerHTML = `
|
||||
${hasLoc ? `<div id="nodeFullMap" style="height:200px;border-radius:8px;overflow:hidden;margin-bottom:16px"></div>` : ''}
|
||||
${hasLoc ? `<div id="nodeFullMap" class="node-detail-map" style="border-radius:8px;overflow:hidden;margin-bottom:16px"></div>` : ''}
|
||||
<div class="node-full-card">
|
||||
<div class="node-detail-name" style="font-size:20px">${escapeHtml(n.name || '(unnamed)')}</div>
|
||||
<div style="margin:6px 0 12px"><span class="badge" style="background:${roleColor}20;color:${roleColor}">${n.role}</span> ${statusLabel}</div>
|
||||
<div class="node-detail-key mono" style="font-size:11px;word-break:break-all;margin-bottom:12px">${n.public_key}</div>
|
||||
<div class="node-detail-key mono" style="font-size:11px;word-break:break-all;margin-bottom:8px">${n.public_key}</div>
|
||||
<div style="margin-bottom:12px">
|
||||
<button class="btn-primary" id="copyUrlBtn" style="font-size:12px;padding:4px 10px">📋 Copy URL</button>
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="display:inline-block;margin-left:6px;text-decoration:none;font-size:12px;padding:4px 10px">📊 Analytics</a>
|
||||
</div>
|
||||
<div class="node-qr" id="nodeFullQrCode"></div>
|
||||
</div>
|
||||
|
||||
<div class="node-full-card">
|
||||
<h4>Stats</h4>
|
||||
<dl class="detail-meta">
|
||||
<dt>First Seen</dt><dd>${n.first_seen ? new Date(n.first_seen).toLocaleString() : '—'}</dd>
|
||||
<dt>Last Heard</dt><dd>${lastHeard ? timeAgo(lastHeard) : (n.last_seen ? timeAgo(n.last_seen) : '—')}</dd>
|
||||
<dt>Total Packets</dt><dd>${stats.totalPackets || n.advert_count || 0}</dd>
|
||||
<dt>First Seen</dt><dd>${n.first_seen ? new Date(n.first_seen).toLocaleString() : '—'}</dd>
|
||||
<dt>Total Packets</dt><dd>${stats.totalTransmissions || stats.totalPackets || n.advert_count || 0}${stats.totalObservations && stats.totalObservations !== (stats.totalTransmissions || stats.totalPackets) ? ' <span class="text-muted" style="font-size:0.85em">(seen ' + stats.totalObservations + '×)</span>' : ''}</dd>
|
||||
<dt>Packets Today</dt><dd>${stats.packetsToday || 0}</dd>
|
||||
<dt>Observers</dt><dd>${observers.length || 0}${observers.length ? ' (' + observers.map(o => escapeHtml(o.observer_name || o.observer_id)).join(', ') + ')' : ''}</dd>
|
||||
${stats.avgSnr != null ? `<dt>Avg SNR</dt><dd>${stats.avgSnr.toFixed(1)} dB</dd>` : ''}
|
||||
${stats.avgHops ? `<dt>Avg Hops</dt><dd>${stats.avgHops}</dd>` : ''}
|
||||
${hasLoc ? `<dt>Location</dt><dd>${n.lat.toFixed(5)}, ${n.lon.toFixed(5)}</dd>` : ''}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
${observers.length ? `<div class="node-full-card">
|
||||
<h4>Heard By (${observers.length} observer${observers.length > 1 ? 's' : ''})</h4>
|
||||
<table class="data-table" style="font-size:12px">
|
||||
<thead><tr><th>Observer</th><th>Packets</th><th>Avg SNR</th><th>Avg RSSI</th></tr></thead>
|
||||
<tbody>
|
||||
${observers.map(o => `<tr>
|
||||
<td style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}</td>
|
||||
<td>${o.packetCount}</td>
|
||||
<td>${o.avgSnr != null ? o.avgSnr.toFixed(1) + ' dB' : '—'}</td>
|
||||
<td>${o.avgRssi != null ? o.avgRssi.toFixed(0) + ' dBm' : '—'}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="node-full-card">
|
||||
<h4>Recent Activity (${recent.length})</h4>
|
||||
<h4>Recent Packets (${adverts.length})</h4>
|
||||
<div class="node-activity-list">
|
||||
${recent.length ? recent.slice(0, 20).map(p => {
|
||||
${adverts.length ? adverts.map(p => {
|
||||
let decoded; try { decoded = JSON.parse(p.decoded_json); } catch {}
|
||||
const typeLabel = p.payload_type === 4 ? '📡 Advert' : p.payload_type === 5 ? '💬 Channel' : p.payload_type === 2 ? '✉️ DM' : 'Packet';
|
||||
const detail = decoded?.text ? ': ' + escapeHtml(truncate(decoded.text, 50)) : '';
|
||||
const snr = p.snr != null ? ` · SNR ${p.snr}dB` : (decoded?.SNR != null ? ` · SNR ${decoded.SNR}dB` : '');
|
||||
const typeLabel = p.payload_type === 4 ? '📡 Advert' : p.payload_type === 5 ? '💬 Channel' : p.payload_type === 2 ? '✉️ DM' : '📦 Packet';
|
||||
const detail = decoded?.text ? ': ' + escapeHtml(truncate(decoded.text, 50)) : decoded?.name ? ' — ' + escapeHtml(decoded.name) : '';
|
||||
const obs = p.observer_name || p.observer_id;
|
||||
const snr = p.snr != null ? ` · SNR ${p.snr}dB` : '';
|
||||
const rssi = p.rssi != null ? ` · RSSI ${p.rssi}dBm` : '';
|
||||
const obsBadge = p.observation_count > 1 ? ` <span class="badge badge-obs" title="Seen ${p.observation_count} times">👁 ${p.observation_count}</span>` : '';
|
||||
return `<div class="node-activity-item">
|
||||
<span class="node-activity-time">${timeAgo(p.timestamp)}</span>
|
||||
<span>${typeLabel}${detail}${snr}</span>
|
||||
<span>${typeLabel}${detail}${obsBadge}${obs ? ' via ' + escapeHtml(obs) : ''}${snr}${rssi}</span>
|
||||
<a href="#/packets/id/${p.id}" class="ch-analyze-link" style="margin-left:8px;font-size:0.8em">Analyze →</a>
|
||||
</div>`;
|
||||
}).join('') : '<div class="text-muted">No recent activity</div>'}
|
||||
}).join('') : '<div class="text-muted">No recent packets</div>'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="node-full-card">
|
||||
<h4>Recent Adverts (${adverts.length})</h4>
|
||||
<div id="advertTimeline">
|
||||
${adverts.length ? adverts.map(a => {
|
||||
return `<div class="advert-entry">
|
||||
<span class="advert-dot" style="background:${roleColor}"></span>
|
||||
<div class="advert-info">
|
||||
<strong>${timeAgo(a.timestamp)}</strong> — Observer: ${a.observer_id || '—'}
|
||||
${a.snr != null ? ` · SNR ${a.snr}dB` : ''}${a.rssi != null ? ` · RSSI ${a.rssi}dBm` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('') : '<div class="text-muted">No recent adverts</div>'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;padding:16px">
|
||||
<button class="btn-primary" id="copyUrlBtn">📋 Copy URL</button>
|
||||
</div>`;
|
||||
|
||||
// Map
|
||||
if (hasLoc) {
|
||||
try {
|
||||
const map = L.map('nodeFullMap', { zoomControl: true, attributionControl: false }).setView([n.lat, n.lon], 13);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 }).addTo(map);
|
||||
L.marker([n.lat, n.lon]).addTo(map).bindPopup(n.name || n.public_key.slice(0, 12));
|
||||
setTimeout(() => map.invalidateSize(), 100);
|
||||
if (detailMap) { detailMap.remove(); detailMap = null; }
|
||||
detailMap = L.map('nodeFullMap', { zoomControl: true, attributionControl: false }).setView([n.lat, n.lon], 13);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 }).addTo(detailMap);
|
||||
L.marker([n.lat, n.lon]).addTo(detailMap).bindPopup(n.name || n.public_key.slice(0, 12));
|
||||
setTimeout(() => detailMap.invalidateSize(), 100);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -167,6 +192,22 @@
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
// QR code for full-screen view
|
||||
const qrFullEl = document.getElementById('nodeFullQrCode');
|
||||
if (qrFullEl && typeof qrcode === 'function') {
|
||||
try {
|
||||
const typeMap = { companion: 1, repeater: 2, room: 3, sensor: 4 };
|
||||
const contactType = typeMap[(n.role || '').toLowerCase()] || 2;
|
||||
const meshcoreUrl = `meshcore://contact/add?name=${encodeURIComponent(n.name || 'Unknown')}&public_key=${n.public_key}&type=${contactType}`;
|
||||
const qr = qrcode(0, 'M');
|
||||
qr.addData(meshcoreUrl);
|
||||
qr.make();
|
||||
qrFullEl.innerHTML = `<div style="font-size:11px;color:var(--text-muted);margin-bottom:4px">Scan with MeshCore app to add contact</div>` + qr.createSvgTag(3, 0);
|
||||
const svg = qrFullEl.querySelector('svg');
|
||||
if (svg) { svg.style.display = 'block'; svg.style.margin = '0 auto'; }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
body.innerHTML = `<div class="text-muted" style="padding:40px">Failed to load node: ${e.message}</div>`;
|
||||
}
|
||||
@@ -175,6 +216,7 @@
|
||||
function destroy() {
|
||||
if (wsHandler) offWS(wsHandler);
|
||||
wsHandler = null;
|
||||
if (detailMap) { detailMap.remove(); detailMap = null; }
|
||||
nodes = [];
|
||||
selectedKey = null;
|
||||
}
|
||||
@@ -185,13 +227,32 @@
|
||||
if (activeTab !== 'all') params.set('role', activeTab);
|
||||
if (search) params.set('search', search);
|
||||
if (lastHeard) params.set('lastHeard', lastHeard);
|
||||
const data = await api('/nodes?' + params);
|
||||
const data = await api('/nodes?' + params, { ttl: CLIENT_TTL.nodeList });
|
||||
nodes = data.nodes || [];
|
||||
counts = data.counts || {};
|
||||
|
||||
// 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));
|
||||
const missing = myNodes.filter(mn => !existingKeys.has(mn.pubkey));
|
||||
if (missing.length) {
|
||||
const fetched = await Promise.allSettled(
|
||||
missing.map(mn => api('/nodes/' + encodeURIComponent(mn.pubkey), { ttl: CLIENT_TTL.nodeDetail }))
|
||||
);
|
||||
fetched.forEach(r => {
|
||||
if (r.status === 'fulfilled' && r.value && r.value.public_key) nodes.push(r.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-sync claimed → favorites
|
||||
syncClaimedToFavorites();
|
||||
|
||||
renderCounts();
|
||||
renderLeft();
|
||||
} catch (e) {
|
||||
console.error('Failed to load nodes:', e);
|
||||
const tbody = document.getElementById('nodesBody');
|
||||
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="text-center" style="padding:24px;color:var(--error,#ef4444)"><div role="alert" aria-live="polite">Failed to load nodes. Please try again.</div></td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +277,7 @@
|
||||
${TABS.map(t => `<button class="node-tab ${activeTab === t.key ? 'active' : ''}" data-tab="${t.key}">${t.label}</button>`).join('')}
|
||||
</div>
|
||||
<div class="nodes-filters">
|
||||
<select id="nodeLastHeard">
|
||||
<select id="nodeLastHeard" aria-label="Filter by last heard time">
|
||||
<option value="">Last Heard: Any</option>
|
||||
<option value="1h" ${lastHeard==='1h'?'selected':''}>1 hour</option>
|
||||
<option value="6h" ${lastHeard==='6h'?'selected':''}>6 hours</option>
|
||||
@@ -224,7 +285,7 @@
|
||||
<option value="7d" ${lastHeard==='7d'?'selected':''}>7 days</option>
|
||||
<option value="30d" ${lastHeard==='30d'?'selected':''}>30 days</option>
|
||||
</select>
|
||||
<select id="nodeSort">
|
||||
<select id="nodeSort" aria-label="Sort nodes">
|
||||
<option value="lastSeen" ${sortBy==='lastSeen'?'selected':''}>Sort: Last Seen</option>
|
||||
<option value="name" ${sortBy==='name'?'selected':''}>Sort: Name</option>
|
||||
<option value="packetCount" ${sortBy==='packetCount'?'selected':''}>Sort: Adverts</option>
|
||||
@@ -233,17 +294,18 @@
|
||||
</div>
|
||||
<table class="data-table" id="nodesTable">
|
||||
<thead><tr>
|
||||
<th class="sortable" data-sort="name">Name</th>
|
||||
<th class="sortable" data-sort="name" aria-sort="${sortBy === 'name' ? 'ascending' : 'none'}">Name</th>
|
||||
<th>Public Key</th>
|
||||
<th>Role</th>
|
||||
<th>Regions</th>
|
||||
<th class="sortable" data-sort="lastSeen">Last Seen</th>
|
||||
<th class="sortable" data-sort="packetCount">Adverts</th>
|
||||
<th class="sortable" data-sort="lastSeen" aria-sort="${sortBy === 'lastSeen' ? 'descending' : 'none'}">Last Seen</th>
|
||||
<th class="sortable" data-sort="packetCount" aria-sort="${sortBy === 'packetCount' ? 'descending' : 'none'}">Adverts</th>
|
||||
</tr></thead>
|
||||
<tbody id="nodesBody"></tbody>
|
||||
</table>`;
|
||||
|
||||
// Tab clicks
|
||||
const nodeTabs = document.getElementById('nodeTabs');
|
||||
initTabBar(nodeTabs);
|
||||
el.querySelectorAll('.node-tab').forEach(btn => {
|
||||
btn.addEventListener('click', () => { activeTab = btn.dataset.tab; loadNodes(); });
|
||||
});
|
||||
@@ -257,6 +319,33 @@
|
||||
th.addEventListener('click', () => { sortBy = th.dataset.sort; loadNodes(); });
|
||||
});
|
||||
|
||||
// Delegated click/keyboard handler for table rows
|
||||
const tbody = document.getElementById('nodesBody');
|
||||
if (tbody) {
|
||||
const handler = (e) => {
|
||||
const row = e.target.closest('tr[data-action="select"]');
|
||||
if (!row) return;
|
||||
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
|
||||
if (e.type === 'keydown') e.preventDefault();
|
||||
selectNode(row.dataset.value);
|
||||
};
|
||||
tbody.addEventListener('click', handler);
|
||||
tbody.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
// Escape to close node detail panel
|
||||
document.addEventListener('keydown', function nodesPanelEsc(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const panel = document.getElementById('nodesRight');
|
||||
if (panel && !panel.classList.contains('empty')) {
|
||||
panel.classList.add('empty');
|
||||
panel.innerHTML = '<span>Select a node to view details</span>';
|
||||
selectedKey = null;
|
||||
renderRows();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
renderRows();
|
||||
}
|
||||
|
||||
@@ -269,13 +358,26 @@
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = nodes.map(n => {
|
||||
// Claimed ("My Mesh") nodes always on top, then favorites
|
||||
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
|
||||
const myKeys = new Set(myNodes.map(n => n.pubkey));
|
||||
const favs = getFavorites();
|
||||
const sorted = [...nodes].sort((a, b) => {
|
||||
const aMy = myKeys.has(a.public_key) ? 0 : 1;
|
||||
const bMy = myKeys.has(b.public_key) ? 0 : 1;
|
||||
if (aMy !== bMy) return aMy - bMy;
|
||||
const aFav = favs.includes(a.public_key) ? 0 : 1;
|
||||
const bFav = favs.includes(b.public_key) ? 0 : 1;
|
||||
return aFav - bFav;
|
||||
});
|
||||
|
||||
tbody.innerHTML = sorted.map(n => {
|
||||
const roleColor = ROLE_COLORS[n.role] || '#6b7280';
|
||||
return `<tr data-key="${n.public_key}" onclick="window._nodeSelect('${n.public_key}')" class="${selectedKey === n.public_key ? 'selected' : ''}">
|
||||
<td>${favStar(n.public_key, 'node-fav')}<strong>${n.name || '(unnamed)'}</strong></td>
|
||||
const isClaimed = myKeys.has(n.public_key);
|
||||
return `<tr data-key="${n.public_key}" data-action="select" data-value="${n.public_key}" tabindex="0" role="row" class="${selectedKey === n.public_key ? 'selected' : ''}${isClaimed ? ' claimed-row' : ''}">
|
||||
<td>${favStar(n.public_key, 'node-fav')}${isClaimed ? '<span class="claimed-badge" title="My Mesh">★</span> ' : ''}<strong>${n.name || '(unnamed)'}</strong></td>
|
||||
<td class="mono">${truncate(n.public_key, 16)}</td>
|
||||
<td><span class="badge" style="background:${roleColor}20;color:${roleColor}">${n.role}</span></td>
|
||||
<td>—</td>
|
||||
<td>${timeAgo(n.last_seen)}</td>
|
||||
<td>${n.advert_count || 0}</td>
|
||||
</tr>`;
|
||||
@@ -298,8 +400,8 @@
|
||||
|
||||
try {
|
||||
const [data, healthData] = await Promise.all([
|
||||
api('/nodes/' + encodeURIComponent(pubkey)),
|
||||
api('/nodes/' + encodeURIComponent(pubkey) + '/health').catch(() => null)
|
||||
api('/nodes/' + encodeURIComponent(pubkey), { ttl: CLIENT_TTL.nodeDetail }),
|
||||
api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeDetail }).catch(() => null)
|
||||
]);
|
||||
data.healthData = healthData;
|
||||
renderDetail(panel, data);
|
||||
@@ -311,55 +413,81 @@
|
||||
function renderDetail(panel, data) {
|
||||
const n = data.node;
|
||||
const adverts = data.recentAdverts || [];
|
||||
const recent = data.healthData?.recentPackets || [];
|
||||
const h = data.healthData || {};
|
||||
const stats = h.stats || {};
|
||||
const observers = h.observers || [];
|
||||
const recent = h.recentPackets || [];
|
||||
const roleColor = ROLE_COLORS[n.role] || '#6b7280';
|
||||
const hasLoc = n.lat != null && n.lon != null;
|
||||
const nodeUrl = location.origin + '#/nodes/' + encodeURIComponent(n.public_key);
|
||||
|
||||
// Status calculation
|
||||
const lastHeard = stats.lastHeard;
|
||||
const statusAge = lastHeard ? (Date.now() - new Date(lastHeard).getTime()) : Infinity;
|
||||
const role = (n.role || '').toLowerCase();
|
||||
const { degradedMs, silentMs } = getHealthThresholds(role);
|
||||
const statusLabel = statusAge < degradedMs ? '🟢 Active' : statusAge < silentMs ? '🟡 Degraded' : '🔴 Silent';
|
||||
const totalPackets = stats.totalTransmissions || stats.totalPackets || n.advert_count || 0;
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="node-detail">
|
||||
${hasLoc ? `<div class="node-map-container" id="nodeMap" style="height:180px;border-radius:8px;overflow:hidden;"></div>` : ''}
|
||||
<div class="node-detail-name">${n.name || '(unnamed)'}</div>
|
||||
<div class="node-detail-role"><span class="badge" style="background:${roleColor}20;color:${roleColor}">${n.role}</span></div>
|
||||
${hasLoc ? `<div class="node-map-container node-detail-map" id="nodeMap" style="border-radius:8px;overflow:hidden;"></div>` : ''}
|
||||
<div class="node-detail-name">${escapeHtml(n.name || '(unnamed)')}</div>
|
||||
<div class="node-detail-role"><span class="badge" style="background:${roleColor}20;color:${roleColor}">${n.role}</span> ${statusLabel}
|
||||
<button class="btn-primary" id="copyUrlBtn" style="font-size:11px;padding:2px 8px;margin-left:8px">📋 URL</button>
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="display:inline-block;margin-left:4px;text-decoration:none;font-size:11px;padding:2px 8px">📊 Analytics</a>
|
||||
</div>
|
||||
|
||||
<div class="node-detail-section">
|
||||
<h4>Public Key</h4>
|
||||
<div class="node-detail-key mono">${n.public_key}</div>
|
||||
${(n.advert_count || 0) > 0 ? `<div class="node-qr" id="nodeQrCode"></div>` : ''}
|
||||
<div class="node-qr" id="nodeQrCode"></div>
|
||||
</div>
|
||||
|
||||
<div class="node-detail-section">
|
||||
<h4>Info</h4>
|
||||
<h4>Overview</h4>
|
||||
<dl class="detail-meta">
|
||||
<dt>Last Heard</dt><dd>${lastHeard ? timeAgo(lastHeard) : (n.last_seen ? timeAgo(n.last_seen) : '—')}</dd>
|
||||
<dt>First Seen</dt><dd>${n.first_seen ? new Date(n.first_seen).toLocaleString() : '—'}</dd>
|
||||
<dt>Last Seen</dt><dd>${n.last_seen ? timeAgo(n.last_seen) : '—'}</dd>
|
||||
<dt>Adverts</dt><dd>${n.advert_count || 0}</dd>
|
||||
<dt>Total Packets</dt><dd>${totalPackets}</dd>
|
||||
<dt>Packets Today</dt><dd>${stats.packetsToday || 0}</dd>
|
||||
${stats.avgSnr != null ? `<dt>Avg SNR</dt><dd>${stats.avgSnr.toFixed(1)} dB</dd>` : ''}
|
||||
${stats.avgHops ? `<dt>Avg Hops</dt><dd>${stats.avgHops}</dd>` : ''}
|
||||
${hasLoc ? `<dt>Location</dt><dd>${n.lat.toFixed(5)}, ${n.lon.toFixed(5)}</dd>` : ''}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;margin-bottom:16px">
|
||||
<button class="btn-primary" id="copyUrlBtn">📋 Copy URL</button>
|
||||
</div>
|
||||
${observers.length ? `<div class="node-detail-section">
|
||||
<h4>Heard By (${observers.length} observer${observers.length > 1 ? 's' : ''})</h4>
|
||||
<div class="observer-list">
|
||||
${observers.map(o => `<div class="observer-row" style="display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid var(--border);font-size:12px">
|
||||
<span style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}</span>
|
||||
<span style="color:var(--text-muted)">${o.packetCount} pkts · ${o.avgSnr != null ? 'SNR ' + o.avgSnr.toFixed(1) + 'dB' : ''}${o.avgRssi != null ? ' · RSSI ' + o.avgRssi.toFixed(0) : ''}</span>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="node-detail-section">
|
||||
<h4>Recent Activity (${recent.length})</h4>
|
||||
<h4>Recent Packets (${adverts.length})</h4>
|
||||
<div id="advertTimeline">
|
||||
${recent.length ? recent.map(a => {
|
||||
${adverts.length ? adverts.map(a => {
|
||||
let decoded;
|
||||
try { decoded = JSON.parse(a.decoded_json); } catch {}
|
||||
const pType = PAYLOAD_TYPES[a.payload_type] || 'Packet';
|
||||
const icon = a.payload_type === 4 ? '📡' : a.payload_type === 5 ? '💬' : a.payload_type === 2 ? '✉️' : '📦';
|
||||
const detail = decoded?.text ? ': ' + truncate(decoded.text, 50) : decoded?.name ? ' — ' + decoded.name : '';
|
||||
const detail = decoded?.text ? ': ' + escapeHtml(truncate(decoded.text, 50)) : decoded?.name ? ' — ' + escapeHtml(decoded.name) : '';
|
||||
const obs = a.observer_name || a.observer_id;
|
||||
return `<div class="advert-entry">
|
||||
<span class="advert-dot" style="background:${roleColor}"></span>
|
||||
<div class="advert-info">
|
||||
<strong>${timeAgo(a.timestamp)}</strong> ${icon} ${pType}${detail}
|
||||
${a.observation_count > 1 ? ' <span class="badge badge-obs">👁 ' + a.observation_count + '</span>' : ''}
|
||||
${obs ? ' via ' + escapeHtml(obs) : ''}
|
||||
${a.snr != null ? ` · SNR ${a.snr}dB` : ''}${a.rssi != null ? ` · RSSI ${a.rssi}dBm` : ''}
|
||||
<br><a href="#/packets/id/${a.id}" class="ch-analyze-link">Analyze →</a>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('') : '<div class="text-muted" style="padding:8px">No recent activity</div>'}
|
||||
}).join('') : '<div class="text-muted" style="padding:8px">No recent packets</div>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -367,10 +495,11 @@
|
||||
// Init map
|
||||
if (hasLoc) {
|
||||
try {
|
||||
const map = L.map('nodeMap', { zoomControl: false, attributionControl: false }).setView([n.lat, n.lon], 13);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 }).addTo(map);
|
||||
L.marker([n.lat, n.lon]).addTo(map).bindPopup(n.name || n.public_key.slice(0, 12));
|
||||
setTimeout(() => map.invalidateSize(), 100);
|
||||
if (detailMap) { detailMap.remove(); detailMap = null; }
|
||||
detailMap = L.map('nodeMap', { zoomControl: false, attributionControl: false }).setView([n.lat, n.lon], 13);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 }).addTo(detailMap);
|
||||
L.marker([n.lat, n.lon]).addTo(detailMap).bindPopup(n.name || n.public_key.slice(0, 12));
|
||||
setTimeout(() => detailMap.invalidateSize(), 100);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -401,13 +530,5 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Minimal QR-like visual (encode pubkey as a grid pattern - not a real QR but visually useful)
|
||||
function debounce(fn, ms) {
|
||||
let t;
|
||||
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
||||
}
|
||||
|
||||
window._nodeSelect = selectNode;
|
||||
|
||||
registerPage('nodes', { init, destroy });
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
/* === MeshCore Analyzer — observer-detail.js === */
|
||||
'use strict';
|
||||
(function () {
|
||||
const PAYLOAD_LABELS = { 0: 'Request', 1: 'Response', 2: 'Direct Msg', 3: 'ACK', 4: 'Advert', 5: 'Channel Msg', 7: 'Anon Req', 8: 'Path', 9: 'Trace', 11: 'Control' };
|
||||
const CHART_COLORS = ['#4a9eff', '#ff6b6b', '#51cf66', '#fcc419', '#cc5de8', '#20c997', '#ff922b', '#845ef7', '#f06595', '#339af0'];
|
||||
|
||||
let charts = [];
|
||||
let currentDays = 7;
|
||||
let currentId = null;
|
||||
|
||||
function destroyCharts() {
|
||||
charts.forEach(c => { try { c.destroy(); } catch {} });
|
||||
charts = [];
|
||||
}
|
||||
|
||||
function chartDefaults() {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
Chart.defaults.color = style.getPropertyValue('--text-muted').trim() || '#6b7280';
|
||||
Chart.defaults.borderColor = style.getPropertyValue('--border').trim() || '#e2e5ea';
|
||||
}
|
||||
|
||||
function formatDuration(secs) {
|
||||
if (!secs) return '—';
|
||||
const d = Math.floor(secs / 86400);
|
||||
const h = Math.floor((secs % 86400) / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
if (d > 0) return d + 'd ' + h + 'h';
|
||||
if (h > 0) return h + 'h ' + m + 'm';
|
||||
return m + 'm';
|
||||
}
|
||||
|
||||
function init(app, routeParam) {
|
||||
currentId = routeParam;
|
||||
if (!currentId) {
|
||||
app.innerHTML = '<div class="text-center text-muted" style="padding:40px">No observer ID specified.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="observer-detail-page" style="overflow-y:auto;height:calc(100vh - 56px);padding:16px">
|
||||
<div class="page-header" style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
|
||||
<a href="#/observers" class="btn-icon" title="Back to Observers" aria-label="Back">←</a>
|
||||
<h2 style="margin:0" id="obsTitle">Observer Detail</h2>
|
||||
<div style="margin-left:auto;display:flex;gap:8px">
|
||||
<select id="obsDaysSelect" class="time-range-select" aria-label="Time range">
|
||||
<option value="1">24 Hours</option>
|
||||
<option value="3">3 Days</option>
|
||||
<option value="7" selected>7 Days</option>
|
||||
<option value="30">30 Days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="obsDetailContent"><div class="text-center text-muted" style="padding:40px">Loading…</div></div>
|
||||
</div>`;
|
||||
|
||||
document.getElementById('obsDaysSelect').addEventListener('change', function (e) {
|
||||
currentDays = parseInt(e.target.value);
|
||||
loadDetail();
|
||||
});
|
||||
|
||||
loadDetail();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
destroyCharts();
|
||||
currentId = null;
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
try {
|
||||
destroyCharts();
|
||||
chartDefaults();
|
||||
const [obs, analytics] = await Promise.all([
|
||||
api('/observers/' + encodeURIComponent(currentId)),
|
||||
api('/observers/' + encodeURIComponent(currentId) + '/analytics?days=' + currentDays),
|
||||
]);
|
||||
renderDetail(obs, analytics);
|
||||
} catch (e) {
|
||||
document.getElementById('obsDetailContent').innerHTML =
|
||||
'<div class="text-muted" style="padding:40px">Error: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDetail(obs, analytics) {
|
||||
const el = document.getElementById('obsDetailContent');
|
||||
if (!el) return;
|
||||
|
||||
const title = document.getElementById('obsTitle');
|
||||
if (title) title.textContent = obs.name || obs.id.substring(0, 16) + '…';
|
||||
|
||||
// Parse radio string
|
||||
let radioHtml = '—';
|
||||
if (obs.radio) {
|
||||
const rp = obs.radio.split(',');
|
||||
radioHtml = rp[0] + ' MHz · SF' + (rp[2] || '?') + ' · BW' + (rp[1] || '?') + ' · CR' + (rp[3] || '?');
|
||||
}
|
||||
|
||||
// Health status
|
||||
const ago = obs.last_seen ? Date.now() - new Date(obs.last_seen).getTime() : Infinity;
|
||||
const statusCls = ago < 600000 ? 'health-green' : ago < HEALTH_THRESHOLDS.nodeDegradedMs ? 'health-yellow' : 'health-red';
|
||||
const statusLabel = ago < 600000 ? 'Online' : ago < HEALTH_THRESHOLDS.nodeDegradedMs ? 'Stale' : 'Offline';
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="obs-info-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-bottom:20px">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Status</div>
|
||||
<div class="stat-value"><span class="health-dot ${statusCls}">●</span> ${statusLabel}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Region</div>
|
||||
<div class="stat-value">${obs.iata ? '<span class="badge-region">' + obs.iata + '</span>' : '—'}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Model</div>
|
||||
<div class="stat-value">${obs.model || '—'}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Firmware</div>
|
||||
<div class="stat-value" style="font-size:0.8em;word-break:break-all">${obs.firmware || '—'}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Client</div>
|
||||
<div class="stat-value" style="font-size:0.8em;word-break:break-all">${obs.client_version || '—'}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Radio</div>
|
||||
<div class="stat-value" style="font-size:0.85em">${radioHtml}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Battery</div>
|
||||
<div class="stat-value">${obs.battery_mv ? obs.battery_mv + ' mV' : '—'}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Uptime</div>
|
||||
<div class="stat-value">${formatDuration(obs.uptime_secs)}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Noise Floor</div>
|
||||
<div class="stat-value">${obs.noise_floor != null ? obs.noise_floor + ' dBm' : '—'}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total Packets</div>
|
||||
<div class="stat-value">${(obs.packet_count || 0).toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Packets/Hour</div>
|
||||
<div class="stat-value">${(obs.packetsLastHour || 0).toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">First Seen</div>
|
||||
<div class="stat-value" style="font-size:0.85em">${obs.first_seen ? new Date(obs.first_seen).toLocaleDateString() : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mono" style="font-size:0.75em;color:var(--text-muted);margin-bottom:20px;word-break:break-all">
|
||||
ID: ${obs.id}
|
||||
</div>
|
||||
<div class="obs-charts" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(400px,1fr));gap:16px">
|
||||
<div class="chart-card" style="padding:12px">
|
||||
<h3 style="margin:0 0 8px;font-size:0.95em">Packets Over Time</h3>
|
||||
<canvas id="obsTimeChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-card" style="padding:12px">
|
||||
<h3 style="margin:0 0 8px;font-size:0.95em">Packet Types</h3>
|
||||
<div style="max-width:280px;margin:0 auto"><canvas id="obsTypeChart"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card" style="padding:12px">
|
||||
<h3 style="margin:0 0 8px;font-size:0.95em">Unique Nodes Heard</h3>
|
||||
<canvas id="obsNodesChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-card" style="padding:12px">
|
||||
<h3 style="margin:0 0 8px;font-size:0.95em">SNR Distribution</h3>
|
||||
<canvas id="obsSnrChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:20px">
|
||||
<h3 style="font-size:0.95em">Recent Packets</h3>
|
||||
<div id="obsRecentPackets"><div class="text-muted">Loading…</div></div>
|
||||
</div>`;
|
||||
|
||||
// Render charts
|
||||
if (analytics.timeline && analytics.timeline.length > 0) {
|
||||
renderTimelineChart(analytics.timeline);
|
||||
}
|
||||
if (analytics.packetTypes) {
|
||||
renderTypeChart(analytics.packetTypes);
|
||||
}
|
||||
if (analytics.nodesTimeline && analytics.nodesTimeline.length > 0) {
|
||||
renderNodesChart(analytics.nodesTimeline);
|
||||
}
|
||||
if (analytics.snrDistribution && analytics.snrDistribution.length > 0) {
|
||||
renderSnrChart(analytics.snrDistribution);
|
||||
}
|
||||
if (analytics.recentPackets) {
|
||||
renderRecentPackets(analytics.recentPackets);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTimelineChart(timeline) {
|
||||
const ctx = document.getElementById('obsTimeChart');
|
||||
if (!ctx) return;
|
||||
const c = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: timeline.map(t => t.label),
|
||||
datasets: [{
|
||||
label: 'Packets',
|
||||
data: timeline.map(t => t.count),
|
||||
backgroundColor: CHART_COLORS[0] + '80',
|
||||
borderColor: CHART_COLORS[0],
|
||||
borderWidth: 1,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { maxRotation: 45, autoSkip: true, maxTicksLimit: 12 } },
|
||||
y: { beginAtZero: true, ticks: { precision: 0 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
charts.push(c);
|
||||
}
|
||||
|
||||
function renderTypeChart(types) {
|
||||
const ctx = document.getElementById('obsTypeChart');
|
||||
if (!ctx) return;
|
||||
const labels = Object.keys(types).map(k => PAYLOAD_LABELS[k] || 'Type ' + k);
|
||||
const values = Object.values(types);
|
||||
const c = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{ data: values, backgroundColor: CHART_COLORS.slice(0, labels.length) }]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: true,
|
||||
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12 } } }
|
||||
}
|
||||
});
|
||||
charts.push(c);
|
||||
}
|
||||
|
||||
function renderNodesChart(timeline) {
|
||||
const ctx = document.getElementById('obsNodesChart');
|
||||
if (!ctx) return;
|
||||
const c = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: timeline.map(t => t.label),
|
||||
datasets: [{
|
||||
label: 'Unique Nodes',
|
||||
data: timeline.map(t => t.count),
|
||||
borderColor: CHART_COLORS[2],
|
||||
backgroundColor: CHART_COLORS[2] + '20',
|
||||
fill: true, tension: 0.3, pointRadius: 2,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { maxRotation: 45, autoSkip: true, maxTicksLimit: 12 } },
|
||||
y: { beginAtZero: true, ticks: { precision: 0 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
charts.push(c);
|
||||
}
|
||||
|
||||
function renderSnrChart(distribution) {
|
||||
const ctx = document.getElementById('obsSnrChart');
|
||||
if (!ctx) return;
|
||||
const c = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: distribution.map(d => d.range),
|
||||
datasets: [{
|
||||
label: 'Packets',
|
||||
data: distribution.map(d => d.count),
|
||||
backgroundColor: CHART_COLORS[3] + '80',
|
||||
borderColor: CHART_COLORS[3],
|
||||
borderWidth: 1,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { title: { display: true, text: 'SNR (dB)' } },
|
||||
y: { beginAtZero: true, ticks: { precision: 0 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
charts.push(c);
|
||||
}
|
||||
|
||||
function renderRecentPackets(packets) {
|
||||
const el = document.getElementById('obsRecentPackets');
|
||||
if (!el || !packets.length) { if (el) el.innerHTML = '<div class="text-muted">No recent packets.</div>'; return; }
|
||||
el.innerHTML = `<table class="data-table" style="font-size:0.85em">
|
||||
<thead><tr><th>Time</th><th>Type</th><th>Hash</th><th>SNR</th><th>RSSI</th><th>Hops</th></tr></thead>
|
||||
<tbody>${packets.map(p => {
|
||||
const decoded = typeof p.decoded_json === 'string' ? JSON.parse(p.decoded_json) : (p.decoded_json || {});
|
||||
const hops = typeof p.path_json === 'string' ? JSON.parse(p.path_json) : (p.path_json || []);
|
||||
const typeName = PAYLOAD_LABELS[p.payload_type] || 'Type ' + p.payload_type;
|
||||
return `<tr style="cursor:pointer" onclick="location.hash='#/packet/${p.id}'">
|
||||
<td>${timeAgo(p.timestamp)}</td>
|
||||
<td>${typeName}</td>
|
||||
<td class="mono" style="font-size:0.85em">${(p.hash || '').substring(0, 10)}</td>
|
||||
<td>${p.snr != null ? p.snr.toFixed(1) : '—'}</td>
|
||||
<td>${p.rssi != null ? p.rssi : '—'}</td>
|
||||
<td>${hops.length}</td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
registerPage('observer-detail', { init, destroy });
|
||||
})();
|
||||
@@ -11,17 +11,21 @@
|
||||
<div class="observers-page">
|
||||
<div class="page-header">
|
||||
<h2>Observer Status</h2>
|
||||
<button class="btn-icon" onclick="window._obsRefresh()" title="Refresh">🔄</button>
|
||||
<button class="btn-icon" data-action="obs-refresh" title="Refresh" aria-label="Refresh observers">🔄</button>
|
||||
</div>
|
||||
<div id="obsContent"><div class="text-center text-muted" style="padding:40px">Loading…</div></div>
|
||||
</div>`;
|
||||
loadObservers();
|
||||
// Event delegation for data-action buttons
|
||||
app.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-action]');
|
||||
if (btn && btn.dataset.action === 'obs-refresh') loadObservers();
|
||||
});
|
||||
// Auto-refresh every 30s
|
||||
refreshTimer = setInterval(loadObservers, 30000);
|
||||
wsHandler = (msg) => {
|
||||
if (msg.type === 'packet') loadObservers();
|
||||
};
|
||||
onWS(wsHandler);
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
if (msgs.some(function (m) { return m.type === 'packet'; })) loadObservers();
|
||||
});
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
@@ -34,20 +38,23 @@
|
||||
|
||||
async function loadObservers() {
|
||||
try {
|
||||
const data = await api('/observers');
|
||||
const data = await api('/observers', { ttl: CLIENT_TTL.observers });
|
||||
observers = data.observers || [];
|
||||
render();
|
||||
} catch (e) {
|
||||
document.getElementById('obsContent').innerHTML =
|
||||
`<div class="text-muted" style="padding:40px">Error loading observers: ${e.message}</div>`;
|
||||
`<div class="text-muted" role="alert" aria-live="polite" style="padding:40px">Error loading observers: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: Comparing server timestamps to Date.now() can skew if client/server
|
||||
// clocks differ. We add ±30s tolerance to thresholds to reduce false positives.
|
||||
function healthStatus(lastSeen) {
|
||||
if (!lastSeen) return { cls: 'health-red', label: 'Unknown' };
|
||||
const ago = Date.now() - new Date(lastSeen).getTime();
|
||||
if (ago < 600000) return { cls: 'health-green', label: 'Online' }; // < 10 min
|
||||
if (ago < 3600000) return { cls: 'health-yellow', label: 'Stale' }; // < 1 hour
|
||||
const tolerance = 30000; // 30s tolerance for clock skew
|
||||
if (ago < 600000 + tolerance) return { cls: 'health-green', label: 'Online' }; // < 10 min + tolerance
|
||||
if (ago < 3600000 + tolerance) return { cls: 'health-yellow', label: 'Stale' }; // < 1 hour + tolerance
|
||||
return { cls: 'health-red', label: 'Offline' };
|
||||
}
|
||||
|
||||
@@ -62,9 +69,9 @@
|
||||
}
|
||||
|
||||
function sparkBar(count, max) {
|
||||
if (max === 0) return '<div class="spark-bar"><div class="spark-fill" style="width:0"></div></div>';
|
||||
if (max === 0) return `<span class="text-muted">0/hr</span>`;
|
||||
const pct = Math.min(100, Math.round((count / max) * 100));
|
||||
return `<div class="spark-bar"><div class="spark-fill" style="width:${pct}%"></div><span class="spark-label">${count}/hr</span></div>`;
|
||||
return `<span style="display:inline-flex;align-items:center;gap:6px;white-space:nowrap"><span style="display:inline-block;width:60px;height:12px;background:var(--border);border-radius:3px;overflow:hidden;vertical-align:middle"><span style="display:block;height:100%;width:${pct}%;background:linear-gradient(90deg,#3b82f6,#60a5fa);border-radius:3px"></span></span><span style="font-size:11px">${count}/hr</span></span>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
@@ -85,20 +92,22 @@
|
||||
|
||||
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"><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>
|
||||
</div>
|
||||
<table class="data-table obs-table" id="obsTable">
|
||||
<div class="obs-table-scroll"><table class="data-table obs-table" id="obsTable">
|
||||
<caption class="sr-only">Observer status and statistics</caption>
|
||||
<thead><tr>
|
||||
<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 => {
|
||||
const h = healthStatus(o.last_seen);
|
||||
return `<tr>
|
||||
<td><span class="health-dot ${h.cls}" title="${h.label}"></span> ${h.label}</td>
|
||||
const shape = h.cls === 'health-green' ? '●' : h.cls === 'health-yellow' ? '▲' : '✕';
|
||||
return `<tr style="cursor:pointer" onclick="location.hash='#/observers/${encodeURIComponent(o.id)}'">
|
||||
<td><span class="health-dot ${h.cls}" title="${h.label}">${shape}</span> ${h.label}</td>
|
||||
<td class="mono">${o.name || o.id}</td>
|
||||
<td>${o.iata ? `<span class="badge-region">${o.iata}</span>` : '—'}</td>
|
||||
<td>${timeAgo(o.last_seen)}</td>
|
||||
@@ -107,11 +116,10 @@
|
||||
<td>${uptimeStr(o.first_seen)}</td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table>`;
|
||||
</table></div>`;
|
||||
makeColumnsResizable('#obsTable', 'meshcore-obs-col-widths');
|
||||
}
|
||||
|
||||
window._obsRefresh = loadObservers;
|
||||
|
||||
registerPage('observers', { init, destroy });
|
||||
})();
|
||||
|
||||
@@ -3,16 +3,25 @@
|
||||
|
||||
(function () {
|
||||
let packets = [];
|
||||
|
||||
// Resolve observer_id to friendly name from loaded observers list
|
||||
function obsName(id) {
|
||||
if (!id) return '—';
|
||||
const o = observers.find(ob => ob.id === id);
|
||||
return o?.name || id;
|
||||
}
|
||||
let selectedId = null;
|
||||
let groupByHash = true;
|
||||
let filters = {};
|
||||
let wsHandler = null;
|
||||
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' };
|
||||
function typeName(t) { return TYPE_NAMES[t] ?? `Type ${t}`; }
|
||||
let totalCount = 0;
|
||||
let expandedHashes = new Set();
|
||||
let hopNameCache = {};
|
||||
let filtersBuilt = false;
|
||||
const PANEL_WIDTH_KEY = 'meshcore-panel-width';
|
||||
|
||||
function initPanelResize() {
|
||||
@@ -24,30 +33,64 @@
|
||||
if (saved) panel.style.width = saved + 'px';
|
||||
|
||||
let startX, startW;
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
startX = e.clientX;
|
||||
function startResize(clientX) {
|
||||
startX = clientX;
|
||||
startW = panel.offsetWidth;
|
||||
handle.classList.add('dragging');
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
function onMove(e2) {
|
||||
const w = Math.max(280, Math.min(window.innerWidth * 0.7, startW - (e2.clientX - startX)));
|
||||
panel.style.width = w + 'px';
|
||||
panel.style.minWidth = w + 'px';
|
||||
}
|
||||
function doResize(clientX) {
|
||||
const w = Math.max(280, Math.min(window.innerWidth * 0.7, startW - (clientX - startX)));
|
||||
panel.style.width = w + 'px';
|
||||
panel.style.minWidth = w + 'px';
|
||||
const left = document.getElementById('pktLeft');
|
||||
if (left) {
|
||||
const available = left.parentElement.clientWidth - w;
|
||||
left.style.width = available + 'px';
|
||||
}
|
||||
}
|
||||
function endResize() {
|
||||
handle.classList.remove('dragging');
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
localStorage.setItem(PANEL_WIDTH_KEY, panel.offsetWidth);
|
||||
const left = document.getElementById('pktLeft');
|
||||
if (left) left.style.width = '';
|
||||
}
|
||||
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
startResize(e.clientX);
|
||||
|
||||
function onMove(e2) { doResize(e2.clientX); }
|
||||
function onUp() {
|
||||
handle.classList.remove('dragging');
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
localStorage.setItem(PANEL_WIDTH_KEY, panel.offsetWidth);
|
||||
endResize();
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
|
||||
handle.addEventListener('touchstart', (e) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
e.preventDefault();
|
||||
startResize(e.touches[0].clientX);
|
||||
|
||||
function onTouchMove(e2) {
|
||||
if (e2.touches.length !== 1) return;
|
||||
e2.preventDefault();
|
||||
doResize(e2.touches[0].clientX);
|
||||
}
|
||||
function onTouchEnd() {
|
||||
endResize();
|
||||
document.removeEventListener('touchmove', onTouchMove);
|
||||
document.removeEventListener('touchend', onTouchEnd);
|
||||
}
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', onTouchEnd);
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
// Resolve hop hex prefixes to node names (cached)
|
||||
@@ -72,7 +115,7 @@
|
||||
const title = ambiguous
|
||||
? `${h} — ⚠ ${entry.candidates.length} matches: ${entry.candidates.map(c => c.name).join(', ')}`
|
||||
: h;
|
||||
return `<a class="hop hop-link ${name ? 'hop-named' : ''} ${ambiguous ? 'hop-ambiguous' : ''}" href="#/nodes/${encodeURIComponent(pubkey)}" title="${title}" onclick="event.stopPropagation()">${display}${ambiguous ? '<span class="hop-warn">⚠</span>' : ''}</a>`;
|
||||
return `<a class="hop hop-link ${name ? 'hop-named' : ''} ${ambiguous ? 'hop-ambiguous' : ''}" href="#/nodes/${encodeURIComponent(pubkey)}" title="${title}" data-hop-link="true">${display}${ambiguous ? '<span class="hop-warn">⚠</span>' : ''}</a>`;
|
||||
}
|
||||
|
||||
function renderPath(hops) {
|
||||
@@ -81,8 +124,10 @@
|
||||
}
|
||||
|
||||
let directPacketId = null;
|
||||
let initGeneration = 0;
|
||||
|
||||
async function init(app, routeParam) {
|
||||
const gen = ++initGeneration;
|
||||
// Detect route param type: "id/123" for direct packet, short hex for hash, long hex for node
|
||||
if (routeParam) {
|
||||
if (routeParam.startsWith('id/')) {
|
||||
@@ -95,7 +140,7 @@
|
||||
}
|
||||
app.innerHTML = `<div class="split-layout">
|
||||
<div class="panel-left" id="pktLeft"></div>
|
||||
<div class="panel-right empty" id="pktRight">
|
||||
<div class="panel-right empty" id="pktRight" aria-live="polite">
|
||||
<div class="panel-resize-handle" id="pktResizeHandle"></div>
|
||||
<span>Select a packet to view details</span>
|
||||
</div>
|
||||
@@ -104,12 +149,21 @@
|
||||
await loadObservers();
|
||||
loadPackets();
|
||||
|
||||
// Event delegation for data-action buttons
|
||||
app.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
if (btn.dataset.action === 'pkt-refresh') loadPackets();
|
||||
else if (btn.dataset.action === 'pkt-byop') showBYOP();
|
||||
});
|
||||
|
||||
// If linked directly to a packet by ID, load its detail and filter list
|
||||
if (directPacketId) {
|
||||
const pktId = Number(directPacketId);
|
||||
directPacketId = null;
|
||||
try {
|
||||
const data = await api(`/packets/${pktId}`);
|
||||
if (gen !== initGeneration) return;
|
||||
if (data.packet?.hash) {
|
||||
filters.hash = data.packet.hash;
|
||||
const hashInput = document.getElementById('fHash');
|
||||
@@ -134,12 +188,83 @@
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
wsHandler = (msg) => {
|
||||
if (msg.type === 'packet') {
|
||||
loadPackets(); // refresh on new packet
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
const newPkts = msgs
|
||||
.filter(m => m.type === 'packet' && m.data?.packet)
|
||||
.map(m => m.data.packet);
|
||||
if (!newPkts.length) return;
|
||||
|
||||
// Check if new packets pass current filters
|
||||
const filtered = newPkts.filter(p => {
|
||||
if (filters.type !== undefined && filters.type !== '' && p.payload_type !== Number(filters.type)) return false;
|
||||
if (filters.observer && p.observer_id !== filters.observer) return false;
|
||||
if (filters.hash && p.hash !== filters.hash) return false;
|
||||
if (filters.region) {
|
||||
const obs = observers.find(o => o.id === p.observer_id);
|
||||
if (!obs || obs.iata !== filters.region) return false;
|
||||
}
|
||||
if (filters.node && !(p.decoded_json || '').includes(filters.node)) return false;
|
||||
return true;
|
||||
});
|
||||
if (!filtered.length) return;
|
||||
|
||||
// Resolve any new hops, then update and re-render
|
||||
const newHops = new Set();
|
||||
for (const p of filtered) {
|
||||
try { JSON.parse(p.path_json || '[]').forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
|
||||
}
|
||||
};
|
||||
onWS(wsHandler);
|
||||
(newHops.size ? resolveHops([...newHops]) : Promise.resolve()).then(() => {
|
||||
if (groupByHash) {
|
||||
// Update existing groups or create new ones
|
||||
for (const p of filtered) {
|
||||
const h = p.hash;
|
||||
const existing = packets.find(g => g.hash === h);
|
||||
if (existing) {
|
||||
existing.count = (existing.count || 1) + 1;
|
||||
existing.observation_count = (existing.observation_count || 1) + 1;
|
||||
existing.latest = p.timestamp > existing.latest ? p.timestamp : existing.latest;
|
||||
// Track unique observers
|
||||
if (p.observer_id && p.observer_id !== existing.observer_id) {
|
||||
existing.observer_count = (existing.observer_count || 1) + 1;
|
||||
}
|
||||
// Keep longest path
|
||||
if (p.path_json && (!existing.path_json || p.path_json.length > existing.path_json.length)) {
|
||||
existing.path_json = p.path_json;
|
||||
existing.raw_hex = p.raw_hex;
|
||||
}
|
||||
// Update decoded_json to latest
|
||||
if (p.decoded_json) existing.decoded_json = p.decoded_json;
|
||||
// Update expanded children if this group is expanded
|
||||
if (expandedHashes.has(h) && existing._children) {
|
||||
existing._children.unshift(p);
|
||||
}
|
||||
} else {
|
||||
// New group
|
||||
packets.unshift({
|
||||
hash: h,
|
||||
count: 1,
|
||||
observer_count: 1,
|
||||
latest: p.timestamp,
|
||||
observer_id: p.observer_id,
|
||||
observer_name: p.observer_name,
|
||||
path_json: p.path_json,
|
||||
payload_type: p.payload_type,
|
||||
raw_hex: p.raw_hex,
|
||||
decoded_json: p.decoded_json,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Re-sort by latest DESC, cap size
|
||||
packets.sort((a, b) => (b.latest || '').localeCompare(a.latest || ''));
|
||||
packets = packets.slice(0, 200);
|
||||
} else {
|
||||
// Flat mode: prepend
|
||||
packets = filtered.concat(packets).slice(0, 200);
|
||||
}
|
||||
totalCount += filtered.length;
|
||||
renderTableRows();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
@@ -147,12 +272,21 @@
|
||||
wsHandler = null;
|
||||
packets = [];
|
||||
selectedId = null;
|
||||
filtersBuilt = false;
|
||||
delete filters.node;
|
||||
expandedHashes = new Set();
|
||||
hopNameCache = {};
|
||||
totalCount = 0;
|
||||
observers = [];
|
||||
directPacketId = null;
|
||||
groupByHash = true;
|
||||
filters = {};
|
||||
regionMap = {};
|
||||
}
|
||||
|
||||
async function loadObservers() {
|
||||
try {
|
||||
const data = await api('/observers');
|
||||
const data = await api('/observers', { ttl: CLIENT_TTL.observers });
|
||||
observers = data.observers || [];
|
||||
} catch {}
|
||||
}
|
||||
@@ -198,6 +332,8 @@
|
||||
renderLeft();
|
||||
} catch (e) {
|
||||
console.error('Failed to load packets:', e);
|
||||
const tbody = document.getElementById('pktBody');
|
||||
if (tbody) tbody.innerHTML = '<tr><td colspan="10" class="text-center" style="padding:24px;color:var(--error,#ef4444)"><div role="alert" aria-live="polite">Failed to load packets. Please try again.</div></td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,29 +341,42 @@
|
||||
const el = document.getElementById('pktLeft');
|
||||
if (!el) return;
|
||||
|
||||
// Only build the filter bar + table skeleton once; subsequent calls just update rows
|
||||
if (filtersBuilt) {
|
||||
renderTableRows();
|
||||
return;
|
||||
}
|
||||
filtersBuilt = true;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h2>Latest Packets <span class="count">(${totalCount})</span></h2>
|
||||
<div>
|
||||
<button class="btn-icon" onclick="window._pktRefresh()" title="Refresh">🔄</button>
|
||||
<button class="btn-icon" onclick="window._pktBYOP()" title="Bring Your Own Packet">📦 BYOP</button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-bar" id="pktFilters">
|
||||
<input type="text" placeholder="Packet hash…" id="fHash">
|
||||
<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">
|
||||
<div class="node-filter-dropdown hidden" id="fNodeDropdown"></div>
|
||||
<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>
|
||||
<select id="fObserver"><option value="">All Observers</option></select>
|
||||
<select id="fRegion"><option value="">All Regions</option></select>
|
||||
<select id="fType"><option value="">All Types</option></select>
|
||||
<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>
|
||||
</div>
|
||||
<table class="data-table" id="pktTable">
|
||||
<thead><tr>
|
||||
<th></th><th>Region</th><th>Time</th><th>Hash</th><th>Size</th>
|
||||
<th>Type</th><th>Observer</th><th>Path</th><th>Rpt</th><th>Details</th>
|
||||
<th></th><th class="col-region">Region</th><th class="col-time">Time</th><th class="col-hash">Hash</th><th class="col-size">Size</th>
|
||||
<th class="col-type">Type</th><th class="col-observer">Observer</th><th class="col-path">Path</th><th class="col-rpt">Rpt</th><th class="col-details">Details</th>
|
||||
</tr></thead>
|
||||
<tbody id="pktBody"></tbody>
|
||||
</table>
|
||||
@@ -235,13 +384,13 @@
|
||||
|
||||
// Populate filter dropdowns
|
||||
const regionSel = document.getElementById('fRegion');
|
||||
for (const [code, name] of Object.entries(window._regions || {})) {
|
||||
for (const [code, name] of Object.entries(regionMap || {})) {
|
||||
regionSel.innerHTML += `<option value="${code}" ${filters.region === code ? 'selected' : ''}>${code}</option>`;
|
||||
}
|
||||
|
||||
const obsSel = document.getElementById('fObserver');
|
||||
for (const o of observers) {
|
||||
obsSel.innerHTML += `<option value="${o.id}" ${filters.observer === o.id ? 'selected' : ''}>${o.id}</option>`;
|
||||
obsSel.innerHTML += `<option value="${o.id}" ${filters.observer === o.id ? 'selected' : ''}>${o.name || o.id}</option>`;
|
||||
}
|
||||
|
||||
const typeSel = document.getElementById('fType');
|
||||
@@ -249,6 +398,13 @@
|
||||
typeSel.innerHTML += `<option value="${k}" ${String(filters.type) === k ? 'selected' : ''}>${v}</option>`;
|
||||
}
|
||||
|
||||
// Filter toggle button for mobile
|
||||
document.getElementById('filterToggleBtn').addEventListener('click', function() {
|
||||
const bar = document.getElementById('pktFilters');
|
||||
bar.classList.toggle('filters-expanded');
|
||||
this.textContent = bar.classList.contains('filters-expanded') ? 'Filters ▴' : 'Filters ▾';
|
||||
});
|
||||
|
||||
// Filter event listeners
|
||||
document.getElementById('fHash').value = filters.hash || '';
|
||||
document.getElementById('fHash').addEventListener('input', debounce((e) => { filters.hash = e.target.value || undefined; loadPackets(); }, 300));
|
||||
@@ -256,15 +412,69 @@
|
||||
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(); });
|
||||
document.getElementById('fGroup').addEventListener('click', () => { groupByHash = !groupByHash; loadPackets(); });
|
||||
document.getElementById('fMyNodes').addEventListener('click', function () {
|
||||
filters.myNodes = !filters.myNodes;
|
||||
this.classList.toggle('active', filters.myNodes);
|
||||
loadPackets();
|
||||
});
|
||||
|
||||
// Column visibility toggle (#71)
|
||||
const COL_DEFS = [
|
||||
{ key: 'region', label: 'Region' },
|
||||
{ key: 'time', label: 'Time' },
|
||||
{ key: 'hash', label: 'Hash' },
|
||||
{ key: 'size', label: 'Size' },
|
||||
{ key: 'type', label: 'Type' },
|
||||
{ key: 'observer', label: 'Observer' },
|
||||
{ key: 'path', label: 'Path' },
|
||||
{ key: 'rpt', label: 'Rpt' },
|
||||
{ key: 'details', label: 'Details' },
|
||||
];
|
||||
const isMobile = window.innerWidth <= 640;
|
||||
const defaultHidden = isMobile ? ['region', 'hash', 'observer', 'path', 'rpt', 'size'] : ['region'];
|
||||
let visibleCols;
|
||||
try {
|
||||
visibleCols = JSON.parse(localStorage.getItem('packets-visible-cols'));
|
||||
} catch {}
|
||||
if (!visibleCols) visibleCols = COL_DEFS.map(c => c.key).filter(k => !defaultHidden.includes(k));
|
||||
const colMenu = document.getElementById('colToggleMenu');
|
||||
const pktTable = document.getElementById('pktTable');
|
||||
function applyColVisibility() {
|
||||
COL_DEFS.forEach(c => {
|
||||
pktTable.classList.toggle('hide-col-' + c.key, !visibleCols.includes(c.key));
|
||||
});
|
||||
localStorage.setItem('packets-visible-cols', JSON.stringify(visibleCols));
|
||||
}
|
||||
colMenu.innerHTML = COL_DEFS.map(c =>
|
||||
`<label><input type="checkbox" data-col="${c.key}" ${visibleCols.includes(c.key) ? 'checked' : ''}> ${c.label}</label>`
|
||||
).join('');
|
||||
colMenu.addEventListener('change', (e) => {
|
||||
const cb = e.target;
|
||||
const col = cb.dataset.col;
|
||||
if (!col) return;
|
||||
if (cb.checked) { if (!visibleCols.includes(col)) visibleCols.push(col); }
|
||||
else { visibleCols = visibleCols.filter(k => k !== col); }
|
||||
applyColVisibility();
|
||||
});
|
||||
document.getElementById('colToggleBtn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
colMenu.classList.toggle('open');
|
||||
});
|
||||
document.addEventListener('click', () => colMenu.classList.remove('open'));
|
||||
applyColVisibility();
|
||||
|
||||
// Node name filter with autocomplete
|
||||
const fNode = document.getElementById('fNode');
|
||||
const fNodeDrop = document.getElementById('fNodeDropdown');
|
||||
fNode.value = filters.nodeName || '';
|
||||
let nodeActiveIdx = -1;
|
||||
fNode.addEventListener('input', debounce(async (e) => {
|
||||
const q = e.target.value.trim();
|
||||
nodeActiveIdx = -1;
|
||||
fNode.setAttribute('aria-activedescendant', '');
|
||||
if (!q) {
|
||||
fNodeDrop.classList.add('hidden');
|
||||
fNode.setAttribute('aria-expanded', 'false');
|
||||
if (filters.node) { filters.node = undefined; filters.nodeName = undefined; loadPackets(); }
|
||||
return;
|
||||
}
|
||||
@@ -272,35 +482,137 @@
|
||||
const resp = await fetch('/api/nodes/search?q=' + encodeURIComponent(q));
|
||||
const data = await resp.json();
|
||||
const nodes = data.nodes || [];
|
||||
if (nodes.length === 0) { fNodeDrop.classList.add('hidden'); return; }
|
||||
fNodeDrop.innerHTML = nodes.map(n =>
|
||||
`<div class="node-filter-option" data-key="${n.public_key}" data-name="${escapeHtml(n.name || n.public_key.slice(0,8))}">${escapeHtml(n.name || n.public_key.slice(0,8))} <span style="color:var(--muted);font-size:0.8em">${n.public_key.slice(0,8)}</span></div>`
|
||||
if (nodes.length === 0) { fNodeDrop.classList.add('hidden'); fNode.setAttribute('aria-expanded', 'false'); return; }
|
||||
fNodeDrop.innerHTML = nodes.map((n, i) =>
|
||||
`<div class="node-filter-option" id="fNodeOpt-${i}" role="option" data-key="${n.public_key}" data-name="${escapeHtml(n.name || n.public_key.slice(0,8))}">${escapeHtml(n.name || n.public_key.slice(0,8))} <span style="color:var(--muted);font-size:0.8em">${n.public_key.slice(0,8)}</span></div>`
|
||||
).join('');
|
||||
fNodeDrop.classList.remove('hidden');
|
||||
fNode.setAttribute('aria-expanded', 'true');
|
||||
fNodeDrop.querySelectorAll('.node-filter-option').forEach(opt => {
|
||||
opt.addEventListener('click', () => {
|
||||
filters.node = opt.dataset.key;
|
||||
filters.nodeName = opt.dataset.name;
|
||||
fNode.value = opt.dataset.name;
|
||||
fNodeDrop.classList.add('hidden');
|
||||
loadPackets();
|
||||
selectNodeOption(opt);
|
||||
});
|
||||
});
|
||||
} catch {}
|
||||
}, 250));
|
||||
fNode.addEventListener('blur', () => { setTimeout(() => fNodeDrop.classList.add('hidden'), 200); });
|
||||
|
||||
function selectNodeOption(opt) {
|
||||
filters.node = opt.dataset.key;
|
||||
filters.nodeName = opt.dataset.name;
|
||||
fNode.value = opt.dataset.name;
|
||||
fNodeDrop.classList.add('hidden');
|
||||
fNode.setAttribute('aria-expanded', 'false');
|
||||
fNode.setAttribute('aria-activedescendant', '');
|
||||
nodeActiveIdx = -1;
|
||||
loadPackets();
|
||||
}
|
||||
|
||||
fNode.addEventListener('keydown', (e) => {
|
||||
const options = fNodeDrop.querySelectorAll('.node-filter-option');
|
||||
if (!options.length || fNodeDrop.classList.contains('hidden')) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
nodeActiveIdx = Math.min(nodeActiveIdx + 1, options.length - 1);
|
||||
updateNodeActive(options);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
nodeActiveIdx = Math.max(nodeActiveIdx - 1, 0);
|
||||
updateNodeActive(options);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (nodeActiveIdx >= 0 && options[nodeActiveIdx]) selectNodeOption(options[nodeActiveIdx]);
|
||||
} else if (e.key === 'Escape') {
|
||||
fNodeDrop.classList.add('hidden');
|
||||
fNode.setAttribute('aria-expanded', 'false');
|
||||
nodeActiveIdx = -1;
|
||||
}
|
||||
});
|
||||
|
||||
function updateNodeActive(options) {
|
||||
options.forEach((o, i) => {
|
||||
o.classList.toggle('node-filter-active', i === nodeActiveIdx);
|
||||
o.setAttribute('aria-selected', i === nodeActiveIdx ? 'true' : 'false');
|
||||
});
|
||||
if (nodeActiveIdx >= 0 && options[nodeActiveIdx]) {
|
||||
fNode.setAttribute('aria-activedescendant', options[nodeActiveIdx].id);
|
||||
options[nodeActiveIdx].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
fNode.addEventListener('blur', () => { setTimeout(() => { fNodeDrop.classList.add('hidden'); fNode.setAttribute('aria-expanded', 'false'); }, 200); });
|
||||
|
||||
// Delegated click/keyboard handler for table rows
|
||||
const pktBody = document.getElementById('pktBody');
|
||||
if (pktBody) {
|
||||
const handler = (e) => {
|
||||
// Let hop links navigate naturally without selecting the row
|
||||
if (e.target.closest('[data-hop-link]')) return;
|
||||
const row = e.target.closest('tr[data-action]');
|
||||
if (!row) return;
|
||||
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
|
||||
if (e.type === 'keydown') e.preventDefault();
|
||||
const action = row.dataset.action;
|
||||
const value = row.dataset.value;
|
||||
if (action === 'select') selectPacket(Number(value));
|
||||
else if (action === 'select-hash') pktSelectHash(value);
|
||||
else if (action === 'toggle-select') { pktToggleGroup(value); pktSelectHash(value); }
|
||||
};
|
||||
pktBody.addEventListener('click', handler);
|
||||
pktBody.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
// Escape to close packet detail panel
|
||||
document.addEventListener('keydown', function pktEsc(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const panel = document.getElementById('pktRight');
|
||||
if (panel && !panel.classList.contains('empty')) {
|
||||
panel.classList.add('empty');
|
||||
panel.innerHTML = '<div class="panel-resize-handle" id="pktResizeHandle"></div><span>Select a packet to view details</span>';
|
||||
selectedId = null;
|
||||
renderTableRows();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
renderTableRows();
|
||||
makeColumnsResizable('#pktTable', 'meshcore-pkt-col-widths');
|
||||
}
|
||||
|
||||
function renderTableRows() {
|
||||
async function renderTableRows() {
|
||||
const tbody = document.getElementById('pktBody');
|
||||
if (!tbody) return;
|
||||
|
||||
// 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);
|
||||
|
||||
// Filter to claimed/favorited nodes if toggle is on — use server-side multi-node lookup
|
||||
let displayPackets = packets;
|
||||
if (filters.myNodes) {
|
||||
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
|
||||
const myKeys = myNodes.map(n => n.pubkey).filter(Boolean);
|
||||
const favs = getFavorites();
|
||||
const allKeys = [...new Set([...myKeys, ...favs])];
|
||||
if (allKeys.length > 0) {
|
||||
try {
|
||||
const myData = await api('/packets?nodes=' + allKeys.join(',') + '&limit=500');
|
||||
displayPackets = myData.packets || [];
|
||||
} catch { displayPackets = []; }
|
||||
} else {
|
||||
displayPackets = [];
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (groupByHash) {
|
||||
let html = '';
|
||||
for (const p of packets) {
|
||||
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 || '') : '';
|
||||
let groupPath = [];
|
||||
@@ -310,20 +622,17 @@
|
||||
const groupTypeClass = payloadTypeColor(p.payload_type);
|
||||
const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
|
||||
const isSingle = p.count <= 1;
|
||||
const rowClick = isSingle
|
||||
? `window._pktSelectHash('${p.hash}')`
|
||||
: `window._pktToggleGroup('${p.hash}'); window._pktSelectHash('${p.hash}')`;
|
||||
html += `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" onclick="${rowClick}">
|
||||
html += `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${isSingle ? 'select-hash' : 'toggle-select'}" data-value="${p.hash}" tabindex="0" role="row">
|
||||
<td style="width:28px;text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
|
||||
<td>${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
|
||||
<td>${timeAgo(p.latest)}</td>
|
||||
<td class="mono">${truncate(p.hash || '—', 8)}</td>
|
||||
<td>${groupSize ? groupSize + 'B' : '—'}</td>
|
||||
<td>${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>` : '—'}</td>
|
||||
<td>${isSingle ? truncate(p.observer_name || p.observer_id || '—', 16) : truncate(p.observer_name || p.observer_id || '—', 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
|
||||
<td><span class="path-hops">${groupPathStr}</span></td>
|
||||
<td>${isSingle ? '' : p.count}</td>
|
||||
<td>${getDetailPreview((() => { try { return JSON.parse(p.decoded_json || '{}'); } catch { return {}; } })())}</td>
|
||||
<td class="col-region">${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
|
||||
<td class="col-time">${timeAgo(p.latest)}</td>
|
||||
<td class="mono col-hash">${truncate(p.hash || '—', 8)}</td>
|
||||
<td class="col-size">${groupSize ? groupSize + 'B' : '—'}</td>
|
||||
<td class="col-type">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>` : '—'}</td>
|
||||
<td class="col-observer">${isSingle ? truncate(obsName(p.observer_id), 16) : truncate(obsName(p.observer_id), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
|
||||
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
|
||||
<td class="col-rpt">${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) {
|
||||
@@ -335,16 +644,16 @@
|
||||
let childPath = [];
|
||||
try { childPath = JSON.parse(c.path_json || '[]'); } catch {}
|
||||
const childPathStr = renderPath(childPath);
|
||||
html += `<tr class="group-child" data-id="${c.id}" onclick="window._pktSelect(${c.id})">
|
||||
<td></td><td>${childRegion ? `<span class="badge-region">${childRegion}</span>` : '—'}</td>
|
||||
<td>${timeAgo(c.timestamp)}</td>
|
||||
<td class="mono">${truncate(c.hash || '', 8)}</td>
|
||||
<td>${size}B</td>
|
||||
<td><span class="badge badge-${typeClass}">${typeName}</span></td>
|
||||
<td>${truncate(c.observer_name || c.observer_id || '—', 16)}</td>
|
||||
<td><span class="path-hops">${childPathStr}</span></td>
|
||||
<td></td>
|
||||
<td>${getDetailPreview((() => { try { return JSON.parse(c.decoded_json); } catch { return {}; } })())}</td>
|
||||
html += `<tr class="group-child" data-id="${c.id}" data-action="select" data-value="${c.id}" 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>
|
||||
<td class="col-size">${size}B</td>
|
||||
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span></td>
|
||||
<td class="col-observer">${truncate(obsName(c.observer_id), 16)}</td>
|
||||
<td class="col-path"><span class="path-hops">${childPathStr}</span></td>
|
||||
<td class="col-rpt"></td>
|
||||
<td class="col-details">${getDetailPreview((() => { try { return JSON.parse(c.decoded_json); } catch { return {}; } })())}</td>
|
||||
</tr>`;
|
||||
}
|
||||
}
|
||||
@@ -353,7 +662,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = packets.map(p => {
|
||||
tbody.innerHTML = displayPackets.map(p => {
|
||||
let decoded, pathHops = [];
|
||||
try { decoded = JSON.parse(p.decoded_json); } catch {}
|
||||
try { pathHops = JSON.parse(p.path_json || '[]'); } catch {}
|
||||
@@ -365,16 +674,16 @@
|
||||
const pathStr = renderPath(pathHops);
|
||||
const detail = getDetailPreview(decoded);
|
||||
|
||||
return `<tr data-id="${p.id}" onclick="window._pktSelect(${p.id})" class="${selectedId === p.id ? 'selected' : ''}">
|
||||
<td></td><td>${region ? `<span class="badge-region">${region}</span>` : '—'}</td>
|
||||
<td>${timeAgo(p.timestamp)}</td>
|
||||
<td class="mono">${truncate(p.hash || String(p.id), 8)}</td>
|
||||
<td>${size}B</td>
|
||||
<td><span class="badge badge-${typeClass}">${typeName}</span></td>
|
||||
<td>${truncate(p.observer_name || p.observer_id || '—', 16)}</td>
|
||||
<td><span class="path-hops">${pathStr}</span></td>
|
||||
<td></td>
|
||||
<td>${detail}</td>
|
||||
return `<tr data-id="${p.id}" data-action="select" data-value="${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>
|
||||
<td class="col-size">${size}B</td>
|
||||
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span></td>
|
||||
<td class="col-observer">${truncate(obsName(p.observer_id), 16)}</td>
|
||||
<td class="col-path"><span class="path-hops">${pathStr}</span></td>
|
||||
<td class="col-rpt"></td>
|
||||
<td class="col-details">${detail}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -400,7 +709,7 @@
|
||||
// Anonymous requests
|
||||
if (decoded.type === 'ANON_REQ') return `🔒 anon → ${decoded.destHash?.slice(0,8) || '?'}`;
|
||||
// Companion bridge text
|
||||
if (decoded.text) return decoded.text.length > 80 ? decoded.text.slice(0, 80) + '…' : decoded.text;
|
||||
if (decoded.text) return escapeHtml(decoded.text.length > 80 ? decoded.text.slice(0, 80) + '…' : decoded.text);
|
||||
// Bare adverts with just pubkey
|
||||
if (decoded.public_key) return `📡 ${decoded.public_key.slice(0, 16)}…`;
|
||||
return '';
|
||||
@@ -408,11 +717,35 @@
|
||||
|
||||
async function selectPacket(id) {
|
||||
selectedId = id;
|
||||
history.replaceState(null, '', `#/packet/${id}`);
|
||||
renderTableRows();
|
||||
const panel = document.getElementById('pktRight');
|
||||
panel.classList.remove('empty');
|
||||
panel.innerHTML = '<div class="panel-resize-handle" id="pktResizeHandle"></div><div class="text-center text-muted" style="padding:40px">Loading…</div>';
|
||||
initPanelResize();
|
||||
const isMobileNow = window.innerWidth <= 640;
|
||||
let panel;
|
||||
if (isMobileNow) {
|
||||
// Use mobile bottom sheet
|
||||
let sheet = document.getElementById('mobileDetailSheet');
|
||||
if (!sheet) {
|
||||
sheet = document.createElement('div');
|
||||
sheet.id = 'mobileDetailSheet';
|
||||
sheet.className = 'mobile-detail-sheet';
|
||||
sheet.innerHTML = '<div class="mobile-sheet-handle"></div><button class="mobile-sheet-close" id="mobileSheetClose">✕</button><div class="mobile-sheet-content"></div>';
|
||||
document.body.appendChild(sheet);
|
||||
sheet.querySelector('#mobileSheetClose').addEventListener('click', () => {
|
||||
sheet.classList.remove('open');
|
||||
});
|
||||
sheet.querySelector('.mobile-sheet-handle').addEventListener('click', () => {
|
||||
sheet.classList.remove('open');
|
||||
});
|
||||
}
|
||||
panel = sheet.querySelector('.mobile-sheet-content');
|
||||
panel.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
|
||||
sheet.classList.add('open');
|
||||
} else {
|
||||
panel = document.getElementById('pktRight');
|
||||
panel.classList.remove('empty');
|
||||
panel.innerHTML = '<div class="panel-resize-handle" id="pktResizeHandle"></div><div class="text-center text-muted" style="padding:40px">Loading…</div>';
|
||||
initPanelResize();
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api(`/packets/${id}`);
|
||||
@@ -423,11 +756,11 @@
|
||||
const newHops = hops.filter(h => !(h in hopNameCache));
|
||||
if (newHops.length) await resolveHops(newHops);
|
||||
} catch {}
|
||||
panel.innerHTML = '<div class="panel-resize-handle" id="pktResizeHandle"></div>';
|
||||
panel.innerHTML = isMobileNow ? '' : '<div class="panel-resize-handle" id="pktResizeHandle"></div>';
|
||||
const content = document.createElement('div');
|
||||
panel.appendChild(content);
|
||||
renderDetail(content, data);
|
||||
initPanelResize();
|
||||
if (!isMobileNow) initPanelResize();
|
||||
} catch (e) {
|
||||
panel.innerHTML = `<div class="text-muted">Error: ${e.message}</div>`;
|
||||
}
|
||||
@@ -467,7 +800,7 @@
|
||||
<div class="detail-hash">${pkt.hash || 'Packet #' + pkt.id}</div>
|
||||
${messageHtml}
|
||||
<dl class="detail-meta">
|
||||
<dt>Observer</dt><dd>${pkt.observer_name || pkt.observer_id || '—'}</dd>
|
||||
<dt>Observer</dt><dd>${obsName(pkt.observer_id)}</dd>
|
||||
<dt>SNR / RSSI</dt><dd>${snr != null ? snr + ' dB' : '—'} / ${rssi != null ? rssi + ' dBm' : '—'}</dd>
|
||||
<dt>Route Type</dt><dd>${routeTypeName(pkt.route_type)}</dd>
|
||||
<dt>Payload Type</dt><dd><span class="badge badge-${payloadTypeColor(pkt.payload_type)}">${typeName}</span></dd>
|
||||
@@ -475,6 +808,7 @@
|
||||
<dt>Path</dt><dd>${pathHops.length ? renderPath(pathHops) : '—'}</dd>
|
||||
</dl>
|
||||
<div class="detail-actions">
|
||||
<button class="copy-link-btn" data-packet-id="${pkt.id}" title="Copy link to this packet">🔗 Copy Link</button>
|
||||
${pathHops.length ? `<button class="detail-map-link" id="viewRouteBtn">🗺️ View route on map</button>` : ''}
|
||||
<button class="replay-live-btn" title="Replay this packet on the live map">▶ Replay</button>
|
||||
</div>
|
||||
@@ -485,6 +819,20 @@
|
||||
${hasRawHex ? buildFieldTable(pkt, decoded, pathHops, ranges) : buildDecodedTable(decoded)}
|
||||
`;
|
||||
|
||||
// Wire up copy link button
|
||||
const copyLinkBtn = panel.querySelector('.copy-link-btn');
|
||||
if (copyLinkBtn) {
|
||||
copyLinkBtn.addEventListener('click', () => {
|
||||
const url = `${location.origin}/#/packet/${copyLinkBtn.dataset.packetId}`;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
copyLinkBtn.textContent = '✅ Copied!';
|
||||
setTimeout(() => { copyLinkBtn.textContent = '🔗 Copy Link'; }, 1500);
|
||||
}).catch(() => {
|
||||
prompt('Copy this link:', url);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Wire up replay button
|
||||
const replayBtn = panel.querySelector('.replay-live-btn');
|
||||
if (replayBtn) {
|
||||
@@ -493,7 +841,7 @@
|
||||
id: pkt.id, hash: pkt.hash,
|
||||
_ts: new Date(pkt.timestamp).getTime(),
|
||||
decoded: { header: { payloadTypeName: typeName }, payload: decoded, path: { hops: pathHops } },
|
||||
snr: pkt.snr, rssi: pkt.rssi, observer: pkt.observer_name
|
||||
snr: pkt.snr, rssi: pkt.rssi, observer: obsName(pkt.observer_id)
|
||||
};
|
||||
sessionStorage.setItem('replay-packet', JSON.stringify(livePkt));
|
||||
window.location.hash = '#/live';
|
||||
@@ -505,7 +853,7 @@
|
||||
if (routeBtn && pathHops.length) {
|
||||
routeBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const obsId = pkt.observer_name || pkt.observer_id || '';
|
||||
const obsId = obsName(pkt.observer_id);
|
||||
const observerParam = obsId ? '&observer=' + encodeURIComponent(obsId) : '';
|
||||
const resp = await fetch('/api/resolve-hops?hops=' + encodeURIComponent(pathHops.join(',')) + observerParam);
|
||||
const data = await resp.json();
|
||||
@@ -523,10 +871,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
function buildDecodedTable(decoded) {
|
||||
let rows = '';
|
||||
for (const [k, v] of Object.entries(decoded)) {
|
||||
@@ -565,7 +909,7 @@
|
||||
const hopName = hopEntry ? (typeof hopEntry === 'string' ? hopEntry : hopEntry.name) : null;
|
||||
const hopPubkey = hopEntry?.pubkey || pathHops[i];
|
||||
const nameHtml = hopName
|
||||
? `<a href="#/nodes/${encodeURIComponent(hopPubkey)}" class="hop-link hop-named" onclick="event.stopPropagation()">${escapeHtml(hopName)}</a>${hopEntry?.ambiguous ? ' ⚠' : ''}`
|
||||
? `<a href="#/nodes/${encodeURIComponent(hopPubkey)}" class="hop-link hop-named" data-hop-link="true">${escapeHtml(hopName)}</a>${hopEntry?.ambiguous ? ' ⚠' : ''}`
|
||||
: '';
|
||||
const label = hopName ? `Hop ${i} — ${nameHtml}` : `Hop ${i}`;
|
||||
rows += fieldRow(off + i * hashSize, label, pathHops[i], '');
|
||||
@@ -591,7 +935,7 @@
|
||||
fOff += 8;
|
||||
}
|
||||
if (decoded.flags.hasName) {
|
||||
rows += fieldRow(fOff, 'Node Name', decoded.name || '', '');
|
||||
rows += fieldRow(fOff, 'Node Name', escapeHtml(decoded.name || ''), '');
|
||||
}
|
||||
}
|
||||
} else if (decoded.type === 'GRP_TXT') {
|
||||
@@ -626,9 +970,10 @@
|
||||
|
||||
// BYOP modal — decode only, no DB injection
|
||||
function showBYOP() {
|
||||
const triggerBtn = document.querySelector('[data-action="pkt-byop"]');
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.innerHTML = '<div class="modal byop-modal">'
|
||||
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>'
|
||||
+ '<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>'
|
||||
@@ -637,10 +982,30 @@
|
||||
+ '</div>';
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const close = () => overlay.remove();
|
||||
const modal = overlay.querySelector('.byop-modal');
|
||||
const close = () => { overlay.remove(); if (triggerBtn) triggerBtn.focus(); };
|
||||
overlay.querySelector('.byop-x').onclick = close;
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
|
||||
// Focus trap
|
||||
function getFocusable() {
|
||||
return modal.querySelectorAll('textarea, button, input, [tabindex]:not([tabindex="-1"])');
|
||||
}
|
||||
overlay.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
|
||||
if (e.key === 'Tab') {
|
||||
const focusable = getFocusable();
|
||||
if (!focusable.length) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
} else {
|
||||
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const textarea = overlay.querySelector('#byopHex');
|
||||
textarea.focus();
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
@@ -726,33 +1091,26 @@
|
||||
return '<div class="byop-row"><span class="byop-key">' + key + '</span><span class="byop-val">' + val + '</span></div>';
|
||||
}
|
||||
|
||||
// Debounce helper
|
||||
function debounce(fn, ms) {
|
||||
let t;
|
||||
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
||||
}
|
||||
|
||||
// Load regions from config
|
||||
// Load regions from config API
|
||||
(async () => {
|
||||
try {
|
||||
// We'll use a simple approach - hardcode from config
|
||||
window._regions = {"SJC":"San Jose, US","SFO":"San Francisco, US","OAK":"Oakland, US","MRY":"Monterey, US","LAR":"Los Angeles, US"};
|
||||
regionMap = await api('/config/regions', { ttl: 3600 });
|
||||
} catch {}
|
||||
})();
|
||||
|
||||
// Global handlers
|
||||
window._pktSelect = selectPacket;
|
||||
window._pktToggleGroup = async (hash) => {
|
||||
async function pktToggleGroup(hash) {
|
||||
if (expandedHashes.has(hash)) {
|
||||
expandedHashes.delete(hash);
|
||||
renderTableRows();
|
||||
return;
|
||||
}
|
||||
// Load children for this hash
|
||||
// Load children (observations) for this hash
|
||||
try {
|
||||
const data = await api(`/packets?hash=${hash}&limit=20`);
|
||||
const data = await api(`/packets?hash=${hash}&limit=1&expand=observations`);
|
||||
const pkt = (data.packets || [])[0];
|
||||
const group = packets.find(p => p.hash === hash);
|
||||
if (group) group._children = data.packets || [];
|
||||
if (group && pkt) group._children = (pkt.observations || []).map(o => ({...pkt, ...o, _isObservation: true}));
|
||||
// Resolve any new hops from children
|
||||
const childHops = new Set();
|
||||
for (const c of (group?._children || [])) {
|
||||
@@ -763,16 +1121,41 @@
|
||||
expandedHashes.add(hash);
|
||||
renderTableRows();
|
||||
} catch {}
|
||||
};
|
||||
window._pktSelectHash = async (hash) => {
|
||||
}
|
||||
async function pktSelectHash(hash) {
|
||||
// When grouped, find first packet with this hash
|
||||
try {
|
||||
const data = await api(`/packets?hash=${hash}&limit=1`);
|
||||
if (data.packets?.[0]) selectPacket(data.packets[0].id);
|
||||
} catch {}
|
||||
};
|
||||
window._pktRefresh = loadPackets;
|
||||
window._pktBYOP = showBYOP;
|
||||
}
|
||||
|
||||
registerPage('packets', { init, destroy });
|
||||
|
||||
// Standalone packet detail page: #/packet/123
|
||||
registerPage('packet-detail', {
|
||||
init: async (app, routeParam) => {
|
||||
const id = Number(routeParam);
|
||||
app.innerHTML = `<div style="max-width:800px;margin:0 auto;padding:20px"><div class="text-center text-muted" style="padding:40px">Loading packet #${id}…</div></div>`;
|
||||
try {
|
||||
const data = await api(`/packets/${id}`);
|
||||
if (!data?.packet) { app.innerHTML = `<div style="max-width:800px;margin:0 auto;padding:40px;text-align:center"><h2>Packet not found</h2><p>Packet #${id} doesn't exist.</p><a href="#/packets">← Back to packets</a></div>`; return; }
|
||||
const hops = [];
|
||||
try { const ph = JSON.parse(data.packet.path_json || '[]'); hops.push(...ph); } catch {}
|
||||
const newHops = hops.filter(h => !(h in hopNameCache));
|
||||
if (newHops.length) await resolveHops(newHops);
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = 'max-width:800px;margin:0 auto;padding:20px';
|
||||
container.innerHTML = `<div style="margin-bottom:16px"><a href="#/packets" style="color:var(--primary);text-decoration:none">← Back to packets</a></div>`;
|
||||
const detail = document.createElement('div');
|
||||
container.appendChild(detail);
|
||||
renderDetail(detail, data);
|
||||
app.innerHTML = '';
|
||||
app.appendChild(container);
|
||||
} catch (e) {
|
||||
app.innerHTML = `<div style="max-width:800px;margin:0 auto;padding:40px;text-align:center"><h2>Error</h2><p>${e.message}</p><a href="#/packets">← Back to packets</a></div>`;
|
||||
}
|
||||
},
|
||||
destroy: () => {}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/* === MeshCore Analyzer — perf.js === */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
let interval = null;
|
||||
|
||||
async function render(app) {
|
||||
app.innerHTML = '<div style="height:100%;overflow-y:auto;padding:16px 24px;"><h2>⚡ Performance Dashboard</h2><div id="perfContent">Loading...</div></div>';
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const el = document.getElementById('perfContent');
|
||||
if (!el) return;
|
||||
try {
|
||||
const [server, client] = await Promise.all([
|
||||
fetch('/api/perf').then(r => r.json()),
|
||||
Promise.resolve(window.apiPerf ? window.apiPerf() : null)
|
||||
]);
|
||||
|
||||
// Also fetch health telemetry
|
||||
const health = await fetch('/api/health').then(r => r.json()).catch(() => null);
|
||||
|
||||
let html = '';
|
||||
|
||||
// Server overview
|
||||
html += `<div style="display:flex;gap:16px;flex-wrap:wrap;margin:16px 0;">
|
||||
<div class="perf-card"><div class="perf-num">${server.totalRequests}</div><div class="perf-label">Total Requests</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${server.avgMs}ms</div><div class="perf-label">Avg Response</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${health ? health.uptimeHuman : Math.round(server.uptime / 60) + 'm'}</div><div class="perf-label">Uptime</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${server.slowQueries.length}</div><div class="perf-label">Slow (>100ms)</div></div>
|
||||
</div>`;
|
||||
|
||||
// System health (memory, event loop, WS)
|
||||
if (health) {
|
||||
const m = health.memory, el = health.eventLoop;
|
||||
const elColor = el.p95Ms > 500 ? '#ef4444' : el.p95Ms > 100 ? '#f59e0b' : '#22c55e';
|
||||
const memColor = m.heapUsed > m.heapTotal * 0.85 ? '#ef4444' : m.heapUsed > m.heapTotal * 0.7 ? '#f59e0b' : '#22c55e';
|
||||
html += `<h3>System Health</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||||
<div class="perf-card"><div class="perf-num" style="color:${memColor}">${m.heapUsed}MB</div><div class="perf-label">Heap Used / ${m.heapTotal}MB</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${m.rss}MB</div><div class="perf-label">RSS</div></div>
|
||||
<div class="perf-card"><div class="perf-num" style="color:${elColor}">${el.p95Ms}ms</div><div class="perf-label">Event Loop p95</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${el.maxLagMs}ms</div><div class="perf-label">EL Max Lag</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${el.currentLagMs}ms</div><div class="perf-label">EL Current</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${health.websocket.clients}</div><div class="perf-label">WS Clients</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Cache stats
|
||||
if (server.cache) {
|
||||
const c = server.cache;
|
||||
const clientCache = _apiCache ? _apiCache.size : 0;
|
||||
html += `<h3>Cache</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||||
<div class="perf-card"><div class="perf-num">${c.size}</div><div class="perf-label">Server Entries</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${c.hits}</div><div class="perf-label">Server Hits</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${c.misses}</div><div class="perf-label">Server Misses</div></div>
|
||||
<div class="perf-card"><div class="perf-num" style="color:${c.hitRate > 50 ? '#22c55e' : c.hitRate > 20 ? '#f59e0b' : '#ef4444'}">${c.hitRate}%</div><div class="perf-label">Server Hit Rate</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${c.staleHits || 0}</div><div class="perf-label">Stale Hits (SWR)</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${c.recomputes || 0}</div><div class="perf-label">Recomputes</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${clientCache}</div><div class="perf-label">Client Entries</div></div>
|
||||
</div>`;
|
||||
if (client) {
|
||||
html += `<div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||||
<div class="perf-card"><div class="perf-num">${client.cacheHits || 0}</div><div class="perf-label">Client Hits</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${client.cacheMisses || 0}</div><div class="perf-label">Client Misses</div></div>
|
||||
<div class="perf-card"><div class="perf-num" style="color:${(client.cacheHitRate||0) > 50 ? '#22c55e' : '#f59e0b'}">${client.cacheHitRate || 0}%</div><div class="perf-label">Client Hit Rate</div></div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Packet Store stats
|
||||
if (server.packetStore) {
|
||||
const ps = server.packetStore;
|
||||
html += `<h3>In-Memory Packet Store</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||||
<div class="perf-card"><div class="perf-num">${ps.inMemory.toLocaleString()}</div><div class="perf-label">Packets in RAM</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.estimatedMB}MB</div><div class="perf-label">Memory Used</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.maxMB}MB</div><div class="perf-label">Memory Limit</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.queries.toLocaleString()}</div><div class="perf-label">Queries Served</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.inserts.toLocaleString()}</div><div class="perf-label">Live Inserts</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.evicted.toLocaleString()}</div><div class="perf-label">Evicted</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.indexes.byHash.toLocaleString()}</div><div class="perf-label">Unique Hashes</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.indexes.byObserver}</div><div class="perf-label">Observers</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${ps.indexes.byNode.toLocaleString()}</div><div class="perf-label">Indexed Nodes</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Server endpoints table
|
||||
const eps = Object.entries(server.endpoints);
|
||||
if (eps.length) {
|
||||
html += '<h3>Server Endpoints (sorted by total time)</h3>';
|
||||
html += '<div style="overflow-x:auto"><table class="perf-table"><thead><tr><th>Endpoint</th><th>Count</th><th>Avg</th><th>P50</th><th>P95</th><th>Max</th><th>Total</th></tr></thead><tbody>';
|
||||
for (const [path, s] of eps) {
|
||||
const total = Math.round(s.count * s.avgMs);
|
||||
const cls = s.p95Ms > 200 ? ' class="perf-slow"' : s.p95Ms > 50 ? ' class="perf-warn"' : '';
|
||||
html += `<tr${cls}><td><code>${path}</code></td><td>${s.count}</td><td>${s.avgMs}ms</td><td>${s.p50Ms}ms</td><td>${s.p95Ms}ms</td><td>${s.maxMs}ms</td><td>${total}ms</td></tr>`;
|
||||
}
|
||||
html += '</tbody></table></div>';
|
||||
}
|
||||
|
||||
// Client API calls
|
||||
if (client && client.endpoints.length) {
|
||||
html += '<h3>Client API Calls (this session)</h3>';
|
||||
html += '<div style="overflow-x:auto"><table class="perf-table"><thead><tr><th>Endpoint</th><th>Count</th><th>Avg</th><th>Max</th><th>Total</th></tr></thead><tbody>';
|
||||
for (const s of client.endpoints) {
|
||||
const cls = s.maxMs > 500 ? ' class="perf-slow"' : s.avgMs > 200 ? ' class="perf-warn"' : '';
|
||||
html += `<tr${cls}><td><code>${s.path}</code></td><td>${s.count}</td><td>${s.avgMs}ms</td><td>${s.maxMs}ms</td><td>${s.totalMs}ms</td></tr>`;
|
||||
}
|
||||
html += '</tbody></table></div>';
|
||||
}
|
||||
|
||||
// Slow queries
|
||||
if (server.slowQueries.length) {
|
||||
html += '<h3>Recent Slow Queries (>100ms)</h3>';
|
||||
html += '<div style="overflow-x:auto"><table class="perf-table"><thead><tr><th>Time</th><th>Path</th><th>Duration</th><th>Status</th></tr></thead><tbody>';
|
||||
for (const q of server.slowQueries.slice().reverse()) {
|
||||
html += `<tr class="perf-slow"><td>${new Date(q.time).toLocaleTimeString()}</td><td><code>${q.path}</code></td><td>${q.ms}ms</td><td>${q.status}</td></tr>`;
|
||||
}
|
||||
html += '</tbody></table></div>';
|
||||
}
|
||||
|
||||
html += `<div style="margin-top:16px"><button id="perfReset" style="padding:8px 16px;cursor:pointer">Reset Stats</button> <button id="perfRefresh" style="padding:8px 16px;cursor:pointer">Refresh</button></div>`;
|
||||
el.innerHTML = html;
|
||||
|
||||
document.getElementById('perfReset')?.addEventListener('click', async () => {
|
||||
await fetch('/api/perf/reset', { method: 'POST' });
|
||||
if (window._apiPerf) { window._apiPerf = { calls: 0, totalMs: 0, log: [] }; }
|
||||
refresh();
|
||||
});
|
||||
document.getElementById('perfRefresh')?.addEventListener('click', refresh);
|
||||
} catch (err) {
|
||||
el.innerHTML = `<p style="color:red">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
registerPage('perf', {
|
||||
init(app) {
|
||||
render(app);
|
||||
interval = setInterval(refresh, 5000);
|
||||
},
|
||||
destroy() {
|
||||
if (interval) { clearInterval(interval); interval = null; }
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,127 @@
|
||||
/* === MeshCore Analyzer — roles.js (shared config module) === */
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* Centralized roles, thresholds, tile URLs, and UI constants.
|
||||
* Loaded BEFORE all page scripts via index.html.
|
||||
* Defaults are set synchronously; server config overrides arrive via fetch.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
// ─── Role definitions ───
|
||||
window.ROLE_COLORS = {
|
||||
repeater: '#dc2626', companion: '#2563eb', room: '#16a34a',
|
||||
sensor: '#d97706', observer: '#8b5cf6', unknown: '#6b7280'
|
||||
};
|
||||
|
||||
window.ROLE_LABELS = {
|
||||
repeater: 'Repeaters', companion: 'Companions', room: 'Room Servers',
|
||||
sensor: 'Sensors', observer: 'Observers'
|
||||
};
|
||||
|
||||
window.ROLE_STYLE = {
|
||||
repeater: { color: '#dc2626', shape: 'diamond', radius: 10, weight: 2 },
|
||||
companion: { color: '#2563eb', shape: 'circle', radius: 8, weight: 2 },
|
||||
room: { color: '#16a34a', shape: 'square', radius: 9, weight: 2 },
|
||||
sensor: { color: '#d97706', shape: 'triangle', radius: 8, weight: 2 },
|
||||
observer: { color: '#8b5cf6', shape: 'star', radius: 11, weight: 2 }
|
||||
};
|
||||
|
||||
window.ROLE_EMOJI = {
|
||||
repeater: '◆', companion: '●', room: '■', sensor: '▲', observer: '★'
|
||||
};
|
||||
|
||||
window.ROLE_SORT = ['repeater', 'companion', 'room', 'sensor', 'observer'];
|
||||
|
||||
// ─── Health thresholds (ms) ───
|
||||
window.HEALTH_THRESHOLDS = {
|
||||
infraDegradedMs: 86400000, // 24h
|
||||
infraSilentMs: 259200000, // 72h
|
||||
nodeDegradedMs: 3600000, // 1h
|
||||
nodeSilentMs: 86400000 // 24h
|
||||
};
|
||||
|
||||
// Helper: get degraded/silent thresholds for a role
|
||||
window.getHealthThresholds = function (role) {
|
||||
var isInfra = role === 'repeater' || role === 'room';
|
||||
return {
|
||||
degradedMs: isInfra ? HEALTH_THRESHOLDS.infraDegradedMs : HEALTH_THRESHOLDS.nodeDegradedMs,
|
||||
silentMs: isInfra ? HEALTH_THRESHOLDS.infraSilentMs : HEALTH_THRESHOLDS.nodeSilentMs
|
||||
};
|
||||
};
|
||||
|
||||
// ─── Tile URLs ───
|
||||
window.TILE_DARK = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
||||
window.TILE_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
||||
|
||||
window.getTileUrl = function () {
|
||||
var isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
return isDark ? TILE_DARK : TILE_LIGHT;
|
||||
};
|
||||
|
||||
// ─── SNR thresholds ───
|
||||
window.SNR_THRESHOLDS = { excellent: 6, good: 0 };
|
||||
|
||||
// ─── Distance thresholds (km) ───
|
||||
window.DIST_THRESHOLDS = { local: 50, regional: 200 };
|
||||
|
||||
// ─── MAX_HOP_DIST (degrees, ~200km ≈ 1.8°) ───
|
||||
window.MAX_HOP_DIST = 1.8;
|
||||
|
||||
// ─── Result limits ───
|
||||
window.LIMITS = {
|
||||
topNodes: 15,
|
||||
topPairs: 12,
|
||||
topRingNodes: 8,
|
||||
topSenders: 10,
|
||||
topCollisionNodes: 10,
|
||||
recentReplay: 8,
|
||||
feedMax: 25
|
||||
};
|
||||
|
||||
// ─── Performance thresholds ───
|
||||
window.PERF_SLOW_MS = 100;
|
||||
|
||||
// ─── WebSocket reconnect delay (ms) ───
|
||||
window.WS_RECONNECT_MS = 3000;
|
||||
|
||||
// ─── Cache invalidation debounce (ms) ───
|
||||
window.CACHE_INVALIDATE_MS = 5000;
|
||||
|
||||
// ─── External URLs ───
|
||||
window.EXTERNAL_URLS = {
|
||||
flasher: 'https://flasher.meshcore.co.uk/'
|
||||
};
|
||||
|
||||
// ─── Fetch server overrides ───
|
||||
window.MeshConfigReady = fetch('/api/config/client').then(function (r) { return r.json(); }).then(function (cfg) {
|
||||
if (cfg.roles) {
|
||||
if (cfg.roles.colors) Object.assign(ROLE_COLORS, cfg.roles.colors);
|
||||
if (cfg.roles.labels) Object.assign(ROLE_LABELS, cfg.roles.labels);
|
||||
if (cfg.roles.style) {
|
||||
for (var k in cfg.roles.style) ROLE_STYLE[k] = Object.assign(ROLE_STYLE[k] || {}, cfg.roles.style[k]);
|
||||
}
|
||||
if (cfg.roles.emoji) Object.assign(ROLE_EMOJI, cfg.roles.emoji);
|
||||
if (cfg.roles.sort) window.ROLE_SORT = cfg.roles.sort;
|
||||
}
|
||||
if (cfg.healthThresholds) Object.assign(HEALTH_THRESHOLDS, cfg.healthThresholds);
|
||||
if (cfg.tiles) {
|
||||
if (cfg.tiles.dark) window.TILE_DARK = cfg.tiles.dark;
|
||||
if (cfg.tiles.light) window.TILE_LIGHT = cfg.tiles.light;
|
||||
}
|
||||
if (cfg.snrThresholds) Object.assign(SNR_THRESHOLDS, cfg.snrThresholds);
|
||||
if (cfg.distThresholds) Object.assign(DIST_THRESHOLDS, cfg.distThresholds);
|
||||
if (cfg.maxHopDist != null) window.MAX_HOP_DIST = cfg.maxHopDist;
|
||||
if (cfg.limits) Object.assign(LIMITS, cfg.limits);
|
||||
if (cfg.perfSlowMs != null) window.PERF_SLOW_MS = cfg.perfSlowMs;
|
||||
if (cfg.wsReconnectMs != null) window.WS_RECONNECT_MS = cfg.wsReconnectMs;
|
||||
if (cfg.cacheInvalidateMs != null) window.CACHE_INVALIDATE_MS = cfg.cacheInvalidateMs;
|
||||
if (cfg.externalUrls) Object.assign(EXTERNAL_URLS, cfg.externalUrls);
|
||||
// Sync ROLE_STYLE colors with ROLE_COLORS
|
||||
for (var role in ROLE_STYLE) {
|
||||
if (ROLE_COLORS[role]) ROLE_STYLE[role].color = ROLE_COLORS[role];
|
||||
}
|
||||
}).catch(function () { /* use defaults */ });
|
||||
})();
|
||||
@@ -22,8 +22,12 @@
|
||||
--surface-3: #ffffff;
|
||||
--content-bg: var(--surface-0);
|
||||
--card-bg: var(--surface-1);
|
||||
--hover-bg: rgba(0,0,0, 0.04);
|
||||
}
|
||||
|
||||
/* ⚠️ DARK THEME VARIABLES — KEEP BOTH BLOCKS IN SYNC
|
||||
The media query handles OS-level dark mode (auto); [data-theme="dark"] handles manual toggle.
|
||||
When changing dark theme variables, update BOTH blocks below. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--surface-0: #0f0f23;
|
||||
@@ -40,9 +44,11 @@
|
||||
--detail-bg: #232340;
|
||||
--input-bg: #1e1e34;
|
||||
--selected-bg: #1e3a5f;
|
||||
--hover-bg: rgba(255,255,255, 0.06);
|
||||
--section-bg: #1e1e34;
|
||||
}
|
||||
}
|
||||
|
||||
/* ⚠️ DARK THEME VARIABLES — KEEP IN SYNC with @media block above */
|
||||
[data-theme="dark"] {
|
||||
--surface-0: #0f0f23;
|
||||
--surface-1: #1a1a2e;
|
||||
@@ -58,6 +64,8 @@
|
||||
--detail-bg: #232340;
|
||||
--input-bg: #1e1e34;
|
||||
--selected-bg: #1e3a5f;
|
||||
--hover-bg: rgba(255,255,255, 0.06);
|
||||
--section-bg: #1e1e34;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
@@ -74,7 +82,6 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
}
|
||||
|
||||
/* === Touch Targets === */
|
||||
.nav-btn { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.nav-link { min-height: 44px; display: inline-flex; align-items: center; }
|
||||
|
||||
/* === Nav === */
|
||||
@@ -143,7 +150,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
.nav-stats .stat-val.updated { color: var(--accent); }
|
||||
|
||||
/* === Layout === */
|
||||
#app { height: calc(100vh - 52px); overflow: hidden; }
|
||||
#app { height: calc(100vh - 52px); height: calc(100dvh - 52px); overflow: hidden; }
|
||||
|
||||
.split-layout {
|
||||
display: flex; height: 100%; overflow: hidden;
|
||||
@@ -214,9 +221,12 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
.data-table th.sortable:hover { color: var(--accent); }
|
||||
.data-table td {
|
||||
padding: 3px 6px; border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle; white-space: nowrap; max-width: 180px;
|
||||
vertical-align: middle; white-space: nowrap;
|
||||
overflow: hidden; text-overflow: ellipsis;
|
||||
max-width: 0; /* forces td to respect table width instead of expanding to content */
|
||||
}
|
||||
.data-table td.col-details { white-space: normal; word-break: break-word; }
|
||||
.data-table td:has(.spark-bar), .data-table td.col-spark { max-width: none; overflow: visible; min-width: 80px; }
|
||||
.data-table tbody tr:nth-child(even) { background: var(--row-stripe); }
|
||||
.data-table tbody tr:hover { background: var(--row-hover); cursor: pointer; }
|
||||
.data-table tbody tr.selected { background: var(--selected-bg); }
|
||||
@@ -253,6 +263,11 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
font-size: 10px; font-weight: 700; font-family: var(--mono);
|
||||
background: var(--nav-bg); color: #fff; letter-spacing: .5px;
|
||||
}
|
||||
.badge-obs {
|
||||
display: inline-block; padding: 1px 6px; border-radius: 10px;
|
||||
font-size: 10px; font-weight: 600;
|
||||
background: #ede9fe; color: #6d28d9;
|
||||
}
|
||||
|
||||
/* === Monospace === */
|
||||
.mono { font-family: var(--mono); font-size: 12px; }
|
||||
@@ -314,7 +329,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
padding: 5px 8px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.field-table .section-row td {
|
||||
background: #eef2ff; font-weight: 700; font-size: 11px;
|
||||
background: var(--section-bg, #eef2ff); font-weight: 700; font-size: 11px;
|
||||
text-transform: uppercase; letter-spacing: .5px; color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -428,6 +443,14 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
.ch-item-preview { font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.ch-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; position: relative; }
|
||||
|
||||
/* Sidebar resize handle (#89) */
|
||||
.ch-sidebar-resize {
|
||||
position: absolute; top: 0; right: -3px; width: 6px; height: 100%;
|
||||
cursor: col-resize; z-index: 10; background: transparent;
|
||||
}
|
||||
.ch-sidebar-resize:hover { background: var(--accent); opacity: 0.3; }
|
||||
.ch-sidebar { position: relative; }
|
||||
.ch-main-header {
|
||||
padding: 14px 20px; font-size: 16px; font-weight: 700;
|
||||
border-bottom: 1px solid var(--border); background: var(--card-bg);
|
||||
@@ -483,7 +506,7 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
.ch-node-tooltip {
|
||||
position: fixed; z-index: 1000; background: var(--card-bg); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 10px 14px; box-shadow: 0 4px 16px rgba(0,0,0,.15);
|
||||
min-width: 180px; max-width: 260px; pointer-events: none;
|
||||
min-width: 180px; max-width: 260px;
|
||||
}
|
||||
.ch-tooltip-name { font-weight: 700; font-size: 14px; margin-bottom: 4px; }
|
||||
.ch-tooltip-role { font-size: 12px; color: var(--text-muted); margin-bottom: 2px; }
|
||||
@@ -664,7 +687,7 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
|
||||
/* === Observers Page === */
|
||||
.observers-page { padding: 20px; max-width: 1200px; margin: 0 auto; }
|
||||
.observers-page { padding: 20px; max-width: 1200px; margin: 0 auto; overflow-y: auto; height: calc(100vh - 56px); }
|
||||
.obs-summary { display: flex; gap: 20px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.obs-stat { display: flex; align-items: center; gap: 6px; font-size: 14px; color: var(--text-muted); }
|
||||
.health-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
@@ -672,7 +695,10 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
.health-dot.health-yellow { background: #eab308; box-shadow: 0 0 6px #eab30880; }
|
||||
.health-dot.health-red { background: #ef4444; box-shadow: 0 0 6px #ef444480; }
|
||||
.obs-table td:first-child { white-space: nowrap; }
|
||||
.spark-bar { position: relative; width: 100px; height: 18px; background: var(--border); border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; }
|
||||
.obs-table td:nth-child(6) { max-width: none; overflow: visible; }
|
||||
.col-observer { min-width: 70px; max-width: none; }
|
||||
.spark-bar { position: relative; min-width: 60px; max-width: 100px; flex: 1; height: 18px; background: var(--border); border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; }
|
||||
@media (max-width: 640px) { .spark-bar { max-width: 60px; } }
|
||||
.spark-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #60a5fa); border-radius: 4px; transition: width 0.3s; }
|
||||
.spark-label { position: absolute; right: 4px; top: 0; line-height: 18px; font-size: 11px; color: var(--text); font-weight: 500; }
|
||||
|
||||
@@ -793,8 +819,8 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
|
||||
/* Layouts: stack instead of side-by-side */
|
||||
.split-layout { flex-direction: column; overflow-y: auto; }
|
||||
.panel-left { padding: 10px; }
|
||||
.panel-right { width: 100%; min-width: 0; border-left: none; border-top: 1px solid var(--border); max-height: 50vh; }
|
||||
.panel-left { padding: 6px; flex: 1; min-height: 0; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
.panel-right { display: none; }
|
||||
|
||||
/* Channels: Discord-style full screen toggle */
|
||||
.ch-layout { flex-direction: row; position: relative; }
|
||||
@@ -809,24 +835,31 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
z-index: 3; background: var(--content-bg);
|
||||
}
|
||||
.ch-layout.ch-show-main .ch-main { transform: translateX(0); }
|
||||
.ch-layout.ch-show-main .ch-sidebar { pointer-events: none; }
|
||||
.ch-back-btn { display: flex; }
|
||||
.ch-main-header { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* Tables: smaller text, allow horizontal scroll */
|
||||
.data-table { font-size: 12px; }
|
||||
.data-table td { padding: 6px 6px; max-width: 120px; }
|
||||
.data-table th { padding: 6px 6px; font-size: 11px; }
|
||||
/* Tables: smaller text for mobile */
|
||||
.data-table { font-size: 11px; min-width: 0; }
|
||||
.data-table td { padding: 5px 4px; max-width: 100px; }
|
||||
.data-table th { padding: 5px 4px; font-size: 10px; }
|
||||
.panel-left { overflow-x: auto; }
|
||||
|
||||
/* Filters: full width */
|
||||
.filter-bar { flex-direction: column; }
|
||||
.filter-bar input { width: 100%; }
|
||||
.filter-bar select { width: 100%; }
|
||||
/* Filters: collapse on mobile */
|
||||
.filter-bar { flex-direction: row; flex-wrap: wrap; gap: 4px; }
|
||||
.filter-toggle-btn { display: inline-flex !important; }
|
||||
.filter-bar > *:not(.filter-toggle-btn):not(.col-toggle-wrap) { display: none; }
|
||||
.filter-bar.filters-expanded > * { display: inline-flex; }
|
||||
.filter-bar.filters-expanded > .col-toggle-wrap { display: inline-block; }
|
||||
.filter-bar.filters-expanded input { width: 100%; }
|
||||
.filter-bar.filters-expanded select { width: 100%; }
|
||||
.filter-bar .btn { min-height: 36px; }
|
||||
.node-filter-wrap { width: 100%; }
|
||||
|
||||
/* Nodes */
|
||||
.nodes-topbar { flex-direction: column; gap: 8px; padding: 10px; }
|
||||
.nodes-tabs-bar { flex-direction: column; }
|
||||
.nodes-counts { flex-wrap: wrap; }
|
||||
.node-count-pill { font-size: 11px; padding: 2px 8px; }
|
||||
|
||||
/* Traces */
|
||||
.trace-summary { flex-direction: column; }
|
||||
@@ -837,7 +870,7 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
.search-overlay { padding-top: 60px; }
|
||||
|
||||
/* Map controls */
|
||||
.map-controls { width: calc(100vw - 24px); right: 12px; top: 8px; max-height: 200px; }
|
||||
.map-controls { width: calc(100vw - 24px); right: 12px; top: 8px; max-height: 200px; font-size: 12px; padding: 10px 12px; }
|
||||
#leaflet-map { z-index: 0; }
|
||||
#map-wrap { z-index: 0; }
|
||||
|
||||
@@ -908,7 +941,7 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
.nav-fav-dropdown {
|
||||
display: none; position: absolute; top: 100%; right: 0; z-index: 1000;
|
||||
min-width: 260px; max-height: 360px; overflow-y: auto;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--surface-1, var(--detail-bg)); border: 1px solid var(--border); border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.15); margin-top: 6px;
|
||||
}
|
||||
.nav-fav-dropdown.open { display: block; }
|
||||
@@ -936,10 +969,14 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
|
||||
/* Column resize handles */
|
||||
.col-resize-handle {
|
||||
position: absolute; top: 4px; right: -1px; width: 3px; height: calc(100% - 8px);
|
||||
cursor: col-resize; z-index: 5; background: var(--border); border-radius: 1px;
|
||||
position: absolute; top: 0; right: -4px; width: 9px; height: 100%;
|
||||
cursor: col-resize; z-index: 5; background: transparent; border-radius: 1px;
|
||||
}
|
||||
.col-resize-handle:hover, .col-resize-handle.active {
|
||||
.col-resize-handle::after {
|
||||
content: ''; position: absolute; top: 4px; left: 3px; width: 3px; height: calc(100% - 8px);
|
||||
background: var(--border); border-radius: 1px;
|
||||
}
|
||||
.col-resize-handle:hover::after, .col-resize-handle.active::after {
|
||||
background: var(--accent); opacity: 0.6;
|
||||
}
|
||||
|
||||
@@ -971,7 +1008,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
min-height: 36px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.ch-avatar.ch-tappable { min-width: 40px; min-height: 40px; width: 40px; height: 40px; }
|
||||
.ch-avatar.ch-tappable { min-width: 44px; min-height: 44px; width: 44px; height: 44px; }
|
||||
}
|
||||
|
||||
/* Full-screen node detail */
|
||||
@@ -1038,7 +1075,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.tab-btn { padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px; background: var(--card-bg); color: var(--text); cursor: pointer; font-size: 13px; transition: all .15s; }
|
||||
.tab-btn:hover { background: var(--hover-bg, rgba(0,0,0,.04)); }
|
||||
.tab-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 240px)); gap: 12px; margin-bottom: 16px; }
|
||||
.stat-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 14px; text-align: center; }
|
||||
.stat-value { font-size: 24px; font-weight: 700; color: var(--text); }
|
||||
.stat-label { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||||
@@ -1066,6 +1103,8 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.repeater-name { min-width: 80px; }
|
||||
.reach-ring { flex-wrap: wrap; }
|
||||
.analytics-page { padding: 12px; }
|
||||
.analytics-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.observer-selector { display: flex; gap: 4px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.node-qr { text-align: center; margin-top: 8px; }
|
||||
@@ -1115,6 +1154,12 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.node-filter-option:hover { background: var(--surface-2, rgba(255,255,255,0.08)); }
|
||||
.node-filter-option.node-filter-active { background: var(--accent); color: #fff; }
|
||||
|
||||
/* Hide low-value columns on mobile */
|
||||
@media (max-width: 640px) {
|
||||
.col-region, .col-rpt, .col-size { display: none; }
|
||||
}
|
||||
|
||||
/* Clickable hop links */
|
||||
.hop-link {
|
||||
@@ -1140,6 +1185,19 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
}
|
||||
.detail-map-link:hover { background: rgba(245, 158, 11, 0.25); }
|
||||
|
||||
.copy-link-btn {
|
||||
padding: 5px 12px;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
border: 1px solid rgba(59, 130, 246, 0.25);
|
||||
color: var(--primary, #3b82f6);
|
||||
border-radius: 6px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.copy-link-btn:hover { background: rgba(59, 130, 246, 0.25); }
|
||||
|
||||
/* Route tooltip on map */
|
||||
.route-tooltip {
|
||||
background: rgba(0,0,0,0.8) !important;
|
||||
@@ -1161,7 +1219,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.hop-prefix { color: #9ca3af; font-size: 0.8em; }
|
||||
|
||||
/* Subpath split layout */
|
||||
.subpath-layout { display: flex; gap: 0; height: calc(100vh - 160px); position: relative; }
|
||||
.subpath-layout { display: flex; gap: 0; flex: 1; min-height: 0; overflow: auto; position: relative; }
|
||||
.subpath-list { flex: 1; overflow-y: auto; padding: 16px; min-width: 0; }
|
||||
.subpath-detail { width: 420px; min-width: 360px; max-width: 50vw; border-left: 1px solid var(--border, #e5e7eb); overflow-y: auto; padding: 16px; transition: width 0.2s; }
|
||||
.subpath-detail.collapsed { width: 0; min-width: 0; padding: 0; overflow: hidden; border: none; }
|
||||
@@ -1186,6 +1244,14 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
.subpath-detail { width: 100%; border-left: none; border-top: 1px solid var(--border, #e5e7eb); }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.subpath-detail { min-width: 100%; width: 100%; max-width: 100%; }
|
||||
.subpath-layout { flex-direction: column; }
|
||||
}
|
||||
|
||||
/* Legend swatches */
|
||||
.legend-swatch { display: inline-block; width: 12px; height: 12px; border: 1px solid var(--border); vertical-align: middle; }
|
||||
|
||||
/* Subpath jump nav */
|
||||
.subpath-jump-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; font-size: 0.9em; flex-wrap: wrap; }
|
||||
.subpath-jump-nav span { color: #9ca3af; }
|
||||
@@ -1196,3 +1262,188 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
.subpath-list .analytics-table td:nth-child(2) { white-space: normal; word-break: break-word; max-width: 50vw; }
|
||||
.subpath-list .analytics-table { table-layout: auto; }
|
||||
.subpath-list h4 { margin-top: 24px; }
|
||||
|
||||
/* #70 — BYOP textarea larger on mobile */
|
||||
@media (max-width: 640px) {
|
||||
.byop-input { min-height: 120px; }
|
||||
}
|
||||
|
||||
/* #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-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:hover { background: var(--row-hover); }
|
||||
|
||||
/* Column hide classes */
|
||||
.hide-col-region .col-region,
|
||||
.hide-col-time .col-time,
|
||||
.hide-col-hash .col-hash,
|
||||
.hide-col-size .col-size,
|
||||
.hide-col-type .col-type,
|
||||
.hide-col-observer .col-observer,
|
||||
.hide-col-path .col-path,
|
||||
.hide-col-rpt .col-rpt,
|
||||
.hide-col-details .col-details { display: none; }
|
||||
|
||||
/* === Home page fixes === */
|
||||
|
||||
/* #25 — Widen home page content cap from 720px to 1200px */
|
||||
.home-stats,
|
||||
.home-health,
|
||||
.home-journey,
|
||||
.home-checklist,
|
||||
.home-footer,
|
||||
.home-favorites { max-width: 1200px; }
|
||||
|
||||
/* #40 — Increase suggest-claim touch target to ≥44px */
|
||||
.suggest-claim { min-height: 44px; min-width: 44px; padding: 10px 14px; display: inline-flex; align-items: center; justify-content: center; }
|
||||
|
||||
/* #41 — Lower My Nodes grid minimum to prevent overflow on 375-640px */
|
||||
.my-nodes-grid { max-width: 1200px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
|
||||
|
||||
/* #42 — Stats cards: use grid with max-width per card on wide screens */
|
||||
.home-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 200px)); justify-content: center; }
|
||||
|
||||
/* #44 — Namespaced home sparkline classes (avoid collision with observers .spark-bar) */
|
||||
.home-spark-label { font-size: .65rem; color: var(--text-muted); margin-bottom: 4px; }
|
||||
.home-spark-bars { display: flex; align-items: flex-end; gap: 2px; height: 28px; }
|
||||
.home-spark-bar { flex: 1; background: var(--accent); border-radius: 1px; min-width: 0; }
|
||||
|
||||
/* === Bug fixes: #17 #20 #21 #69 === */
|
||||
|
||||
/* #17 — Hash matrix mobile overflow */
|
||||
.hash-matrix-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; max-width: 100%; }
|
||||
@media (max-width: 640px) {
|
||||
.hash-matrix-table td { width: 24px !important; height: 24px !important; font-size: 0.7em !important; }
|
||||
.hash-matrix-table td .hash-cell { padding: 0; }
|
||||
}
|
||||
|
||||
/* #20 — Observers table horizontal scroll on mobile */
|
||||
.obs-table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
.obs-table-scroll .obs-table { min-width: 640px; }
|
||||
@media (max-width: 640px) {
|
||||
.spark-bar { min-width: 60px; width: auto; }
|
||||
}
|
||||
|
||||
/* #21 — Chat message bubble max-width */
|
||||
.ch-msg-bubble { max-width: 720px; }
|
||||
|
||||
/* #69 — Touch-friendly resize handle */
|
||||
@media (pointer: coarse) {
|
||||
.panel-resize-handle { width: 12px !important; }
|
||||
}
|
||||
/* #21 — max-width applied via .ch-msg-bubble rule above */
|
||||
|
||||
/* === Bug fixes: #16 collapsible controls, #53 detail map height === */
|
||||
.map-controls-toggle {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1001;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border, #333);
|
||||
background: var(--bg-card, #1e1e1e);
|
||||
color: var(--text, #fff);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||||
}
|
||||
.map-controls.collapsed {
|
||||
display: none;
|
||||
}
|
||||
.node-detail-map {
|
||||
height: 280px;
|
||||
min-height: 200px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.node-detail-map {
|
||||
height: 200px;
|
||||
min-height: 160px;
|
||||
}
|
||||
}
|
||||
.detail-back-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border, #333);
|
||||
color: var(--text, #fff);
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.meshcore-marker { background: none !important; border: none !important; }
|
||||
|
||||
/* === Node Analytics === */
|
||||
.analytics-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 16px; }
|
||||
.analytics-stat-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; text-align: center; }
|
||||
.analytics-stat-label { font-size: 10px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-muted); margin-bottom: 2px; }
|
||||
.analytics-stat-value { font-size: 20px; font-weight: 700; }
|
||||
.analytics-stat-desc { font-size: 10px; color: var(--text-muted); margin-top: 2px; font-style: italic; }
|
||||
.analytics-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px; }
|
||||
.analytics-chart-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; padding: 12px; }
|
||||
.analytics-chart-card.full { grid-column: 1 / -1; }
|
||||
.analytics-chart-card h4 { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-muted); margin-bottom: 4px; }
|
||||
.analytics-chart-desc { font-size: 10px; color: var(--text-muted); margin-bottom: 8px; font-style: italic; }
|
||||
.analytics-heatmap { display: grid; grid-template-columns: 40px repeat(24, 1fr); gap: 2px; }
|
||||
.analytics-heatmap-cell { aspect-ratio: 1; border-radius: 2px; cursor: default; }
|
||||
.analytics-heatmap-label { font-size: 10px; color: var(--text-muted); display: flex; align-items: center; }
|
||||
.analytics-time-range { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
.analytics-time-range button { padding: 4px 12px; border-radius: 4px; border: 1px solid var(--border); background: var(--card-bg); color: var(--text); cursor: pointer; font-size: 12px; }
|
||||
.analytics-time-range button.active { background: var(--accent); color: white; border-color: var(--accent); }
|
||||
.analytics-peer-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.analytics-peer-table th { text-align: left; padding: 6px 8px; border-bottom: 2px solid var(--border); color: var(--text-muted); font-size: 11px; text-transform: uppercase; }
|
||||
.analytics-peer-table td { padding: 6px 8px; border-bottom: 1px solid var(--border); }
|
||||
.analytics-peer-table tr:hover td { background: var(--card-bg); }
|
||||
@media (max-width: 768px) { .analytics-stats { grid-template-columns: repeat(2, 1fr); } .analytics-charts { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 480px) { .analytics-stats { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Claimed (My Mesh) node rows */
|
||||
.claimed-row { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; border-left: 3px solid var(--accent); }
|
||||
.claimed-row:hover { background: color-mix(in srgb, var(--accent) 14%, transparent) !important; }
|
||||
.claimed-badge { color: var(--accent); font-size: 13px; margin-right: 2px; }
|
||||
|
||||
/* Filter toggle button — hidden on desktop */
|
||||
.filter-toggle-btn { display: none; }
|
||||
|
||||
/* Mobile detail bottom sheet */
|
||||
.mobile-detail-sheet {
|
||||
display: none;
|
||||
position: fixed; bottom: 0; left: 0; right: 0;
|
||||
max-height: 70vh; background: var(--detail-bg);
|
||||
border-top-left-radius: 16px; border-top-right-radius: 16px;
|
||||
box-shadow: 0 -4px 24px rgba(0,0,0,.3);
|
||||
z-index: 200; overflow-y: auto; padding: 8px 16px 24px;
|
||||
transform: translateY(100%); transition: transform .25s ease;
|
||||
}
|
||||
.mobile-detail-sheet.open { display: block; transform: translateY(0); }
|
||||
.mobile-sheet-handle {
|
||||
width: 40px; height: 4px; background: var(--border);
|
||||
border-radius: 2px; margin: 4px auto 8px; cursor: pointer;
|
||||
}
|
||||
.mobile-sheet-close {
|
||||
position: absolute; top: 8px; right: 12px;
|
||||
background: none; border: none; font-size: 20px;
|
||||
color: var(--text-muted); cursor: pointer; z-index: 1;
|
||||
}
|
||||
.mobile-sheet-close:hover { color: var(--text); }
|
||||
.mobile-sheet-content { padding-top: 4px; }
|
||||
|
||||
/* Perf dashboard */
|
||||
.perf-card { background: var(--surface-1); border: 1px solid var(--border); border-radius: 8px; padding: 12px 20px; min-width: 120px; text-align: center; }
|
||||
.perf-num { font-size: 24px; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.perf-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; }
|
||||
.perf-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.perf-table th { text-align: left; padding: 6px 10px; border-bottom: 2px solid var(--border); color: var(--text-muted); font-size: 11px; text-transform: uppercase; }
|
||||
.perf-table td { padding: 5px 10px; border-bottom: 1px solid var(--border); font-variant-numeric: tabular-nums; }
|
||||
.perf-table code { font-size: 12px; color: var(--text); }
|
||||
.perf-table .perf-slow { background: rgba(239, 68, 68, 0.08); }
|
||||
.perf-table .perf-slow td { color: #ef4444; }
|
||||
.perf-table .perf-warn { background: rgba(251, 191, 36, 0.06); }
|
||||
.perf-table .perf-warn td { color: #f59e0b; }
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<h2>🔍 Packet Trace</h2>
|
||||
</div>
|
||||
<div class="trace-search">
|
||||
<input type="text" id="traceHashInput" placeholder="Enter packet hash…" value="${urlHash}">
|
||||
<input type="text" id="traceHashInput" placeholder="Enter packet hash…" value="${urlHash}" aria-label="Packet hash to trace">
|
||||
<button class="btn-primary" id="traceBtn">Trace</button>
|
||||
</div>
|
||||
<div id="traceResults"></div>
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# UI/UX Review: Analytics, Channels & Observers Pages
|
||||
|
||||
Reviewer: subagent | Date: 2026-03-19
|
||||
|
||||
---
|
||||
|
||||
## Analytics Page
|
||||
|
||||
### Accessibility
|
||||
|
||||
1. **[Major]** Tab buttons lack `role="tablist"` / `role="tab"` / `aria-selected` — screen readers can't identify the tab pattern. (`analytics.js` ~L60-68, the `.analytics-tabs` div and `.tab-btn` buttons)
|
||||
|
||||
2. **[Major]** All SVG charts (bar charts, scatter plots, histograms, sparklines) have zero text alternatives — no `role="img"`, no `aria-label`, no `<title>` element. Screen readers get nothing. (`analytics.js` — `barChart()` L27, `sparkSvg()` L14, `renderScatter()` L142, `histogram()` L42)
|
||||
|
||||
3. **[Major]** Hash matrix cells use color alone (green/yellow/red) to convey collision status. Color-blind users can't distinguish them. No pattern/icon/text differentiation. (`analytics.js` ~L339-350)
|
||||
|
||||
4. **[Minor]** `clickable-row` elements use `onclick` inline handlers on `<tr>` — not keyboard-focusable, no `tabindex`, no `role="link"` or `role="button"`. (`analytics.js` L293, L318, L328 — multiple tables)
|
||||
|
||||
5. **[Minor]** Observer selector buttons in Topology tab reuse `.tab-btn` class but lack proper ARIA tab semantics. (`analytics.js` ~L220)
|
||||
|
||||
6. **[Minor]** Scatter plot quality zone labels ("Excellent", "Good", "Weak") use semi-transparent fills that may have insufficient contrast against various backgrounds. (`analytics.js` ~L166-170)
|
||||
|
||||
### Mobile Responsive
|
||||
|
||||
7. **[Major]** `.analytics-row` goes `flex-direction: column` on mobile (good), but the hash matrix table (`renderHashMatrix`) generates a fixed-width 16×16 grid with `cellSize=36px` → minimum ~600px wide. The `overflow-x:auto` wrapper helps but the detail panel beside it won't fit. (`analytics.js` ~L331, `style.css` — no specific mobile override for hash matrix)
|
||||
|
||||
8. **[Minor]** SVG charts use fixed `max-height` values (e.g., `max-height:300px`, `max-height:160px`) which may waste space or clip on very small screens. Width is `100%` though, which is correct. (`analytics.js` ~L143, L189, L207)
|
||||
|
||||
9. **[Minor]** `.subpath-layout` uses `height: calc(100vh - 160px)` — this assumes a specific header height. If the analytics tabs wrap to 2 lines on mobile, content gets clipped. (`style.css` — `.subpath-layout`)
|
||||
|
||||
10. **[Minor]** Route Patterns subpath detail panel has `min-width: 360px` — won't fit on phones <375px even in column layout. (`style.css` — `.subpath-detail`)
|
||||
|
||||
### Desktop Space Efficiency
|
||||
|
||||
11. **[Minor]** `.analytics-page` has `max-width: 1600px` — reasonable for most content but the hash matrix + detail panel side-by-side could use more width on ultrawide monitors. (`style.css` — `.analytics-page`)
|
||||
|
||||
12. **[Minor]** Overview stat cards use `minmax(160px, 1fr)` grid — on very wide screens you get many small cards in one row which looks sparse. Could benefit from a `max-width` per card. (`style.css` — `.stats-grid`)
|
||||
|
||||
### Bugs / Inconsistencies
|
||||
|
||||
13. **[Critical]** `svgLine()` function (L7-12) is defined but **never called anywhere**. Dead code. (`analytics.js` L7)
|
||||
|
||||
14. **[Major]** `window._analyticsData` is set as a global — potential for conflicts with other scripts, and the `destroy()` function only does `delete window._analyticsData` but doesn't clean up event listeners on `#analyticsTabs`. (`analytics.js` L87, L460)
|
||||
|
||||
15. **[Major]** `renderCollisions()` and `renderHashMatrix()` both independently fetch `/nodes?limit=2000` — duplicate API call when viewing the "Hash Collisions" tab. (`analytics.js` ~L329, L380)
|
||||
|
||||
16. **[Minor]** `renderSubpaths` uses `async function` but is called without `await` in `renderTab()` switch — the loading state and error handling work via the function's internal try/catch, but the `requestAnimationFrame` column resize in `renderTab` will fire before the async content renders. (`analytics.js` L96 calls renderSubpaths, L99-103 does column resize immediately)
|
||||
|
||||
17. **[Minor]** The `renderTab` function applies `makeColumnsResizable` to `.analytics-table` elements, but `makeColumnsResizable` is called without checking if it exists (it's presumably defined in `app.js`). No guard. (`analytics.js` L100)
|
||||
|
||||
18. **[Minor]** `timeAgo()` and `api()` are used but not imported/defined in this file — relies on global scope from `app.js`. Not a bug per se but fragile coupling. (`analytics.js` multiple locations)
|
||||
|
||||
19. **[Minor]** Hash matrix legend uses inline styles for color swatches rather than CSS classes — inconsistent with the rest of the codebase which uses `.legend-dot` class. (`analytics.js` ~L365)
|
||||
|
||||
---
|
||||
|
||||
## Channels Page
|
||||
|
||||
### Accessibility
|
||||
|
||||
20. **[Major]** Channel list items are `<button>` elements (good!) but message bubbles with sender links use `data-node` + base64-encoded names with click handlers via event delegation. These `<span>` elements with `data-node` are not focusable via keyboard — no `tabindex`, no `role="button"`. (`channels.js` ~L131 `highlightMentions()`, ~L229 message rendering)
|
||||
|
||||
21. **[Major]** The node detail panel slides in but doesn't trap focus — keyboard users can tab behind it. Close button exists but no focus management on open/close. (`channels.js` ~L60-80, `showNodeDetail()`)
|
||||
|
||||
22. **[Minor]** `aria-live="polite"` on scroll button is good, but the button text "↓ New messages" is static — it doesn't actually announce when new messages arrive, only when visibility toggles. (`channels.js` ~L152)
|
||||
|
||||
23. **[Minor]** Channel sidebar has `role="navigation"` and `aria-label="Channel list"` — semantically it's more of a listbox than navigation. (`channels.js` ~L141)
|
||||
|
||||
24. **[Minor]** Node tooltip (`.ch-node-tooltip`) has `pointer-events: none` — keyboard users can never interact with its content. (`style.css` — `.ch-node-tooltip`)
|
||||
|
||||
### Mobile Responsive
|
||||
|
||||
25. **[Minor]** Mobile channel layout uses absolute positioning with `transform: translateX(100%)` for the slide animation — this works but the sidebar gets `pointer-events: none` when main is shown, meaning you can't scroll it even if partially visible. Minor since back button exists. (`style.css` ~L478-484)
|
||||
|
||||
26. **[Minor]** Node detail panel is `max-width: 80%` and `width: 320px` — on small phones this leaves only 20% visible of the messages behind it, but the panel covers the content anyway. Adequate. (`style.css` — `.ch-node-panel`)
|
||||
|
||||
27. **[Minor]** `.ch-avatar` is 36×36px on desktop, bumped to 40×40 on mobile — meets 44px touch target when including the padding around messages, but the avatar itself is slightly under the 44px WCAG recommendation. (`style.css` — `.ch-avatar`, mobile override)
|
||||
|
||||
### Desktop Space Efficiency
|
||||
|
||||
28. **[Minor]** Channel sidebar is fixed at 280px (`min-width: 280px`) — not resizable. On wide monitors this is fine, but on 900-1024px tablets it shrinks to 220px which may truncate channel names. (`style.css` — `.ch-sidebar`, tablet media query)
|
||||
|
||||
29. **[Minor]** Messages area has no `max-width` — on ultrawide monitors, message bubbles stretch very wide. Chat apps typically cap message width at ~700-800px. (`style.css` — `.ch-messages` has no max-width, `.ch-msg-bubble` has `max-width: 100%`)
|
||||
|
||||
### Bugs / Inconsistencies
|
||||
|
||||
30. **[Major]** `window._chShowNode`, `_chCloseNode`, `_chHoverNode`, `_chUnhoverNode`, `_chBack`, `_chSelect` are all set as globals and **never cleaned up** in `destroy()`. If the page is navigated away and back, these persist. Also `_chSelect` is defined but only used via `data-hash` click delegation, making it dead code. (`channels.js` ~L98-103, L269)
|
||||
|
||||
31. **[Minor]** `getSenderColor()` checks `data-theme` attribute and `prefers-color-scheme` at call time — this means if the user toggles dark mode without reloading, already-rendered messages keep old colors while new ones get correct colors. Not reactively updated. (`channels.js` ~L116-120)
|
||||
|
||||
32. **[Minor]** `lookupNode()` caches results in `nodeCache` but cache is never invalidated. If node data changes (name, role), stale data persists until page reload. (`channels.js` ~L12-21)
|
||||
|
||||
33. **[Minor]** `refreshMessages()` compares `messages.length` AND last timestamp to detect changes — but at the 200-message limit, both could be the same even if older messages rotated out. Edge case. (`channels.js` ~L210-213)
|
||||
|
||||
---
|
||||
|
||||
## Observers Page
|
||||
|
||||
### Accessibility
|
||||
|
||||
34. **[Major]** Health status dots use color alone (green/yellow/red) — color-blind users can't distinguish. The text label "Online"/"Stale"/"Offline" is next to the dot in the table which helps, but the summary dots at the top have no text inside the dot itself. (`observers.js` ~L76-79, `style.css` — `.health-dot`)
|
||||
|
||||
35. **[Minor]** Refresh button uses `onclick="window._obsRefresh()"` inline handler — should be a proper event listener. Also uses emoji 🔄 as the only label with just a `title` attribute — screen readers may not convey the title. (`observers.js` ~L14)
|
||||
|
||||
36. **[Minor]** `.obs-table` has no `aria-label` or `<caption>` element. (`observers.js` ~L82)
|
||||
|
||||
37. **[Minor]** `.spark-bar` progress indicators have no ARIA — they're purely visual. Screen readers get the text "X/hr" from `.spark-label` which is acceptable, but `role="meter"` or similar would be better. (`observers.js` ~L41-44)
|
||||
|
||||
### Mobile Responsive
|
||||
|
||||
38. **[Minor]** `.observers-page` has `max-width: 1200px` and `padding: 20px` — on mobile this is fine. However, the table has 7 columns and no responsive override — it will require horizontal scrolling on phones. No `overflow-x: auto` wrapper. (`style.css` — `.observers-page`, `observers.js` ~L82)
|
||||
|
||||
39. **[Minor]** `.spark-bar` has fixed `width: 100px` — doesn't shrink on small screens, contributing to table overflow. (`style.css` — `.spark-bar`)
|
||||
|
||||
### Desktop Space Efficiency
|
||||
|
||||
40. **[Minor]** `max-width: 1200px` with `margin: 0 auto` is appropriate. No issues on desktop.
|
||||
|
||||
### Bugs / Inconsistencies
|
||||
|
||||
41. **[Minor]** `window._obsRefresh` is set globally and never cleaned up in `destroy()`. (`observers.js` L89)
|
||||
|
||||
42. **[Minor]** Every WebSocket packet triggers `loadObservers()` — if packets arrive rapidly (e.g., 10/sec), this fires 10 API calls per second. Should be debounced. (`observers.js` ~L20-22)
|
||||
|
||||
43. **[Minor]** `healthStatus()` computes time difference using `Date.now()` vs parsed date — doesn't account for timezone differences between server and client. Could show wrong status if clocks are skewed. (`observers.js` ~L32-37)
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting CSS Issues
|
||||
|
||||
44. **[Major]** `@media (prefers-color-scheme: dark)` only applies when no `data-theme` attribute is set on `:root` (via `:root:not([data-theme="light"])`). But the dark mode toggle presumably sets `data-theme="dark"`. The auto-detection path (no attribute) and manual path (attribute set) duplicate all the same variables — if one is updated, the other may be forgotten. (`style.css` L18-31 vs L33-47)
|
||||
|
||||
45. **[Minor]** `.clickable-row:hover` uses `var(--hover-bg, rgba(0,0,0,.04))` — `--hover-bg` is never defined in `:root`. It falls back correctly, but the fallback `rgba(0,0,0,.04)` is nearly invisible on dark backgrounds. (`style.css` — `.clickable-row:hover`)
|
||||
|
||||
46. **[Minor]** `prefers-reduced-motion` media query correctly disables animations — good accessibility practice. (`style.css` ~L527)
|
||||
@@ -0,0 +1,163 @@
|
||||
# UI/UX Review: Home Page, Map Page, Nodes Page
|
||||
|
||||
## Home Page (`home.js`, `home.css`)
|
||||
|
||||
### Accessibility
|
||||
|
||||
1. **Minor — Checklist accordion not keyboard accessible** (`home.js` ~L83-85)
|
||||
- `.checklist-q` elements are `<div>` with click handlers, not `<button>`. No `role="button"`, no `tabindex`, no `aria-expanded`. Keyboard users cannot open/close checklist items.
|
||||
|
||||
2. **Minor — Search suggestions not ARIA-linked** (`home.js` ~L97-130)
|
||||
- `#homeSuggest` dropdown has no `role="listbox"`, suggest items have no `role="option"`. The input has no `aria-owns`, `aria-activedescendant`, or `aria-expanded`. Screen readers won't announce suggestions.
|
||||
|
||||
3. **Minor — Missing ARIA on My Node cards** (`home.js` ~L168-210)
|
||||
- Node cards are clickable `<div>`s without `role="button"` or `tabindex`. Not keyboard-focusable.
|
||||
|
||||
4. **Minor — `.mnc-remove` button lacks visible label** (`home.js` ~L175)
|
||||
- Uses "✕" text only. Has `title` but no `aria-label`. Screen readers will read "times" or nothing useful.
|
||||
|
||||
5. **Minor — Timeline items not keyboard accessible** (`home.js` ~L283)
|
||||
- Clickable `.timeline-item` divs with no `tabindex` or `role`.
|
||||
|
||||
### Mobile Responsive
|
||||
|
||||
6. **Minor — Suggest dropdown touch targets slightly small** (`home.css` ~L68)
|
||||
- `.suggest-item` padding is `10px 14px` — adequate but `.suggest-claim` button at `4px 10px` is below 44px minimum touch target.
|
||||
|
||||
7. **Minor — My Nodes grid `minmax(380px, 1fr)` may overflow on small screens** (`home.css` ~L142)
|
||||
- On screens narrower than 380px (e.g. iPhone SE at 375px), grid items will overflow. The `@media (max-width: 640px)` override to `1fr` fixes this, but there's a gap between 375-640px where 380px min could cause horizontal scroll if only one column fits but the min forces wider than viewport minus padding.
|
||||
|
||||
### Desktop Space Efficiency
|
||||
|
||||
8. **Minor — Content capped at `max-width: 720px`** (`home.css` various)
|
||||
- All content (stats, health, checklist, footer) maxes at 720px. On wide monitors this leaves >50% of screen empty. My Nodes grid is 900px max — slightly better but still narrow for 1440p+ displays.
|
||||
|
||||
9. **Minor — Stats cards don't scale up** (`home.css` ~L53)
|
||||
- `flex: 1 1 120px` is fine but on wide screens the 720px cap means only 4 small cards. Could use the extra space.
|
||||
|
||||
### Bugs / Inconsistencies
|
||||
|
||||
10. **Major — `handleOutsideClick` listener not properly cleaned up** (`home.js` ~L136, ~L141)
|
||||
- `document.addEventListener('click', handleOutsideClick)` is added in `setupSearch()` and removed in `destroy()`. However if `renderHome()` is called multiple times (e.g. toggling experience level), `setupSearch()` is called again without removing the old listener, stacking duplicate listeners.
|
||||
|
||||
11. **Minor — `escapeHtml` used inconsistently in timeline** (`home.js` ~L263)
|
||||
- `obsId` passed through `escapeHtml` but `payloadTypeName()` return values are not — likely safe but inconsistent.
|
||||
|
||||
12. **Minor — Sparkline class name collision** (`home.js` ~L191, `home.css` ~L163 vs `style.css` ~L417)
|
||||
- `.spark-bar` and `.spark-label` are defined in both `home.css` and `style.css` with different meanings (home sparkline vs observers page spark bar). Could cause style conflicts.
|
||||
|
||||
13. **Minor — Error state in `loadHealth` uses undefined CSS variable** (`home.js` ~L293)
|
||||
- `color:var(--status-red)` is defined in `home.css` but if home.css fails to load, this falls back to nothing.
|
||||
|
||||
---
|
||||
|
||||
## Map Page (`map.js`)
|
||||
|
||||
### Accessibility
|
||||
|
||||
14. **Major — Map is entirely inaccessible to keyboard/screen reader users** (`map.js` entire)
|
||||
- The Leaflet map has no text alternative, no summary of nodes, no way to navigate nodes without a mouse. This is inherent to map UIs but there's no fallback table or list view.
|
||||
|
||||
15. **Minor — Checkboxes in map controls lack associated labels for some** (`map.js` ~L29-35)
|
||||
- `<label><input type="checkbox" id="mcClusters"> Show clusters</label>` — the label wraps the input which is fine for association, but there's no explicit `for` attribute. Acceptable but not ideal.
|
||||
|
||||
16. **Minor — Popup HTML is not semantically structured** (`map.js` ~L166-180)
|
||||
- Popup content uses inline styles and `<table>` for layout without proper `<th>` headers or `scope` attributes.
|
||||
|
||||
### Mobile Responsive
|
||||
|
||||
17. **Major — Map controls overlay covers most of the map on mobile** (`style.css` ~L498)
|
||||
- On mobile: `width: calc(100vw - 24px)` and `max-height: 200px` — the controls panel takes nearly full width and 200px height, which on a small phone (667px height minus 52px nav) leaves only ~415px for the map, with the controls overlaying a large portion. There's no way to collapse/dismiss the controls panel.
|
||||
|
||||
18. **Minor — No collapse/toggle for map controls** (`map.js` ~L22-45)
|
||||
- The controls panel is always visible. On mobile this is particularly problematic. A toggle button would help.
|
||||
|
||||
### Desktop Space Efficiency
|
||||
|
||||
19. **Minor — Map controls panel fixed at 220px wide** (`style.css` ~L187)
|
||||
- Adequate but could be collapsible to give more map space when not needed.
|
||||
|
||||
### Bugs / Inconsistencies
|
||||
|
||||
20. **Major — `savedView` referenced but never declared in scope** (`map.js` ~L93)
|
||||
- `if (!savedView) fitBounds();` — `savedView` is declared inside the `init()` function at line ~L54, but `loadNodes()` is called at line ~L82 and uses `savedView` at L93. Since `loadNodes` is `async` and `savedView` is a `const` in the outer `init` scope, this works due to closure. However, when `loadNodes` is called again later (e.g. from WS handler at L80 or filter changes), `savedView` will still hold the original value from init time. This means fitBounds is never called on subsequent data refreshes even if the user hasn't manually positioned the map — minor logic bug.
|
||||
|
||||
21. **Minor — `jumpToRegion` ignores the `iata` parameter** (`map.js` ~L124-128)
|
||||
- The function receives `iata` but then fits bounds to ALL nodes with location, not just nodes in that region. Every jump button does the same thing.
|
||||
|
||||
22. **Minor — WS handler triggers full `loadNodes()` on every ADVERT packet** (`map.js` ~L77-80)
|
||||
- Could cause excessive API calls and re-renders on busy networks. No debouncing.
|
||||
|
||||
23. **Minor — `esc()` function called but never defined in map.js** (`map.js` ~L109, ~L112)
|
||||
- `esc(p.name)` and `esc(p.pubkey)` — this likely relies on a global `esc` from `app.js`. If `app.js` doesn't define it, this will throw. Fragile dependency.
|
||||
|
||||
---
|
||||
|
||||
## Nodes Page (`nodes.js`)
|
||||
|
||||
### Accessibility
|
||||
|
||||
24. **Major — Table rows use `onclick` inline handler via global function** (`nodes.js` ~L164)
|
||||
- `onclick="window._nodeSelect('${n.public_key}')"` — rows are not keyboard-focusable (`tabindex` missing), have no `role="button"`, and rely on a global function. This is both an a11y issue and a code smell.
|
||||
|
||||
25. **Minor — Tab buttons lack ARIA tab pattern** (`nodes.js` ~L145-148)
|
||||
- `.node-tab` buttons don't have `role="tab"`, no `role="tablist"` on container, no `aria-selected`. Screen readers won't understand the tab interface.
|
||||
|
||||
26. **Minor — Sort controls on `<th>` elements lack ARIA sort indicators** (`nodes.js` ~L154-156)
|
||||
- Sortable columns don't have `aria-sort` attribute to indicate current sort direction.
|
||||
|
||||
27. **Minor — Select elements lack labels** (`nodes.js` ~L150-153)
|
||||
- `#nodeLastHeard` and `#nodeSort` selects have no `<label>` or `aria-label`. The first `<option>` acts as a pseudo-label ("Last Heard: Any", "Sort: Last Seen") which is a pattern but not accessible.
|
||||
|
||||
### Mobile Responsive
|
||||
|
||||
28. **Minor — Node table may be hard to read on mobile** (`nodes.js` ~L143)
|
||||
- 6 columns (Name, Key, Role, Regions, Last Seen, Adverts) with `font-size: 12px` on mobile. The "Regions" column always shows "—" (hardcoded) — wasted column space.
|
||||
|
||||
29. **Minor — Full-screen node view back button uses inline onclick** (`nodes.js` ~L58)
|
||||
- `onclick="location.hash='#/nodes'"` — works but not progressive enhancement. Also, `ch-back-btn` class reused from channels page.
|
||||
|
||||
### Desktop Space Efficiency
|
||||
|
||||
30. **Minor — Detail panel fixed at 420px** (`style.css` ~L52)
|
||||
- Panel right is 420px, reasonable. But the node detail includes a map that's only 180px tall — could be taller on desktop.
|
||||
|
||||
31. **Minor — "Regions" column always shows "—"** (`nodes.js` ~L167)
|
||||
- Column exists in the table but is never populated. Dead column wasting horizontal space.
|
||||
|
||||
### Bugs / Inconsistencies
|
||||
|
||||
32. **Major — `escapeHtml` defined locally but not used consistently** (`nodes.js` ~L6, ~L80)
|
||||
- `escapeHtml` is defined at top of IIFE, but in `renderDetail` (L199) `truncate(decoded.text, 50)` output is NOT escaped before insertion into innerHTML. Potential XSS if decoded text contains HTML.
|
||||
|
||||
33. **Minor — Dead code: `debounce` defined at bottom** (`nodes.js` ~L241)
|
||||
- `debounce` is defined at the bottom but also likely exists in `app.js` as a global. Redundant.
|
||||
|
||||
34. **Minor — `loadNodes` called on every WS packet** (`nodes.js` ~L70)
|
||||
- `if (msg.type === 'packet') loadNodes()` — no debouncing, could cause rapid API calls and flickering on busy networks.
|
||||
|
||||
35. **Minor — Leaflet map in detail panel not cleaned up on destroy** (`nodes.js` ~L73-76, ~L213)
|
||||
- When `selectNode` creates a Leaflet map in the detail panel, there's no reference kept to it and no cleanup. On re-selection, a new map is created without removing the old one, potentially leaking resources.
|
||||
|
||||
36. **Minor — `window._nodeSelect` is a global** (`nodes.js` ~L244)
|
||||
- Pollutes global namespace. Should use event delegation on the table body instead.
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Issues
|
||||
|
||||
### Style.css
|
||||
|
||||
37. **Minor — Duplicated dark theme definitions** (`style.css` ~L24-37 and ~L39-52)
|
||||
- `@media (prefers-color-scheme: dark)` and `[data-theme="dark"]` define identical variables. Necessary for the toggle but a maintenance burden — easy for them to drift apart.
|
||||
|
||||
38. **Minor — `.nav-btn` defined twice with identical properties** (`style.css` ~L72-73 and ~L97-101)
|
||||
- Once in "Touch Targets" section and again in "Nav" section with the same min-width/min-height.
|
||||
|
||||
### Index.html
|
||||
|
||||
39. **Minor — `onerror=""` on script tags** (`index.html` ~L36-42)
|
||||
- Empty `onerror` handlers swallow load errors silently. Better to have no handler or log the error.
|
||||
|
||||
40. **Minor — Leaflet loaded from CDN without SRI** (`index.html` ~L27-28)
|
||||
- `unpkg.com` scripts loaded without `integrity` or `crossorigin` attributes. Supply chain risk.
|
||||
@@ -0,0 +1,99 @@
|
||||
# UI/UX Review: Live Page + Packets Page
|
||||
|
||||
## Live Page
|
||||
|
||||
### Accessibility
|
||||
|
||||
| # | Severity | Issue | Location |
|
||||
|---|----------|-------|----------|
|
||||
| L-A1 | **Critical** | VCR buttons use emoji-only labels (`⏪`, `⏸`, `▶`) with no `aria-label`. Screen readers will announce meaningless characters. | `live.js` ~L310-315 (init HTML template) |
|
||||
| L-A2 | **Critical** | Sound toggle button (`🔇`/`🔊`) has a `title` but no `aria-label` and no `aria-pressed` state. | `live.js` ~L324, ~L390 |
|
||||
| L-A3 | **Major** | Heat/Ghost checkbox toggles use bare `<label><input>` with short text but no `id`/`for` association — works due to nesting, but the checkboxes lack `aria-` descriptions of what they control. | `live.js` ~L326-329 |
|
||||
| L-A4 | **Major** | VCR LCD canvas (`#vcrLcdCanvas`) has no `aria-label` or `role="img"` — the 7-segment time display is completely invisible to screen readers. No text alternative exists. | `live.js` ~L349, `live.css` ~L263 |
|
||||
| L-A5 | **Major** | Feed items are `<div>` elements with `cursor: pointer` and click handlers but no `role="button"`, `tabindex`, or keyboard handler. Entirely mouse-only. | `live.js` ~L502-510 |
|
||||
| L-A6 | **Major** | Feed detail card (`.feed-detail-card`) is a popup with no focus trap, no `role="dialog"`, no `aria-label`. Dismiss is mouse-only (click outside). No Escape key handler. | `live.js` ~L527-545 |
|
||||
| L-A7 | **Minor** | Legend panel (`.live-legend`) uses plain `<div>` for colored dots — no semantic list (`<ul>`/`<li>`) and colored dots rely solely on color to convey meaning. | `live.js` ~L332-345 |
|
||||
| L-A8 | **Minor** | Scope buttons (`1h`, `6h`, etc.) have no `aria-pressed` or `role="radiogroup"` semantics. Active state is visual-only via CSS class. | `live.js` ~L339-344 |
|
||||
| L-A9 | **Minor** | The VCR prompt buttons (`▶ Replay`, `⏭ Skip to live`) are created via `innerHTML` — no keyboard focus management after they appear. | `live.js` ~L100-112 |
|
||||
|
||||
### Mobile Responsive
|
||||
|
||||
| # | Severity | Issue | Location |
|
||||
|---|----------|-------|----------|
|
||||
| L-M1 | **Major** | VCR bar on mobile (≤600px) only reduces padding/font slightly. The bar has: 4 buttons + mode indicator + scope buttons + timeline + LCD panel, all in a row. This will overflow or be extremely cramped on phones <375px wide. | `live.css` ~L296-301 |
|
||||
| L-M2 | **Major** | VCR scope buttons (`1h`/`6h`/`12h`/`24h`) are tiny at `0.6rem` / `1px 4px` padding on mobile — well below 44px touch target minimum. | `live.css` ~L299 |
|
||||
| L-M3 | **Major** | VCR control buttons on mobile are `3px 6px` padding at `0.7rem` font — similarly tiny touch targets (~24px). | `live.css` ~L298 |
|
||||
| L-M4 | **Major** | Timeline tooltip (`mousemove` only) doesn't work on touch. Touch scrubbing works but there's no time feedback tooltip during touch drag. | `live.js` ~L405-412 |
|
||||
| L-M5 | **Minor** | Legend is `display: none` on mobile (`live.css` ~L179) which is good, but there's no alternative way to access it (e.g., a toggle button). |
|
||||
| L-M6 | **Minor** | Feed detail card is positioned `right: 14px; top: 50%; transform: translateY(-50%)` absolutely — on narrow phones it may overlap the feed panel or go off-screen. | `live.css` ~L186 |
|
||||
| L-M7 | **Minor** | The `live-header` wraps on mobile but the sound button and toggles may push to a second row without clear separation. | `live.css` ~L175-179 |
|
||||
|
||||
### Desktop Space Efficiency
|
||||
|
||||
| # | Severity | Issue | Location |
|
||||
|---|----------|-------|----------|
|
||||
| L-D1 | **Minor** | Feed panel is fixed at 360px width — on ultrawide monitors this is a small fraction of the screen. Could be wider or resizable. | `live.css` ~L83 |
|
||||
| L-D2 | **Minor** | Feed is capped at 25 items (`live.js` ~L515) and `max-height: 340px` — reasonable but no scroll indicator for users. The `overflow: hidden` means items are silently dropped, not scrollable. | `live.css` ~L84 |
|
||||
| L-D3 | **Minor** | VCR LCD panel has `min-width: 110px` — takes space even when mode text is short. Fine overall. | `live.css` ~L252 |
|
||||
|
||||
### Bugs / Inconsistencies
|
||||
|
||||
| # | Severity | Issue | Location |
|
||||
|---|----------|-------|----------|
|
||||
| L-B1 | **Major** | `overflow: hidden` on `.live-feed` means older feed items are clipped, not scrollable. Users can never scroll to see older items — they're just cut off. Should be `overflow-y: auto`. | `live.css` ~L84 |
|
||||
| L-B2 | **Major** | `drawLcdText` reuses variable name `ch` (function param) shadowed by `ch2` but the outer `ch` in the canvas sizing (`const ch = canvas.offsetHeight`) is shadowed by a loop variable `const ch2 = text[i]` — actually this is fine since renamed to `ch2`. However, the dim color calculation `color.replace(/[\d.]+\)$/, '0.07)')` assumes the color is always in `rgba()` format, but it's called with `'#4ade80'` (hex). The regex won't match, so ghost segments get the raw hex string as color, likely rendering as black or transparent. | `live.js` ~L188-189 |
|
||||
| L-B3 | **Major** | Multiple `setInterval` calls in `init()` (rate counter ~L376, timeline refresh ~L429, clock tick ~L434) are never cleared in `destroy()`. These leak across page navigations. | `live.js` ~L376, L429, L434 vs L593-610 |
|
||||
| L-B4 | **Minor** | `vcrRewind` fetches `limit=200` packets but `vcrReplayFromTs` fetches `limit=10000` — inconsistent fetch sizes for similar operations. The 10K fetch could be very slow on large datasets. | `live.js` ~L126 vs L91 |
|
||||
| L-B5 | **Minor** | `replayRecent` fetches `limit=8` — hardcoded magic number with no configuration. | `live.js` ~L398 |
|
||||
| L-B6 | **Minor** | Dead/unused CSS: `.vcr-clock { display: none; }` and `.vcr-lcd-time { display: none; }` — leftover from refactor. | `live.css` ~L247, L266 |
|
||||
| L-B7 | **Minor** | The nav auto-hide timeout (4s) means the nav disappears while users may still be reading it. No way to pin it open. | `live.js` ~L445-454 |
|
||||
| L-B8 | **Minor** | `VCR.buffer` is capped at 2000 entries by splicing 500 from the front (`live.js` ~L236-237), which means timeline playhead indices could become stale if packets are spliced while in PAUSED or REPLAY mode. | `live.js` ~L236-237 |
|
||||
|
||||
---
|
||||
|
||||
## Packets Page
|
||||
|
||||
### Accessibility
|
||||
|
||||
| # | Severity | Issue | Location |
|
||||
|---|----------|-------|----------|
|
||||
| P-A1 | **Critical** | Table rows use `onclick` inline handlers (`onclick="window._pktSelect(…)"`) with no `tabindex`, `role`, or `onkeydown`. Entire table is keyboard-inaccessible. | `packets.js` ~L209-212, L238-244 |
|
||||
| P-A2 | **Critical** | Global functions exposed on `window` (`_pktSelect`, `_pktToggleGroup`, `_pktRefresh`, `_pktBYOP`) via `onclick` attributes — no keyboard equivalent and pollutes global namespace. | `packets.js` ~L363-380 |
|
||||
| P-A3 | **Major** | Filter `<select>` elements and `<input>` fields have no associated `<label>` elements. Only `placeholder` text which disappears on input. Screen readers get no context. | `packets.js` ~L144-150 |
|
||||
| P-A4 | **Major** | "Group by Hash" toggle button has no `aria-pressed` state to indicate current on/off status. | `packets.js` ~L152 |
|
||||
| P-A5 | **Major** | BYOP modal has no focus trap, no `role="dialog"`, no `aria-label`. Escape key doesn't close it. | `packets.js` ~L303-325 |
|
||||
| P-A6 | **Major** | Node filter dropdown (autocomplete) has no ARIA combobox pattern (`role="listbox"`, `aria-activedescendant`, etc.). Arrow key navigation not supported. | `packets.js` ~L172-192 |
|
||||
| P-A7 | **Minor** | Path hop links have `onclick="event.stopPropagation()"` as an inline HTML attribute string — screen readers see these as links which is correct, but `stopPropagation` prevents row selection which may confuse keyboard users. | `packets.js` ~L42 |
|
||||
| P-A8 | **Minor** | The "Loading…" state in the detail panel is a plain `<div>` with no `aria-live` region. Screen readers won't announce when content loads. | `packets.js` ~L224 |
|
||||
|
||||
### Mobile Responsive
|
||||
|
||||
| # | Severity | Issue | Location |
|
||||
|---|----------|-------|----------|
|
||||
| P-M1 | **Major** | The packets table has 10 columns (expand, region, time, hash, size, type, observer, path, repeat count, details). On mobile, `style.css` sets `max-width: 120px` per cell and allows horizontal scroll on `.panel-left`, but the table will still be very wide. No column hiding strategy for mobile. | `style.css` ~L496-499 |
|
||||
| P-M2 | **Major** | On mobile (≤640px), `.split-layout` stacks vertically with `.panel-right` getting `max-height: 50vh` — but the detail panel has complex content (hex dump, field table, message preview) that may need more space. No way to expand it. | `style.css` ~L489 |
|
||||
| P-M3 | **Minor** | Filter bar goes `flex-direction: column` on mobile, which is good, but the node filter dropdown (`position: absolute`) may not align correctly in the stacked layout. | `style.css` ~L493-495 |
|
||||
| P-M4 | **Minor** | Panel resize handle (drag to resize) is mouse-only — no touch support implemented. The handle is 6px wide, hard to grab on touch. | `packets.js` ~L14-36 |
|
||||
| P-M5 | **Minor** | BYOP modal textarea at `min-height: 60px` is small on mobile for pasting long hex strings. | `style.css` modal styles |
|
||||
|
||||
### Desktop Space Efficiency
|
||||
|
||||
| # | Severity | Issue | Location |
|
||||
|---|----------|-------|----------|
|
||||
| P-D1 | **Minor** | Detail panel defaults to 420px (`style.css` ~L117) which is reasonable. Saved width is restored from localStorage which is nice. |
|
||||
| P-D2 | **Minor** | The table has no column visibility toggle — on wide screens all 10 columns show, but some (like the empty expand column for non-grouped rows, or the "Rpt" column) waste space. | `packets.js` ~L139 |
|
||||
| P-D3 | **Minor** | `max-width: 180px` on `<td>` (`style.css` ~L139) truncates path and detail columns even when there's plenty of room. Column resize helps but the default is tight. |
|
||||
|
||||
### Bugs / Inconsistencies
|
||||
|
||||
| # | Severity | Issue | Location |
|
||||
|---|----------|-------|----------|
|
||||
| P-B1 | **Major** | `renderLeft()` rebuilds entire filter bar HTML on every `loadPackets()` call, destroying and re-creating event listeners. This means: (1) user's cursor position in filter inputs is lost, (2) dropdown state is reset, (3) it's called on every WS `packet` message, causing constant re-renders while typing. | `packets.js` ~L115 (wsHandler calls loadPackets), ~L122 (renderLeft rebuilds everything) |
|
||||
| P-B2 | **Major** | Regions are hardcoded: `window._regions = {"SJC":…,"LAR":…}` — this is a TODO/hack that should come from the server. | `packets.js` ~L354-358 |
|
||||
| P-B3 | **Minor** | `escapeHtml` is defined in both `live.js` (~L548) and `packets.js` (~L267) — duplicated utility. | Both files |
|
||||
| P-B4 | **Minor** | `payloadTypeName`, `payloadTypeColor`, `routeTypeName`, `truncate`, `timeAgo`, `api`, `onWS`, `offWS`, `registerPage`, `makeColumnsResizable` — these are all called but never imported/defined in `packets.js`. They must be globals from `app.js`. No error handling if they're missing. | Throughout `packets.js` |
|
||||
| P-B5 | **Minor** | `directPacketId` is module-scoped but set to `null` in init, then read and cleared — race condition if init is called twice rapidly. | `packets.js` ~L70, L100-115 |
|
||||
| P-B6 | **Minor** | The `destroy()` function clears `packets` and `selectedId` but doesn't clear `expandedHashes`, `hopNameCache`, `totalCount`, or `observers` — stale state persists across page navigations. | `packets.js` ~L119-123 |
|
||||
| P-B7 | **Minor** | No empty state — when no packets match filters, the table body is just empty with no message. | `packets.js` renderTableRows |
|
||||
| P-B8 | **Minor** | No error state — `loadPackets` catches errors with `console.error` only. User sees stale data with no indication of failure. | `packets.js` ~L113 |
|
||||
| P-B9 | **Minor** | The field table section rows use dark mode hardcoded colors: `.section-row td { background: #eef2ff }` — this won't respect dark theme. | `style.css` ~L160 |
|
||||
@@ -0,0 +1,99 @@
|
||||
# v1.1 Fix Plan — Post-Review
|
||||
|
||||
Based on 3 subagent reviews of the full site. ~100 issues found, grouped into actionable milestones.
|
||||
|
||||
---
|
||||
|
||||
## M1: Keyboard & Screen Reader Foundations
|
||||
**Priority: High | Effort: Medium**
|
||||
|
||||
Fix the systemic patterns that block keyboard/assistive tech users across the entire site.
|
||||
|
||||
- [ ] **Replace all `window._xxx` + inline `onclick` with event delegation** — packets, nodes, channels, observers, analytics. Use `data-` attributes + single delegated listener per table/container. Add `tabindex="0"` and `keydown` (Enter/Space) handlers.
|
||||
- [ ] **Add ARIA tab pattern to all tab bars** — analytics tabs, node tabs, observer selector. `role="tablist"`, `role="tab"`, `aria-selected`.
|
||||
- [ ] **Add `aria-label` to all VCR buttons** — ⏪ Rewind, ⏸ Pause, ▶ Play, LIVE, speed button. Add `aria-pressed` to toggles (sound, heat, ghost).
|
||||
- [ ] **Add `role="img" aria-label="..."` to all SVG charts** — bar charts, histograms, scatter, sparklines. Brief text description of what's shown.
|
||||
- [ ] **Add labels to all form controls** — filter selects, search inputs, node filter. Use `aria-label` where visual label would clutter.
|
||||
- [ ] **Focus trap for modals/panels** — BYOP modal, feed detail card, channel node detail panel. Escape to close. Focus first element on open, restore on close.
|
||||
|
||||
## M2: Bugs & Memory Leaks
|
||||
**Priority: High | Effort: Low-Medium**
|
||||
|
||||
Actual broken behavior that affects users now.
|
||||
|
||||
- [ ] **Fix feed `overflow: hidden` → `overflow-y: auto`** — items are silently clipped, not scrollable (live.css ~L84)
|
||||
- [ ] **Clear all `setInterval` in live.js `destroy()`** — rate counter, timeline refresh, clock tick leak across navigations
|
||||
- [ ] **Fix LCD ghost color regex** — fails on hex colors like `#4ade80`; needs hex→rgba conversion or different dim approach
|
||||
- [ ] **Fix home.js stacking event listeners** — `handleOutsideClick` added multiple times on re-render; remove before adding
|
||||
- [ ] **Escape `decoded.text` in nodes detail** — potential XSS via innerHTML (nodes.js ~L199)
|
||||
- [ ] **Fix packets `renderLeft()` rebuilding on every WS message** — separate filter bar render from data render; only rebuild table body on WS updates
|
||||
- [ ] **Debounce WS handlers site-wide** — map, nodes, packets, observers all trigger full reloads on every packet. Add 1-2s debounce.
|
||||
- [ ] **Clean up globals in `destroy()`** — channels, observers, analytics all leak `window._xxx` functions
|
||||
|
||||
## M3: Mobile & Touch
|
||||
**Priority: Medium | Effort: Medium**
|
||||
|
||||
Make the site actually usable on phones.
|
||||
|
||||
- [ ] **VCR bar mobile layout** — stack into 2 rows or make scrollable; increase touch targets to ≥44px
|
||||
- [ ] **Map controls collapsible** — add toggle button, default collapsed on mobile
|
||||
- [ ] **Hash matrix mobile** — smaller cells or horizontal scroll with clear affordance
|
||||
- [ ] **Packets table column hiding on mobile** — hide low-value columns (Region, Rpt count) on <640px
|
||||
- [ ] **Touch timeline tooltip** — show time during touch drag on VCR scrubber
|
||||
- [ ] **Observers table horizontal scroll wrapper** — add `overflow-x: auto` on mobile
|
||||
- [ ] **Chat message max-width** — cap bubbles at ~700px on ultrawide to prevent wall-of-text stretching
|
||||
|
||||
## M4: Color & Visual Accessibility
|
||||
**Priority: Medium | Effort: Low**
|
||||
|
||||
Color-blind users can't distinguish several indicators.
|
||||
|
||||
- [ ] **Hash matrix: add icons/patterns alongside color** — ✓ for available, • for taken, ✕ for collision. Or texture fills.
|
||||
- [ ] **Observer health dots: add text inside or icon** — ● Online vs ▲ Stale vs ✕ Offline, not just color
|
||||
- [ ] **Scatter plot quality zones** — add text labels or pattern fills, not just semi-transparent color
|
||||
|
||||
## M5: Desktop Space & Layout
|
||||
**Priority: Low | Effort: Low**
|
||||
|
||||
Better use of wide screens.
|
||||
|
||||
- [ ] **Home page: widen from 720px to 1200px** — stats, health cards, timeline can spread out
|
||||
- [ ] **Remove dead Regions column** from nodes table — always shows "—"
|
||||
- [ ] **Remove hardcoded regions hack** from packets.js — either fetch from server or remove filter
|
||||
- [ ] **Feed panel resizable** on live page (currently fixed 360px)
|
||||
- [ ] **Observers page: already 1200px** — fine as-is
|
||||
|
||||
## M6: Code Cleanup
|
||||
**Priority: Low | Effort: Low**
|
||||
|
||||
Tech debt that won't affect users but makes future work easier.
|
||||
|
||||
- [ ] **Deduplicate utilities** — `escapeHtml`, `debounce` defined in multiple files; move to `app.js` exports
|
||||
- [ ] **Remove dead code** — `svgLine()` in analytics.js, `display:none` CSS for VCR clock/LCD time, unused Regions logic
|
||||
- [ ] **Remove dead CSS** — `.vcr-clock`, `.vcr-lcd-time`, duplicate `.nav-btn` definitions
|
||||
- [ ] **Add SRI to CDN scripts** — Leaflet loaded from unpkg without integrity hash
|
||||
- [ ] **Add empty/error states** — packets table shows nothing on empty results; add "No packets found" message + error banner on API failure
|
||||
- [ ] **Fix `section-row` dark mode** — hardcoded `#eef2ff` background doesn't respect theme
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. **M2 first** — real bugs, quick wins, immediately noticeable
|
||||
2. **M1 second** — keyboard/ARIA is the biggest systemic gap
|
||||
3. **M3 third** — mobile usability
|
||||
4. **M4 fourth** — visual accessibility polish
|
||||
5. **M5 + M6 together** — layout + cleanup as final pass
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
| Milestone | Issues | Effort |
|
||||
|-----------|--------|--------|
|
||||
| M1: Keyboard/SR | ~15 | 3-4 subagent runs |
|
||||
| M2: Bugs | ~8 | 2-3 subagent runs |
|
||||
| M3: Mobile | ~7 | 2-3 subagent runs |
|
||||
| M4: Color a11y | ~3 | 1 subagent run |
|
||||
| M5: Desktop | ~5 | 1 subagent run |
|
||||
| M6: Cleanup | ~6 | 1 subagent run |
|
||||
|
||||
Total: ~44 actionable items across 6 milestones, ~10-12 subagent runs to implement.
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Milestone 1: Packet Dedup Schema Migration
|
||||
*
|
||||
* Creates `transmissions` and `observations` tables from the existing `packets` table.
|
||||
* Idempotent — drops and recreates new tables on each run.
|
||||
* Does NOT touch the original `packets` table.
|
||||
*
|
||||
* Usage: node scripts/migrate-dedup.js <path-to-meshcore.db>
|
||||
*/
|
||||
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = process.argv[2];
|
||||
if (!dbPath) {
|
||||
console.error('Usage: node scripts/migrate-dedup.js <path-to-meshcore.db>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// --- Drop existing new tables (idempotent) ---
|
||||
console.log('Dropping existing transmissions/observations tables if they exist...');
|
||||
db.exec('DROP TABLE IF EXISTS observations');
|
||||
db.exec('DROP TABLE IF EXISTS transmissions');
|
||||
|
||||
// --- Create new tables ---
|
||||
console.log('Creating transmissions and observations tables...');
|
||||
db.exec(`
|
||||
CREATE TABLE transmissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT NOT NULL,
|
||||
hash TEXT NOT NULL UNIQUE,
|
||||
first_seen TEXT NOT NULL,
|
||||
route_type INTEGER,
|
||||
payload_type INTEGER,
|
||||
payload_version INTEGER,
|
||||
decoded_json TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
|
||||
hash TEXT NOT NULL,
|
||||
observer_id TEXT,
|
||||
observer_name TEXT,
|
||||
direction TEXT,
|
||||
snr REAL,
|
||||
rssi REAL,
|
||||
score INTEGER,
|
||||
path_json TEXT,
|
||||
timestamp TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_transmissions_hash ON transmissions(hash);
|
||||
CREATE INDEX idx_transmissions_first_seen ON transmissions(first_seen);
|
||||
CREATE INDEX idx_transmissions_payload_type ON transmissions(payload_type);
|
||||
CREATE INDEX idx_observations_hash ON observations(hash);
|
||||
CREATE INDEX idx_observations_transmission_id ON observations(transmission_id);
|
||||
CREATE INDEX idx_observations_observer_id ON observations(observer_id);
|
||||
CREATE INDEX idx_observations_timestamp ON observations(timestamp);
|
||||
`);
|
||||
|
||||
// --- Read all packets ordered by timestamp ---
|
||||
console.log('Reading packets...');
|
||||
const packets = db.prepare('SELECT * FROM packets ORDER BY timestamp ASC').all();
|
||||
const totalPackets = packets.length;
|
||||
console.log(`Total packets: ${totalPackets}`);
|
||||
|
||||
// --- Group by hash and migrate ---
|
||||
const insertTransmission = db.prepare(`
|
||||
INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const insertObservation = db.prepare(`
|
||||
INSERT INTO observations (transmission_id, hash, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const hashToTransmissionId = new Map();
|
||||
let transmissionCount = 0;
|
||||
|
||||
const migrate = db.transaction(() => {
|
||||
for (const pkt of packets) {
|
||||
let txId = hashToTransmissionId.get(pkt.hash);
|
||||
if (txId === undefined) {
|
||||
const result = insertTransmission.run(
|
||||
pkt.raw_hex, pkt.hash, pkt.timestamp,
|
||||
pkt.route_type, pkt.payload_type, pkt.payload_version, pkt.decoded_json
|
||||
);
|
||||
txId = result.lastInsertRowid;
|
||||
hashToTransmissionId.set(pkt.hash, txId);
|
||||
transmissionCount++;
|
||||
}
|
||||
insertObservation.run(
|
||||
txId, pkt.hash, pkt.observer_id, pkt.observer_name, pkt.direction,
|
||||
pkt.snr, pkt.rssi, pkt.score, pkt.path_json, pkt.timestamp
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
migrate();
|
||||
|
||||
// --- Verify ---
|
||||
const obsCount = db.prepare('SELECT COUNT(*) as c FROM observations').get().c;
|
||||
const txCount = db.prepare('SELECT COUNT(*) as c FROM transmissions').get().c;
|
||||
const distinctHash = db.prepare('SELECT COUNT(DISTINCT hash) as c FROM packets').get().c;
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(2);
|
||||
|
||||
console.log('\n=== Migration Stats ===');
|
||||
console.log(`Total packets (source): ${totalPackets}`);
|
||||
console.log(`Unique transmissions created: ${transmissionCount}`);
|
||||
console.log(`Observations created: ${obsCount}`);
|
||||
console.log(`Dedup ratio: ${(totalPackets / transmissionCount).toFixed(2)}x`);
|
||||
console.log(`Time taken: ${elapsed}s`);
|
||||
|
||||
console.log('\n=== Verification ===');
|
||||
const obsOk = obsCount === totalPackets;
|
||||
const txOk = txCount === distinctHash;
|
||||
console.log(`observations (${obsCount}) = packets (${totalPackets}): ${obsOk ? 'PASS ✓' : 'FAIL ✗'}`);
|
||||
console.log(`transmissions (${txCount}) = distinct hashes (${distinctHash}): ${txOk ? 'PASS ✓' : 'FAIL ✗'}`);
|
||||
|
||||
if (!obsOk || !txOk) {
|
||||
console.error('\nVerification FAILED!');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\nMigration complete!');
|
||||
db.close();
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
# Pre-push validation — catches common JS errors before they hit prod
|
||||
set -e
|
||||
|
||||
echo "=== Syntax check ==="
|
||||
node -c server.js
|
||||
for f in public/*.js; do node -c "$f"; done
|
||||
echo "✅ All JS files parse OK"
|
||||
|
||||
echo "=== Checking for undefined common references ==="
|
||||
ERRORS=0
|
||||
|
||||
# esc() should only exist inside IIFEs that define it, not in files that don't
|
||||
for f in public/live.js public/map.js public/home.js public/nodes.js public/channels.js public/observers.js; do
|
||||
if grep -q '\besc(' "$f" 2>/dev/null && ! grep -q 'function esc' "$f" 2>/dev/null; then
|
||||
REFS=$(grep -n '\besc(' "$f" | grep -v escapeHtml | grep -v "desc\|Esc\|resc\|safeEsc" || true)
|
||||
if [ -n "$REFS" ]; then
|
||||
echo "❌ $f uses esc() but doesn't define it:"
|
||||
echo "$REFS"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "❌ $ERRORS validation error(s) found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Validation passed"
|
||||