Compare commits

...

98 Commits

Author SHA1 Message Date
you
23caae40af release: v2.0.1 — mobile packets UX 2026-03-20 01:19:19 +00:00
you
f7e165cb61 merge: mobile packets improvements (v2.0.1) 2026-03-20 01:19:19 +00:00
you
d9bc9e13c0 mobile: hide hash column by default 2026-03-20 01:15:49 +00:00
you
6fd85db87f mobile packets: bottom sheet detail, collapsed filters, smaller table, fewer columns
- Mobile (≤640px) default columns: time, hash, type, details (hide region, observer, path, rpt, size)
- Detail panel hidden on mobile; tapping row opens slide-up bottom sheet (70vh max, close button, drag handle)
- Filter bar collapses to single 'Filters' toggle button on mobile
- Table font 11px, tighter padding, no min-width forcing horizontal scroll
- Panel-right completely hidden on mobile (no split layout)
2026-03-20 01:14:32 +00:00
you
2e51484fcb readme: GIF under Live Trace Map, iOS screenshot under Mobile Ready section 2026-03-20 01:09:48 +00:00
you
c89658de90 readme: lead with GIF, resize iOS screenshot to 400px 2026-03-20 01:05:17 +00:00
you
e23603c467 add screenshots for README 2026-03-20 01:02:38 +00:00
you
c1f7dcfbb0 add CHANGELOG.md 2026-03-20 01:01:40 +00:00
you
04637c67cc release: v2.0.0 — analytics, live VCR, mobile, accessibility, 100+ fixes 2026-03-20 01:00:16 +00:00
you
92b72393aa fix: mobile VCR bar bottom padding with safe-area + 20px fallback 2026-03-20 00:34:16 +00:00
you
8bb0bf2921 fix: bump feed/legend safe-area offsets to account for two-row VCR on mobile 2026-03-20 00:33:24 +00:00
you
6e28b06b23 fix: VCR bar + feed/legend offset by safe-area-inset-bottom with 34px fallback for iOS 2026-03-20 00:32:57 +00:00
you
1249c220bc fix: VCR bar respects iOS safe area inset (home indicator) 2026-03-20 00:22:00 +00:00
you
d40b64b51a Revert "release: v2.0.0 — analytics, mobile redesign, accessibility, 100+ fixes"
This reverts commit 2cc213a653.
2026-03-20 00:05:51 +00:00
you
2cc213a653 release: v2.0.0 — analytics, mobile redesign, accessibility, 100+ fixes 2026-03-20 00:04:30 +00:00
you
f62839805a fix: rotation — JS-driven height (window.innerHeight) on live-page+app, runs immediately + on every resize/orientation/visualViewport change 2026-03-19 23:54:18 +00:00
you
c4ba90fa8f mobile VCR: proper two-row layout — controls+scope+LCD row, full-width timeline row, no horizontal scroll 2026-03-19 23:49:06 +00:00
you
cab926b7fa mobile: hide feed+legend, show LCD, fix rotation with visualViewport + forced height recalc 2026-03-19 23:47:43 +00:00
you
c97ff6da1f fix: comprehensive mobile responsive fixes for all pages
Live page (priority):
- Fix feed panel max-height from 180px to 40vh (was clipping content)
- Fix VCR bar: bump breakpoint from 600px to 640px, hide LCD, ensure
  no wrapping, increase touch targets to 44px min
- Remove rogue ≤600px rules that hid feed/legend/toggle entirely
- Fix legend toggle: was using inverted logic (legend-mobile-hidden
  toggled off instead of legend-mobile-visible toggled on)
- Header: constrain width to viewport, reduce padding/font on mobile
- Feed detail card: add max-height 50vh + overflow-y auto to prevent
  clipping off screen
- Disable feed resize handle on mobile (not practical for touch)
- Ensure Leaflet zoom controls clear mobile header

Packets page:
- panel-left gets min-height 50vh + overflow-x with -webkit touch scrolling
- data-table gets min-width 500px so it scrolls horizontally instead of
  collapsing columns to nothing
- panel-right removes max-height 50vh cap (was hiding detail panel)
- Filter bar buttons get 44px min touch target
- Node filter wrap goes full width

Nodes page:
- Node count pills wrap properly
- Smaller font on count pills for narrow screens

Analytics pages:
- analytics-grid goes single column on mobile
- analytics-page reduces padding

