Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23caae40af | ||
|
|
f7e165cb61 | ||
|
|
d9bc9e13c0 | ||
|
|
6fd85db87f | ||
|
|
2e51484fcb | ||
|
|
c89658de90 | ||
|
|
e23603c467 | ||
|
|
c1f7dcfbb0 | ||
|
|
04637c67cc | ||
|
|
92b72393aa | ||
|
|
8bb0bf2921 | ||
|
|
6e28b06b23 | ||
|
|
1249c220bc | ||
|
|
d40b64b51a | ||
|
|
2cc213a653 | ||
|
|
f62839805a | ||
|
|
c4ba90fa8f | ||
|
|
cab926b7fa | ||
|
|
c97ff6da1f | ||
|
|
d108f4dfaa | ||
|
|
94203ff7b0 | ||
|
|
1141fd3f87 | ||
|
|
830c8cc0af | ||
|
|
a37b0574e7 | ||
|
|
84633b4bb2 | ||
|
|
0b97e4cf3f | ||
|
|
aac756c832 | ||
|
|
50623e9798 | ||
|
|
0ddbd46323 | ||
|
|
1f8b7a0f0b | ||
|
|
21158b28fd | ||
|
|
f2e9b6a00f | ||
|
|
b036de23c7 | ||
|
|
c75630de25 | ||
|
|
ea04d64935 | ||
|
|
e2e2097612 | ||
|
|
810377e0c0 | ||
|
|
fbe34ff42e | ||
|
|
7b023ccf0f | ||
|
|
072496fb43 | ||
|
|
6dc8f8661d | ||
|
|
89c2ae6b7b | ||
|
|
80a9a597a6 | ||
|
|
6dcca483d6 | ||
|
|
1e9468aa7d | ||
|
|
20e51a3d8d | ||
|
|
fa5252fb73 | ||
|
|
90db8e686a | ||
|
|
6acdf6214b | ||
|
|
f935fbf6d1 | ||
|
|
a1b9e30021 | ||
|
|
f354048c51 | ||
|
|
ed153f55fc | ||
|
|
823aefc781 | ||
|
|
ce47e9223b | ||
|
|
f1ec67967a | ||
|
|
561bc176f9 | ||
|
|
8faa194275 | ||
|
|
2b08402001 | ||
|
|
e725a53878 | ||
|
|
facc937a23 | ||
|
|
e96ed57f0b | ||
|
|
d55092f5c2 | ||
|
|
9bcb61cad3 | ||
|
|
df4efca2be | ||
|
|
fc32e1389a | ||
|
|
50b1d3236b | ||
|
|
fbce6b029d | ||
|
|
43f1641f75 | ||
|
|
54cf3a585d | ||
|
|
9f7233bc2d | ||
|
|
1fbf77e11e | ||
|
|
e2c83edd2a | ||
|
|
308f91da20 | ||
|
|
2ef18805de | ||
|
|
506148852c | ||
|
|
047d67d229 | ||
|
|
4ed043a0ff | ||
|
|
b98a768da4 | ||
|
|
21cbbaf81f | ||
|
|
0fc29877a2 | ||
|
|
881b9a3548 | ||
|
|
2c92be839a | ||
|
|
cdeb73c3c3 | ||
|
|
03f46956d1 | ||
|
|
b2dad0637f | ||
|
|
6fba53600f | ||
|
|
3f8cf84c2c | ||
|
|
61b46df34d | ||
|
|
1ca92cc156 | ||
|
|
4c7c0665ac | ||
|
|
3bd116b0ce | ||
|
|
aa952da917 | ||
|
|
6b10f675b1 | ||
|
|
09f29b11a5 | ||
|
|
18ccd1139a | ||
|
|
d850a16d65 | ||
|
|
df0acbfe81 |
42
CHANGELOG.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Changelog
|
||||
|
||||
## v2.0.0 (2026-03-20)
|
||||
|
||||
85+ commits — analytics, mobile redesign, accessibility, 100+ bug fixes.
|
||||
|
||||
### ✨ New Features
|
||||
- Per-node analytics page (6 charts, stat cards, peer table, time range selector)
|
||||
- Global analytics — Nodes tab (network status, role breakdown, claimed nodes, leaderboards)
|
||||
- Live map VCR playback — rewind/replay/scrub 24h at up to 4× speed, retro LCD clock
|
||||
- Richer node detail — status badge, avg SNR/hops, observer table, QR codes, recent packets
|
||||
- Claimed (My Mesh) nodes — star your nodes, always sorted to top, auto-sync favorites
|
||||
- Packets "My Nodes" toggle — filter to only your mesh traffic
|
||||
- Bulk health API (`GET /api/nodes/bulk-health`)
|
||||
- Network status API (`GET /api/nodes/network-status`)
|
||||
- Live theme toggle — dark/light tiles swap instantly via MutationObserver
|
||||
|
||||
### 📱 Mobile
|
||||
- Two-row VCR bar layout (controls+LCD / full-width timeline)
|
||||
- iOS safe area support (home indicator clearance)
|
||||
- Feed/legend hidden on mobile — just map + VCR + LCD
|
||||
- JS-driven viewport height for reliable orientation changes
|
||||
- Touch-friendly targets, horizontal scroll on tables
|
||||
|
||||
### ♿ Accessibility
|
||||
- ARIA tab patterns, focus management, keyboard navigation
|
||||
- Distinct SVG marker shapes per node role
|
||||
- Color-blind safe palettes, screen reader support
|
||||
|
||||
### 🐛 Bug Fixes (100+)
|
||||
- Excel-like column resize — steal proportionally from all right columns
|
||||
- Panel drag live reflow
|
||||
- VCR scrub pagination, replay buffer management
|
||||
- Express route ordering (named before parameterized)
|
||||
- XSS escaping, WebSocket cleanup, memory leaks
|
||||
- Dark mode consistency, empty states, SRI hashes
|
||||
- Stray CSS fragment corrupting live.css
|
||||
- Geographic prefix disambiguation restored
|
||||
|
||||
## v1.0.0 (2026-03-19)
|
||||
|
||||
Initial release.
|
||||
251
NODE-ANALYTICS-PLAN.md
Normal file
@@ -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
|
||||
71
README.md
@@ -1,19 +1,57 @@
|
||||
# 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
|
||||
- **Global Search** — search packets, nodes, and channels (Ctrl+K)
|
||||
- **Mobile Responsive** — proper two-row VCR bar, iOS safe area support, touch-friendly
|
||||
- **Accessible** — ARIA patterns, keyboard navigation, screen reader support, distinct marker shapes
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -25,7 +63,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
|
||||
```
|
||||
@@ -79,8 +117,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 +128,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
|
||||
```
|
||||
|
||||
@@ -133,9 +169,12 @@ meshcore-analyzer/
|
||||
│ ├── style.css # Theme (light/dark)
|
||||
│ ├── app.js # Router, WebSocket, utilities
|
||||
│ ├── packets.js # Packet feed + byte breakdown
|
||||
│ ├── map.js # Leaflet map
|
||||
│ ├── 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
|
||||
└── tools/
|
||||
|
||||
156
db.js
@@ -328,6 +328,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 +340,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, insertPath, upsertNode, upsertObserver, getPackets, getPacket, getNodes, getNode, getObservers, getStats, seed, searchNodes, getNodeHealth, getNodeAnalytics };
|
||||
|
||||
BIN
docs/screenshots/Live-view-iOS.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
docs/screenshots/MeshVCR.gif
Normal file
|
After Width: | Height: | Size: 85 MiB |
BIN
docs/screenshots/channels1.png
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
docs/screenshots/mesh-overview.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
docs/screenshots/node-analytics.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
docs/screenshots/packets1.png
Normal file
|
After Width: | Height: | Size: 437 KiB |
BIN
docs/screenshots/route-patterns.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "meshcore-analyzer",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.1",
|
||||
"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": {
|
||||
|
||||
@@ -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;
|
||||
@@ -72,6 +65,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 +74,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 +84,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'),
|
||||
]);
|
||||
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`);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -274,7 +285,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 +306,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 +358,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 +448,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 +493,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 +590,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 +626,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 +701,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 +719,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 +733,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 +746,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'); allNodes = nd.nodes || []; } catch {}
|
||||
renderHashMatrix(data.topHops, allNodes);
|
||||
renderCollisions(data.topHops, allNodes);
|
||||
}
|
||||
|
||||
function renderHashTimeline(hourly) {
|
||||
@@ -733,7 +757,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 +776,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 +790,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 +805,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 +855,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 +890,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">
|
||||
@@ -958,7 +976,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>
|
||||
@@ -1119,7 +1137,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'),
|
||||
api('/nodes/bulk-health?limit=50'),
|
||||
api('/nodes/network-status')
|
||||
]);
|
||||
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.totalPackets || 0) - (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>' : '';
|
||||
}
|
||||
|
||||
const ROLE_COLORS = { repeater: '#dc2626', companion: '#2563eb', room: '#16a34a', sensor: '#d97706' };
|
||||
|
||||
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.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.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 });
|
||||
})();
|
||||
|
||||
128
public/app.js
@@ -148,6 +148,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) return '';
|
||||
return 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 +200,11 @@ 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';
|
||||
}
|
||||
|
||||
// Update nav active state
|
||||
document.querySelectorAll('.nav-link[data-route]').forEach(el => {
|
||||
el.classList.toggle('active', el.dataset.route === basePage);
|
||||
@@ -368,12 +405,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 +483,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 +563,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 +588,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 +618,5 @@ function makeColumnsResizable(tableSelector, storageKey) {
|
||||
});
|
||||
th.appendChild(handle);
|
||||
});
|
||||
} // end addResizeHandles
|
||||
}
|
||||
|
||||
@@ -9,9 +9,14 @@
|
||||
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));
|
||||
// Try exact match first, then case-insensitive, then contains
|
||||
@@ -20,7 +25,7 @@
|
||||
|| 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,6 +39,7 @@
|
||||
const tip = document.createElement('div');
|
||||
tip.id = 'chNodeTooltip';
|
||||
tip.className = 'ch-node-tooltip';
|
||||
tip.setAttribute('role', 'tooltip');
|
||||
const role = node.is_repeater ? '📡 Repeater' : node.is_room ? '🏠 Room' : node.is_sensor ? '🌡 Sensor' : '📻 Companion';
|
||||
const lastSeen = node.last_seen ? timeAgo(node.last_seen) : 'unknown';
|
||||
tip.innerHTML = `<div class="ch-tooltip-name">${escapeHtml(node.name)}</div>
|
||||
@@ -41,17 +47,43 @@
|
||||
<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,11 +99,13 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -84,7 +118,7 @@
|
||||
|
||||
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 +131,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,34 +207,77 @@
|
||||
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) {
|
||||
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();
|
||||
|
||||
// #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) => {
|
||||
const item = e.target.closest('.ch-item[data-hash]');
|
||||
@@ -218,6 +303,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 +346,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() {
|
||||
@@ -311,7 +423,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">
|
||||
@@ -356,15 +468,17 @@
|
||||
try {
|
||||
const data = await api(`/channels/${selectedHash}/messages?limit=200`);
|
||||
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 +512,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 +527,5 @@
|
||||
if (msgEl) { msgEl.scrollTop = msgEl.scrollHeight; autoScroll = true; document.getElementById('chScrollBtn')?.classList.add('hidden'); }
|
||||
}
|
||||
|
||||
window._chSelect = selectChannel;
|
||||
registerPage('channels', { init, destroy });
|
||||
})();
|
||||
|
||||
@@ -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,7 +143,7 @@
|
||||
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));
|
||||
@@ -142,9 +151,9 @@
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -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,9 +361,9 @@
|
||||
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 ====================
|
||||
@@ -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,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>MeshCore Analyzer</title>
|
||||
|
||||
<!-- Open Graph / Discord embed -->
|
||||
@@ -20,12 +20,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=1773969261">
|
||||
<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=1773966856">
|
||||
<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>
|
||||
@@ -71,15 +76,16 @@
|
||||
<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="app.js?v=1774079160"></script>
|
||||
<script src="home.js?v=1774079160"></script>
|
||||
<script src="packets.js?v=1773969349"></script>
|
||||
<script src="map.js?v=1774079160" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774079160" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1773961950" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774079160" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1773961035" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1773964458" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774079160" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1773961276" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
330
public/live.css
@@ -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,7 @@
|
||||
}
|
||||
|
||||
.live-sound-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
background: color-mix(in srgb, var(--text) 14%, transparent);
|
||||
}
|
||||
|
||||
/* ---- Feed ---- */
|
||||
@@ -104,11 +106,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 +119,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 +145,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 +161,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 +188,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 +214,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 +226,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 +234,7 @@
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
color: var(--text-muted);
|
||||
align-items: center;
|
||||
margin-left: 8px;
|
||||
}
|
||||
@@ -231,10 +243,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 +254,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 +301,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 +321,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 +346,7 @@
|
||||
gap: 6px 12px;
|
||||
margin-bottom: 8px;
|
||||
font-size: .7rem;
|
||||
color: #94a3b8;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.fdc-link {
|
||||
@@ -352,24 +394,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 +425,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 +453,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 +499,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 +525,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 +550,6 @@
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.vcr-timeline-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vcr-scope-btns {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
@@ -528,11 +557,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 +574,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 +605,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 +622,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 +654,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 +665,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)); }
|
||||
|
||||
284
public/live.js
@@ -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 = {
|
||||
@@ -61,10 +64,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 +138,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 +164,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 +180,8 @@
|
||||
prompt.classList.add('hidden');
|
||||
vcrResumeLive();
|
||||
});
|
||||
// Focus first button for keyboard users (#59)
|
||||
document.getElementById('vcrPromptReplay').focus();
|
||||
}
|
||||
|
||||
function vcrReplayMissed() {
|
||||
@@ -173,7 +198,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 +223,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 +250,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 +316,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 +330,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 +392,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 +406,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 +430,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 +591,58 @@
|
||||
</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>
|
||||
<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">
|
||||
<li><span class="live-dot" style="background:#3b82f6" aria-hidden="true"></span> Repeater</li>
|
||||
<li><span class="live-dot" style="background:#06b6d4" aria-hidden="true"></span> Companion</li>
|
||||
<li><span class="live-dot" style="background:#a855f7" aria-hidden="true"></span> Room</li>
|
||||
<li><span class="live-dot" style="background:#f59e0b" aria-hidden="true"></span> Sensor</li>
|
||||
</ul>
|
||||
</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 +654,19 @@
|
||||
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);
|
||||
const DARK_TILES = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
||||
const LIGHT_TILES = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
||||
let tileLayer = L.tileLayer(isDark ? DARK_TILES : LIGHT_TILES, { maxZoom: 19 }).addTo(map);
|
||||
|
||||
// Swap tiles when theme changes
|
||||
const _themeObs = new MutationObserver(function () {
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
tileLayer.setUrl(dark ? DARK_TILES : LIGHT_TILES);
|
||||
});
|
||||
_themeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
L.control.zoom({ position: 'topright' }).addTo(map);
|
||||
|
||||
nodesLayer = L.layerGroup().addTo(map);
|
||||
@@ -644,6 +716,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 +738,39 @@
|
||||
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 ? '✕' : '🎨';
|
||||
});
|
||||
}
|
||||
|
||||
// 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 +797,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 +822,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 +894,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
|
||||
if (topNav) {
|
||||
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 +958,7 @@
|
||||
|
||||
let pktTimestamps = [];
|
||||
function startRateCounter() {
|
||||
setInterval(() => {
|
||||
_rateCounterInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
pktTimestamps = pktTimestamps.filter(t => now - t < 60000);
|
||||
const el = document.getElementById('livePktRate');
|
||||
@@ -1240,6 +1390,8 @@
|
||||
|
||||
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>
|
||||
@@ -1297,15 +1449,21 @@
|
||||
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 = ''; }
|
||||
if (_navCleanup) {
|
||||
|
||||
184
public/map.js
@@ -11,22 +11,56 @@
|
||||
let filters = { repeater: true, companion: true, room: true, sensor: true, lastHeard: '30d', mqttOnly: false, 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)
|
||||
// Safe escape — falls back to identity if app.js hasn't loaded yet
|
||||
const safeEsc = (typeof esc === 'function') ? esc : function (s) { return s; };
|
||||
|
||||
// Distinct shapes + high-contrast WCAG AA colors for each role
|
||||
const ROLE_STYLE = {
|
||||
repeater: { color: '#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 },
|
||||
repeater: { color: '#dc2626', shape: 'diamond', radius: 10, weight: 2 }, // red diamond
|
||||
companion: { color: '#2563eb', shape: 'circle', radius: 8, weight: 2 }, // blue circle
|
||||
room: { color: '#16a34a', shape: 'square', radius: 9, weight: 2 }, // green square
|
||||
sensor: { color: '#d97706', shape: 'triangle', radius: 8, weight: 2 }, // amber triangle
|
||||
};
|
||||
|
||||
const ROLE_LABELS = { repeater: 'Repeaters', companion: 'Companions', room: 'Room Servers', sensor: 'Sensors' };
|
||||
const ROLE_COLORS = { repeater: '#1d4ed8', companion: '#0369a1', room: '#6d28d9', sensor: '#92400e' };
|
||||
const ROLE_COLORS = { repeater: '#dc2626', companion: '#2563eb', room: '#16a34a', sensor: '#d97706' };
|
||||
|
||||
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;
|
||||
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 +69,13 @@
|
||||
</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="mcMqtt"><input type="checkbox" id="mcMqtt"> MQTT Connected Only</label>
|
||||
<label for="mcNeighbors"><input type="checkbox" id="mcNeighbors"> Show direct neighbors</label>
|
||||
</fieldset>
|
||||
<fieldset class="mc-section">
|
||||
<legend class="mc-label">Last Heard</legend>
|
||||
@@ -80,6 +114,7 @@
|
||||
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,6 +123,21 @@
|
||||
// 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); });
|
||||
@@ -96,12 +146,11 @@
|
||||
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 +166,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 +226,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>`;
|
||||
@@ -188,7 +255,8 @@
|
||||
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);
|
||||
}
|
||||
@@ -200,8 +268,12 @@
|
||||
el.innerHTML = '';
|
||||
for (const role of ['repeater', 'companion', 'room', 'sensor']) {
|
||||
const count = 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 shapeMap = { repeater: '◆', companion: '●', room: '■', sensor: '▲' };
|
||||
const shape = shapeMap[role] || '●';
|
||||
lbl.innerHTML = `<input type="checkbox" id="${cbId}" data-role="${role}" ${filters[role] ? 'checked' : ''}> <span style="color:${ROLE_COLORS[role]};font-weight:600;" aria-hidden="true">${shape}</span> ${ROLE_LABELS[role]} <span style="color:var(--text-muted)">(${count})</span>`;
|
||||
lbl.querySelector('input').addEventListener('change', e => {
|
||||
filters[e.target.dataset.role] = e.target.checked;
|
||||
renderMarkers();
|
||||
@@ -236,11 +308,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,13 +338,9 @@
|
||||
});
|
||||
|
||||
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'})`,
|
||||
});
|
||||
|
||||
@@ -276,16 +356,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>`;
|
||||
}
|
||||
|
||||
|
||||
308
public/node-analytics.js
Normal file
@@ -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);
|
||||
} 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.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 });
|
||||
})();
|
||||
281
public/nodes.js
@@ -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,6 +22,7 @@
|
||||
let sortBy = 'lastSeen';
|
||||
let lastHeard = '';
|
||||
let wsHandler = null;
|
||||
let detailMap = null;
|
||||
|
||||
const ROLE_COLORS = { repeater: '#3b82f6', room: '#6b7280', companion: '#22c55e', sensor: '#f59e0b' };
|
||||
const TABS = [
|
||||
@@ -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,8 +78,7 @@
|
||||
}, 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) {
|
||||
@@ -89,71 +103,83 @@
|
||||
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 isInfra = role === 'repeater' || role === 'room';
|
||||
const degradedMs = isInfra ? 86400000 : 3600000; // 24h : 1h
|
||||
const silentMs = isInfra ? 259200000 : 86400000; // 72h : 24h
|
||||
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>First Seen</dt><dd>${n.first_seen ? new Date(n.first_seen).toLocaleString() : '—'}</dd>
|
||||
<dt>Total Packets</dt><dd>${stats.totalPackets || n.advert_count || 0}</dd>
|
||||
<dt>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` : '';
|
||||
return `<div class="node-activity-item">
|
||||
<span class="node-activity-time">${timeAgo(p.timestamp)}</span>
|
||||
<span>${typeLabel}${detail}${snr}</span>
|
||||
<span>${typeLabel}${detail}${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 +193,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 +217,7 @@
|
||||
function destroy() {
|
||||
if (wsHandler) offWS(wsHandler);
|
||||
wsHandler = null;
|
||||
if (detailMap) { detailMap.remove(); detailMap = null; }
|
||||
nodes = [];
|
||||
selectedKey = null;
|
||||
}
|
||||
@@ -188,10 +231,29 @@
|
||||
const data = await api('/nodes?' + params);
|
||||
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)))
|
||||
);
|
||||
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 +278,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 +286,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 +295,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 +320,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 +359,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>`;
|
||||
@@ -311,55 +414,82 @@
|
||||
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 isInfra = role === 'repeater' || role === 'room';
|
||||
const degradedMs = isInfra ? 86400000 : 3600000;
|
||||
const silentMs = isInfra ? 259200000 : 86400000;
|
||||
const statusLabel = statusAge < degradedMs ? '🟢 Active' : statusAge < silentMs ? '🟡 Degraded' : '🔴 Silent';
|
||||
const totalPackets = 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}
|
||||
${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 +497,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 +532,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 });
|
||||
})();
|
||||
|
||||
@@ -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() {
|
||||
@@ -39,15 +43,18 @@
|
||||
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,10 @@
|
||||
}
|
||||
|
||||
function sparkBar(count, max) {
|
||||
if (max === 0) return '<div class="spark-bar"><div class="spark-fill" style="width:0"></div></div>';
|
||||
const aria = `role="meter" aria-valuenow="${count}" aria-valuemin="0" aria-valuemax="${max}" aria-label="Packet rate"`;
|
||||
if (max === 0) return `<div class="spark-bar" ${aria}><div class="spark-fill" style="width:0"></div></div>`;
|
||||
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 `<div class="spark-bar" ${aria}><div class="spark-fill" style="width:${pct}%"></div><span class="spark-label">${count}/hr</span></div>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
@@ -85,20 +93,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);
|
||||
const shape = h.cls === 'health-green' ? '●' : h.cls === 'health-yellow' ? '▲' : '✕';
|
||||
return `<tr>
|
||||
<td><span class="health-dot ${h.cls}" title="${h.label}"></span> ${h.label}</td>
|
||||
<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 +117,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 });
|
||||
})();
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
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 +26,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 +108,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 +117,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 +133,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 +142,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 +181,11 @@
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
wsHandler = (msg) => {
|
||||
if (msg.type === 'packet') {
|
||||
loadPackets(); // refresh on new packet
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
if (msgs.some(function (m) { return m.type === 'packet'; })) {
|
||||
loadPackets();
|
||||
}
|
||||
};
|
||||
onWS(wsHandler);
|
||||
});
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
@@ -147,7 +193,16 @@
|
||||
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() {
|
||||
@@ -198,6 +253,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 +262,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,7 +305,7 @@
|
||||
|
||||
// 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>`;
|
||||
}
|
||||
|
||||
@@ -249,6 +319,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 +333,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,23 +403,97 @@
|
||||
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');
|
||||
@@ -298,9 +503,40 @@
|
||||
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
|
||||
let displayPackets = packets;
|
||||
if (filters.myNodes) {
|
||||
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
|
||||
const myKeys = new Set(myNodes.map(n => n.pubkey));
|
||||
const favs = getFavorites();
|
||||
const allKeys = new Set([...myKeys, ...favs]);
|
||||
displayPackets = packets.filter(p => {
|
||||
try {
|
||||
const d = JSON.parse(p.decoded_json || '{}');
|
||||
const pathHops = JSON.parse(p.path_json || '[]');
|
||||
// Check if any node key in decoded data or path matches
|
||||
return (d.pubkey && allKeys.has(d.pubkey)) ||
|
||||
(d.to && allKeys.has(d.to)) ||
|
||||
(d.from && allKeys.has(d.from)) ||
|
||||
pathHops.some(h => allKeys.has(h));
|
||||
} catch { return false; }
|
||||
});
|
||||
}
|
||||
|
||||
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 +546,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(p.observer_name || p.observer_id || '—', 16) : truncate(p.observer_name || p.observer_id || '—', 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
|
||||
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
|
||||
<td class="col-rpt">${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 +568,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(c.observer_name || 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 +586,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 +598,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(p.observer_name || 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 +633,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 '';
|
||||
@@ -409,10 +642,33 @@
|
||||
async function selectPacket(id) {
|
||||
selectedId = 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 +679,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>`;
|
||||
}
|
||||
@@ -523,10 +779,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 +817,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 +843,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 +878,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 +890,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,23 +999,16 @@
|
||||
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
|
||||
(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 = {"SJC":"San Jose, US","SFO":"San Francisco, US","OAK":"Oakland, US","MRY":"Monterey, US","LAR":"Los Angeles, US"};
|
||||
} catch {}
|
||||
})();
|
||||
|
||||
// Global handlers
|
||||
window._pktSelect = selectPacket;
|
||||
window._pktToggleGroup = async (hash) => {
|
||||
async function pktToggleGroup(hash) {
|
||||
if (expandedHashes.has(hash)) {
|
||||
expandedHashes.delete(hash);
|
||||
renderTableRows();
|
||||
@@ -763,16 +1029,14 @@
|
||||
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 });
|
||||
})();
|
||||
|
||||
269
public/style.css
@@ -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,11 @@ 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 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); }
|
||||
@@ -314,7 +323,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 +437,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 +500,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; }
|
||||
@@ -672,7 +689,8 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
.health-dot.health-yellow { background: #eab308; box-shadow: 0 0 6px #eab30880; }
|
||||
.health-dot.health-red { background: #ef4444; box-shadow: 0 0 6px #ef444480; }
|
||||
.obs-table td:first-child { white-space: nowrap; }
|
||||
.spark-bar { position: relative; width: 100px; height: 18px; background: var(--border); border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; }
|
||||
.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 +811,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 +827,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 +862,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 +933,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 +961,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 +1000,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 +1067,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 +1095,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 +1146,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 {
|
||||
@@ -1161,7 +1198,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 +1223,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 +1241,175 @@ 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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
135
reviews/review-analytics-channels.md
Normal file
@@ -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)
|
||||
163
reviews/review-home-map-nodes.md
Normal file
@@ -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.
|
||||
99
reviews/review-live-packets.md
Normal file
@@ -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 |
|
||||
99
reviews/v1.1-fix-plan.md
Normal file
@@ -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.
|
||||
73
server.js
@@ -384,7 +384,8 @@ app.get('/api/packets', (req, res) => {
|
||||
if (until) { where.push('timestamp < @until'); params.until = until; }
|
||||
if (node) { where.push("(decoded_json LIKE @nodePattern OR decoded_json LIKE @nodeNamePattern)"); params.nodePattern = `%${node}%`; const nn = db.db.prepare('SELECT name FROM nodes WHERE public_key = ?').get(node); params.nodeNamePattern = nn ? `%${nn.name}%` : `%${node}%`; }
|
||||
const clause = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||
const packets = db.db.prepare(`SELECT * FROM packets ${clause} ORDER BY timestamp DESC LIMIT @limit OFFSET @offset`).all({ ...params, limit: Number(limit), offset: Number(offset) });
|
||||
const orderDir = req.query.order === 'asc' ? 'ASC' : 'DESC';
|
||||
const packets = db.db.prepare(`SELECT * FROM packets ${clause} ORDER BY timestamp ${orderDir} LIMIT @limit OFFSET @offset`).all({ ...params, limit: Number(limit), offset: Number(offset) });
|
||||
const total = db.db.prepare(`SELECT COUNT(*) as count FROM packets ${clause}`).get(params).count;
|
||||
res.json({ packets, total });
|
||||
});
|
||||
@@ -577,6 +578,67 @@ app.get('/api/nodes/search', (req, res) => {
|
||||
res.json({ nodes });
|
||||
});
|
||||
|
||||
// Bulk health summary for analytics — single query approach (MUST be before :pubkey routes)
|
||||
app.get('/api/nodes/bulk-health', (req, res) => {
|
||||
const limit = Math.min(Number(req.query.limit) || 50, 200);
|
||||
const nodes = db.db.prepare(`SELECT * FROM nodes ORDER BY last_seen DESC LIMIT ?`).all(limit);
|
||||
const todayStart = new Date();
|
||||
todayStart.setUTCHours(0, 0, 0, 0);
|
||||
const todayISO = todayStart.toISOString();
|
||||
|
||||
const results = nodes.map(node => {
|
||||
const pk = node.public_key;
|
||||
const keyPattern = `%${pk}%`;
|
||||
const namePattern = node.name ? `%${node.name.replace(/[%_]/g, '')}%` : null;
|
||||
const where = namePattern
|
||||
? `(decoded_json LIKE @k OR decoded_json LIKE @n)`
|
||||
: `decoded_json LIKE @k`;
|
||||
const p = namePattern ? { k: keyPattern, n: namePattern } : { k: keyPattern };
|
||||
|
||||
const observerRows = db.db.prepare(`
|
||||
SELECT observer_id, observer_name, AVG(snr) as avgSnr, AVG(rssi) as avgRssi, COUNT(*) as packetCount
|
||||
FROM packets WHERE ${where} AND observer_id IS NOT NULL GROUP BY observer_id ORDER BY packetCount DESC
|
||||
`).all(p);
|
||||
|
||||
const totalPackets = db.db.prepare(`SELECT COUNT(*) as c FROM packets WHERE ${where}`).get(p).c;
|
||||
const packetsToday = db.db.prepare(`SELECT COUNT(*) as c FROM packets WHERE ${where} AND timestamp > @s`).get({ ...p, s: todayISO }).c;
|
||||
const avgSnr = db.db.prepare(`SELECT AVG(snr) as v FROM packets WHERE ${where}`).get(p).v;
|
||||
const lastHeard = db.db.prepare(`SELECT MAX(timestamp) as v FROM packets WHERE ${where}`).get(p).v;
|
||||
|
||||
return {
|
||||
public_key: pk,
|
||||
name: node.name,
|
||||
role: node.role,
|
||||
lat: node.lat,
|
||||
lon: node.lon,
|
||||
stats: { totalPackets, packetsToday, avgSnr, lastHeard },
|
||||
observers: observerRows
|
||||
};
|
||||
});
|
||||
|
||||
res.json(results);
|
||||
});
|
||||
|
||||
app.get('/api/nodes/network-status', (req, res) => {
|
||||
const now = Date.now();
|
||||
const allNodes = db.db.prepare('SELECT public_key, name, role, last_seen FROM nodes').all();
|
||||
let active = 0, degraded = 0, silent = 0;
|
||||
const roleCounts = {};
|
||||
allNodes.forEach(n => {
|
||||
const r = n.role || 'unknown';
|
||||
roleCounts[r] = (roleCounts[r] || 0) + 1;
|
||||
const ls = n.last_seen ? new Date(n.last_seen).getTime() : 0;
|
||||
const age = now - ls;
|
||||
const isInfra = r === 'repeater' || r === 'room';
|
||||
const degradedMs = isInfra ? 86400000 : 3600000;
|
||||
const silentMs = isInfra ? 259200000 : 86400000;
|
||||
if (age < degradedMs) active++;
|
||||
else if (age < silentMs) degraded++;
|
||||
else silent++;
|
||||
});
|
||||
res.json({ total: allNodes.length, active, degraded, silent, roleCounts });
|
||||
});
|
||||
|
||||
app.get('/api/nodes/:pubkey', (req, res) => {
|
||||
const node = db.getNode(req.params.pubkey);
|
||||
if (!node) return res.status(404).json({ error: 'Not found' });
|
||||
@@ -1250,7 +1312,7 @@ app.get('/api/observers', (req, res) => {
|
||||
const lastHour = db.db.prepare(`SELECT COUNT(*) as count FROM packets WHERE observer_id = ? AND timestamp > ?`).get(o.id, oneHourAgo);
|
||||
return { ...o, packetsLastHour: lastHour.count };
|
||||
});
|
||||
res.json({ observers: result });
|
||||
res.json({ observers: result, server_time: new Date().toISOString() });
|
||||
});
|
||||
|
||||
app.get('/api/traces/:hash', (req, res) => {
|
||||
@@ -1265,6 +1327,13 @@ app.get('/api/nodes/:pubkey/health', (req, res) => {
|
||||
res.json(health);
|
||||
});
|
||||
|
||||
app.get('/api/nodes/:pubkey/analytics', (req, res) => {
|
||||
const days = Math.min(Math.max(Number(req.query.days) || 7, 1), 365);
|
||||
const data = db.getNodeAnalytics(req.params.pubkey, days);
|
||||
if (!data) return res.status(404).json({ error: 'Not found' });
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
// Subpath frequency analysis
|
||||
app.get('/api/analytics/subpaths', (req, res) => {
|
||||
const minLen = Math.max(2, Number(req.query.minLen) || 2);
|
||||
|
||||