Style fixes (global):
- Filter bar gap reduced to 4px on mobile
- All cache busters updated
2026-03-19 23:44:44 +00:00
you
d108f4dfaa mobile live: hide feed+legend, keep LCD visible 2026-03-19 23:41:42 +00:00
you
94203ff7b0 fix: remove stray CSS fragment corrupting live.css 2026-03-19 23:40:46 +00:00
you
1141fd3f87 fix: live map tiles swap instantly on theme toggle via MutationObserver 2026-03-19 23:40:09 +00:00
you
830c8cc0af fix: legend bottom 12px→58px to clear VCR bar 2026-03-19 23:38:33 +00:00
you
a37b0574e7 fix: mobile — dvh for proper viewport height, VCR bar no-wrap, LCD hidden on small screens, orientation triple-invalidate, feed-detail-card bottom aligned 2026-03-19 23:35:09 +00:00
you
84633b4bb2 fix: VCR bar properly sized — bigger buttons, taller timeline (28px), comfortable padding 2026-03-19 23:32:52 +00:00
you
0b97e4cf3f fix: VCR bar much thinner — 3px padding, 12px timeline, feed bottom 40px; removed dead CSS 2026-03-19 23:26:52 +00:00
you
aac756c832 fix: VCR bar — flatten to single row, thinner timeline (16px), remove stacked layout that caused weird gray band 2026-03-19 23:19:19 +00:00
you
50623e9798 UX: move analytics/copy URL buttons to top of node detail; live feed close/resize more visible; VCR bar labeled 2026-03-19 23:12:30 +00:00
you
0ddbd46323 feat: packets view — ★ My Nodes toggle filters to only claimed/favorited node packets 2026-03-19 23:09:44 +00:00
you
1f8b7a0f0b fix: claimed nodes always fetched even if not in current page; auto-sync claimed→favorites 2026-03-19 23:07:49 +00:00
you
21158b28fd fix: favorites dropdown transparent bg (var(--surface) didn't exist); live map uses light/dark tiles based on theme 2026-03-19 23:05:28 +00:00
you
f2e9b6a00f feat: claimed nodes get visual distinction — blue tint, left accent border, ★ badge 2026-03-19 23:04:41 +00:00
you
b036de23c7 fix: node analytics — compact layout, add descriptions to every stat card and chart 2026-03-19 23:01:16 +00:00
you
c75630de25 fix: network status computed server-side across ALL nodes, not just top 50 2026-03-19 22:57:19 +00:00
you
ea04d64935 fix: move bulk-health route before ALL :pubkey wildcards (not just /health) 2026-03-19 22:54:26 +00:00
you
e2e2097612 fix: live page respects dark/light theme — replace hardcoded colors with CSS variables 2026-03-19 22:52:18 +00:00
you
810377e0c0 fix: move bulk-health route before :pubkey wildcard — Express route ordering 2026-03-19 22:49:24 +00:00
you
fbe34ff42e perf: bulk health endpoint — single API call replaces 50 individual health requests for Nodes tab 2026-03-19 22:46:24 +00:00
you
7b023ccf0f fix: hash matrix — free prefixes show actual hex code (e.g. 'A7') instead of dots 2026-03-19 22:45:14 +00:00
you
072496fb43 fix: nodes tab — unwrap {nodes} from API response 2026-03-19 22:44:00 +00:00
you
6dc8f8661d fix: hash matrix color scheme — empty=subtle, 1=light green, 2+=orange→red progression 2026-03-19 22:43:21 +00:00
you
89c2ae6b7b fix: hash matrix — bigger font, remove ⚠ clutter, use · for empty cells; collision risk sorted closest-first 2026-03-19 22:41:01 +00:00
you
80a9a597a6 feat: add Nodes tab to global analytics — status overview, role breakdown, claimed nodes, leaderboards (activity, signal, observers, recent) 2026-03-19 22:40:03 +00:00
you
6dcca483d6 fix: claimed (My Mesh) nodes sort to top, then favorites, then rest 2026-03-19 22:36:33 +00:00
you
1e9468aa7d fix: analytics page scroll — add overflow-y:auto to wrapper 2026-03-19 22:35:50 +00:00
you
20e51a3d8d fix: favorited (claimed) nodes always sort to top of nodes list 2026-03-19 22:34:50 +00:00
you
fa5252fb73 Add per-node analytics page with charts, stats, and heatmap
- New route: #/nodes/:pubkey/analytics with Chart.js v4 visualizations
- Activity timeline (bar), SNR trend (line), packet type breakdown (doughnut)
- Observer coverage (horizontal bar), hop distribution (bar)
- Uptime heatmap (7x24 CSS grid, GitHub-style)
- Peer interactions table with links to node details
- Stat cards: availability, signal grade, packets/day, relay %, silence
- Time range selector: 24h / 7d / 30d / All
- Server: GET /api/nodes/:pubkey/analytics with full aggregation in SQLite
- Analytics button added to both sidebar and full-screen node views
2026-03-19 22:31:09 +00:00
you
90db8e686a fix: merge Recent Adverts + Recent Activity into single Recent Packets section 2026-03-19 22:21:54 +00:00
you
6acdf6214b feat: richer node detail — status badge, avg SNR/hops, observer breakdown table, totalPackets 2026-03-19 22:17:00 +00:00
you
f935fbf6d1 fix: add QR code to full-screen node detail view, bump cache buster 2026-03-19 22:11:04 +00:00
you
a1b9e30021 fix: restore proper advert-entry markup, bump cache buster 2026-03-19 22:08:43 +00:00
you
f354048c51 fix: add cache busters to all JS and CSS files 2026-03-19 22:07:33 +00:00
you
ed153f55fc debug: Recent Adverts sidebar — plain HTML, no CSS classes, hardcoded colors, to find rendering issue 2026-03-19 22:07:03 +00:00
you
823aefc781 fix: Recent Adverts shows packet type + observer + explicit text color, handles missing timestamp 2026-03-19 21:57:25 +00:00
you
ce47e9223b fix: role-aware status thresholds — repeaters/rooms 24h/72h, companions/sensors 1h/24h 2026-03-19 21:54:02 +00:00
you
f1ec67967a fix: always show QR code in node detail, add Recent Adverts section to sidebar detail 2026-03-19 21:53:22 +00:00
you
561bc176f9 fix: map markers use distinct shapes (diamond/circle/square/triangle) + high-contrast colors for accessibility 2026-03-19 21:49:40 +00:00
you
8faa194275 fix: remove dead Regions column from nodes table (closes #26) 2026-03-19 21:48:32 +00:00
you
2b08402001 fix: column resize steals from ALL right columns proportionally, wider grab handle, 50px min 2026-03-19 21:46:02 +00:00
you
e725a53878 fix: restore geographic prefix disambiguation for route overlay 2026-03-19 21:41:58 +00:00
you
facc937a23 fix: don't fitBounds on initial load — respect Bay Area default center 2026-03-19 21:39:38 +00:00
you
e96ed57f0b fix: VCR replay paginates — fetches next 10k when buffer exhausted instead of jumping to live 2026-03-19 21:33:55 +00:00
you
d55092f5c2 Replace hardcoded section-row background with CSS variable for dark mode
closes #32
2026-03-19 21:32:58 +00:00
you
9bcb61cad3 Add empty/error states to data tables with aria-live for accessibility
closes #31
2026-03-19 21:32:54 +00:00
you
df4efca2be Add SRI integrity hashes to Leaflet CDN scripts
closes #30
2026-03-19 21:32:48 +00:00
you
fc32e1389a Remove dead code: svgLine(), .vcr-clock, .vcr-lcd-time display:none rules
closes #29
2026-03-19 21:32:43 +00:00
you
50b1d3236b Remove duplicate escapeHtml and debounce functions, keep globals in app.js
closes #28
2026-03-19 21:32:39 +00:00
you
fbce6b029d fix: VCR scrub fetches ASC from scrub point — prevents jumping forward when >10k packets exist 2026-03-19 21:32:26 +00:00
you
43f1641f75 fix: restore vcrReplayFromTs fetch limit to 10000 — 2000 caused rubber banding on scrub 2026-03-19 21:30:01 +00:00
you
54cf3a585d fix: home page accessibility and UI issues
closes #25 — Widen home page content from 720px to 1200px
closes #35 — Checklist accordion keyboard accessible (role=button, tabindex, aria-expanded, Enter/Space)
closes #36 — Search suggestions ARIA combobox pattern (role=combobox/listbox/option, aria-expanded, aria-activedescendant)
closes #37 — My Node cards keyboard-focusable (tabindex=0, role=button, keydown handler)
closes #38 — Remove button on node cards gets aria-label
closes #39 — Timeline items keyboard accessible (tabindex=0, role=button, keydown handler)
closes #40 — Suggest-claim button meets 44px min touch target
closes #41 — My Nodes grid min lowered to 300px to prevent overflow on 375-640px
closes #42 — Stats cards use CSS grid with minmax(140px, 200px) for wide screens
closes #43 — escapeHtml applied consistently to payloadTypeName in timeline
closes #44 — Sparkline classes namespaced to home-spark-* to avoid collision
closes #45 — Error state uses fallback color var(--status-red, #ef4444)
2026-03-19 21:12:03 +00:00
you
9f7233bc2d fix: VCR timeline tooltip on touch devices
Add touchmove/touchend handlers to show the time tooltip during touch
scrubbing on the timeline, mirroring the existing mousemove behavior.

closes #19
closes #27
closes #54
closes #57
closes #58
closes #59
closes #60
closes #61
closes #62
closes #63
closes #64

Additional fixes in this commit:
- #27: Add drag resize handle on feed panel right edge, persist width to localStorage
- #54: Add aria-describedby to heat/ghost toggles with sr-only descriptions
- #57: Refactor legend to use semantic ul/li with descriptive text, h3 headings
- #58: Wrap scope buttons in role=radiogroup, add role=radio and aria-checked
- #59: Add role=alertdialog to VCR prompt, auto-focus first button on show
- #60: Add legend toggle button visible on mobile to show/hide legend overlay
- #61: Position feed detail card as full-width bottom sheet on mobile
- #62: Add pin button to nav bar to prevent auto-hide
- #63: Adjust VCR.playhead when buffer is spliced to prevent stale indices
- #64: Standardize fetch limits to 2000 for both vcrRewind and vcrReplayFromTs
2026-03-19 21:11:59 +00:00
you
1fbf77e11e Fix 10 UI bugs in map and nodes pages
- Map controls panel collapsible with toggle button, default collapsed on mobile (closes #16)
- jumpToRegion filters nodes by region observers instead of fitting all (closes #33)
- Detail panel Leaflet maps tracked and removed on re-selection (closes #34)
- Popup HTML uses semantic dl/dt/dd instead of table (closes #46)
- fitBounds now tracks user interaction instead of savedView const (closes #47)
- esc() replaced with safeEsc fallback for implicit global safety (closes #48)
- Checkboxes in map controls have explicit for/id association (closes #49)
- Sort columns have aria-sort attributes (closes #50)
- Filter selects have aria-label attributes (closes #51)
- Back button uses addEventListener instead of inline onclick (closes #52)
- Detail panel map height increased to 280px on desktop (closes #53)
2026-03-19 21:11:46 +00:00
you
e2c83edd2a fix: chat message bubble max-width constraint
closes #21
2026-03-19 21:11:28 +00:00
you
308f91da20 fix: packets accessibility — remove stopPropagation, add aria-live, add touch resize
closes #67
closes #68
closes #69
2026-03-19 21:11:15 +00:00
you
2ef18805de fix: observers table horizontal scroll wrapper on mobile
closes #20
2026-03-19 21:11:04 +00:00
you
506148852c fix: hash matrix mobile overflow and scatter plot color-blind accessibility
closes #17
closes #24
2026-03-19 21:11:04 +00:00
you
047d67d229 fix: Excel-like column resize — drag steals from neighbor, percentages persist, panel drag reflows proportionally 2026-03-19 21:04:15 +00:00
you
4ed043a0ff fix: explicitly set left panel + table width during drag for live column reflow 2026-03-19 21:01:01 +00:00
you
b98a768da4 fix: force table reflow during detail pane drag resize 2026-03-19 20:59:50 +00:00
you
21cbbaf81f fix: use max-width:0 on td so table compresses/expands with detail pane resize 2026-03-19 20:58:14 +00:00
you
0fc29877a2 Revert "fix: packets table uses table-layout:fixed with proportional column widths — resizes like Excel when detail pane is dragged"
This reverts commit 881b9a3548.
2026-03-19 20:57:08 +00:00
you
881b9a3548 fix: packets table uses table-layout:fixed with proportional column widths — resizes like Excel when detail pane is dragged 2026-03-19 20:55:53 +00:00
you
2c92be839a fix: restore max-width on td, give Details column more room (fixes #72 regression) 2026-03-19 20:53:40 +00:00
you
cdeb73c3c3 fix: packets — BYOP mobile, column toggle, max-width, race condition, destroy cleanup (closes #70, #71, #72, #73, #74) 2026-03-19 19:39:29 +00:00
you
03f46956d1 fix: channels — aria-live, listbox, tooltip, mobile, resize, theme, cache, refresh (closes #84, #85, #86, #87, #88, #89, #90, #91, #92) 2026-03-19 19:39:08 +00:00
you
b2dad0637f fix: analytics — async race, guards, legend CSS, dedup API, responsive layout (closes #75, #76, #77, #78, #79, #80, #81) 2026-03-19 19:38:58 +00:00
you
6fba53600f fix: style.css/index.html — dark theme sync, dedup nav-btn, onerror, hover color (closes #98, #99, #100, #101) 2026-03-19 19:38:09 +00:00
you
3f8cf84c2c fix: observers — refresh a11y, table caption, spark ARIA, mobile, timezone (closes #93, #94, #95, #96, #97) 2026-03-19 19:37:00 +00:00
you
61b46df34d fix: ARIA tab pattern, form labels, focus management (closes #10, #13, #14) 2026-03-19 19:00:43 +00:00
you
1ca92cc156 fix: SVG alt text, hash matrix color-blind, observer health shapes (closes #12, #22, #23) 2026-03-19 18:58:57 +00:00
you
4c7c0665ac fix: packets mobile columns, BYOP dialog a11y, filter combobox ARIA (closes #18, #65, #66) 2026-03-19 18:58:32 +00:00
you
3bd116b0ce fix: channels sender keyboard access, node panel focus trap (closes #82, #83) 2026-03-19 18:57:55 +00:00
you
aa952da917 fix: live page mobile VCR, LCD aria, feed keyboard (closes #15, #55, #56) 2026-03-19 18:57:21 +00:00
you
6b10f675b1 fix: WS debounce helper, clean up remaining window globals (closes #7, #8) 2026-03-19 16:51:34 +00:00
you
09f29b11a5 fix: live.js — feed overflow, interval leaks, LCD ghost color, VCR aria-labels (closes #1, #2, #3, #11) 2026-03-19 16:47:15 +00:00
you
18ccd1139a fix: home.js listener stacking, packets.js filter bar rebuild (closes #4, #6) 2026-03-19 16:46:41 +00:00
you
d850a16d65 fix: make table rows keyboard-accessible with delegated event listeners (fixes #9)
- Replace inline onclick on <tr> elements with data-action/data-value attributes
- Add tabindex="0" and role="row" to all clickable rows
- Add delegated click and keydown (Enter/Space) listeners on containers
- Remove window._pktSelect, _pktToggleGroup, _pktSelectHash, _nodeSelect globals
- Convert to local functions referenced by delegated handlers

Affected files: packets.js, nodes.js, analytics.js
(channels.js and observers.js had no interactive <tr> elements)
2026-03-19 15:50:18 +00:00
you
df0acbfe81 fix: escape decoded.text and decoded.name in innerHTML to prevent XSS (fixes #5)
- nodes.js: escape decoded.text and decoded.name in advertisement list rendering
- packets.js: escape decoded.text in summary display and decoded.name in fieldRow
2026-03-19 15:46:04 +00:00
31 changed files with 3356 additions and 601 deletions

42
CHANGELOG.md Normal file
View 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
View 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 (SunSat) × 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

View File

@@ -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.
![Live VCR playback — watch packets flow across the Bay Area mesh](docs/screenshots/MeshVCR.gif)
### 📦 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.
![Packets view](docs/screenshots/packets1.png)
### 🗺️ Network Overview
At-a-glance mesh stats — node counts, packet volume, observer coverage.
![Network overview](docs/screenshots/mesh-overview.png)
### 🔀 Route Patterns
Visualize how packets traverse the mesh — see which repeaters carry the most traffic and identify routing patterns.
![Route patterns](docs/screenshots/route-patterns.png)
### 📊 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.
![Node analytics](docs/screenshots/node-analytics.png)
### 💬 Channel Chat
Decoded group messages with sender names, @mentions, timestamps — like reading a Discord channel for your mesh.
![Channels](docs/screenshots/channels1.png)
### 📱 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
View File

@@ -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 };

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

View File

@@ -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": {

View File

@@ -2,17 +2,10 @@
'use strict';
(function () {
let _analyticsData = {};
function esc(s) { return s ? String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : ''; }
// --- 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 });
})();

View File

@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
/* 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
}

View File

@@ -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 });
})();

View File

@@ -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, '&#39;')}'>
<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 });

View File

@@ -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>

View File

@@ -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)); }

View File

@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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) {

View File

@@ -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
View 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">AF 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 });
})();

View File

@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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 });
})();

View File

@@ -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 });
})();

View File

@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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 });
})();

View File

@@ -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; }

View File

@@ -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>

View 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)

View 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.

View 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
View 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.

View File

@@ -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);